diff --git a/.agents/skills/task-think/PROMPTS.md b/.agents/skills/task-think/PROMPTS.md index ad804219a4..aef21013b6 100644 --- a/.agents/skills/task-think/PROMPTS.md +++ b/.agents/skills/task-think/PROMPTS.md @@ -1,35 +1,101 @@ # Phase Prompts -Use these templates for `codex exec --json` child runs. Replace ``, ``, ``, and ``. +Use these templates as Codex subagent messages. Use them as same-session checklists only for Phase 0, intentional main-session build work, Phase 7, or when delegation is unavailable from the start. Replace ``, ``, ``, and ``. + +## Orchestration Rules + +- Phase 0 runs in the main session. +- When delegation is available, use a fresh subagent for Phase 1, Phase 2, Phase 3, each Phase 4 implementation unit, and each Phase 6 pass. Do not switch those phases to same-session midstream because of a timeout or missing artifact. +- Phase 7 runs in the main session on Windows because it depends on the final local diff and touched-file set. +- Write each phase prompt to `.ai///logs/phase-.prompt.md` before execution. +- If you delegate a phase, send the prompt file contents as the initial `spawn_agent` message. +- When writing the phase prompt file, append the standard progress file contract and the standard compact reply block below so the subagent knows how to surface progress before the final artifact. +- After each phase completes, write `.ai///logs/phase-.result.md` summarizing the status, files touched, and any follow-up notes. +- Use `fork_context: false` by default. If the phase depends on thread-only context or UI attachments, pass that context explicitly or enable `fork_context` only for that phase. +- Prefer `worker` for phases that write files. Use `default` for plan or review passes if that fits the host better. Use `explorer` only for narrow read-only questions. +- When supported, request `model: gpt-5.4` and `reasoning_effort: xhigh` for delegated phases. +- Default wait budget for delegated phases is 5 minutes while the phase is clearly still in progress. Successful completion may wake earlier, so this does not delay finished work. +- When a phase appears close to landing, use 1-2 minute waits until it finishes. +- A `wait_agent` timeout is not failure. On timeout, inspect both the expected artifact and the matching progress file before deciding anything. +- If the expected artifact exists and shows progress, wait again. +- If the expected artifact is not ready but the progress file mtime moved or its heartbeat counter increased since the previous check, wait again. Prefer mtime checks first and avoid rereading the file unless you need detail. Do not count that as a failed wait. +- If neither the expected artifact nor the progress file moved since the previous blocked check, send one short follow-up asking the same agent to refresh the progress file, finish the required artifact, and return the standard compact reply block, then wait again. +- If the same agent still produces no usable artifact and no meaningful progress-file movement after two full default waits and one follow-up, close it and retry the phase in a fresh subagent. +- For Phase 1, Phase 2, Phase 3, Phase 4, and Phase 6, if delegated retries still fail, stop and ask the user rather than rerunning the phase locally. +- Never use `codex exec`, background shell child processes, or JSONL child-session logging from this skill. + +## Standard Progress File Contract + +Append this verbatim to every delegated phase prompt: + +```text +Before deep work, create or update the matching progress file in `.ai///logs/`. + +Use `.progress.md` as a concise heartbeat with: +- `Heartbeat: ` on the first line, incremented on each meaningful update +- Current step +- Files being read or edited +- Concrete findings or decisions so far +- Blocker or next checkpoint + +Update it sparingly: preferably at natural milestones, and otherwise only after a longer quiet stretch such as roughly 5-10 minutes. +Keep it tiny so the parent can usually rely on file mtime or the heartbeat counter instead of rereading the whole file. +Do not wait until the final artifact to write progress. +``` + +## Standard Compact Reply Block + +Append this verbatim to every delegated phase prompt: + +```text +Before replying in chat, write the required artifact(s) to disk. + +Reply in 8 lines or fewer using exactly these keys: +STATUS: +ARTIFACTS: +TOUCHED: +BLOCKER: + +Do not restate the full context, plan, diff, or long reasoning in the chat reply. +``` + +## Artifact-Based Completion Checks + +- Phase 1 is complete only when `about.md` and `context.md` both exist and are non-empty. +- Phase 2 is complete only when `plan.md` exists, contains a `## Status` section, and no unintended source edits were made. +- Phase 3 is complete only when `plan.md` contains both `Phases:` in the Status section and `Assessed: yes`. +- Phase 4 is complete only when the target phase checkbox changed to checked and the touched-file list matches the owned write set, or the blocker explains any mismatch. +- Phase 5 is complete only when the build outcome is known and the build checkbox is updated on success. +- Phase 6a is complete only when `review.md` exists and contains a verdict line. +- Phase 6b is complete only when the requested fixes were applied and the post-fix build outcome is known. ## Phase 0: Setup -**Record the current time now** and store it as `$START_TIME`. You will use this at the end to display total elapsed time. +Record the current time now and store it as `$START_TIME`. You will use this at the end to display total elapsed time. -Before running any phase prompts, the orchestrator must determine whether this is a new project or a follow-up task. +Before running any phase prompts, determine whether this is a new project or a follow-up task. -**Follow-up detection (MANDATORY — do this BEFORE anything else):** -1. Extract the first word/token from the task description. Call it `FIRST_TOKEN`. -2. Run these two checks IN PARALLEL: - - `ls .ai/` — to see all existing project names - - `ls .ai//about.md` — to check if this specific project exists -3. If check 2 **succeeds** (the file exists): this is a **follow-up task**. The project name is `FIRST_TOKEN`. The task description is everything after `FIRST_TOKEN`. -4. If check 2 **fails** (file not found): this is a **new project**. The full input is the task description. +Follow-up detection: +1. Extract the first word or token from the task description. Call it `FIRST_TOKEN`. +2. Check `.ai/` to see existing project names. +3. Check whether `.ai//about.md` exists. +4. If the file exists, this is a follow-up task. The project name is `FIRST_TOKEN`. The task description is everything after `FIRST_TOKEN`. +5. If the file does not exist, this is a new project. The full input is the task description. -**Do NOT proceed until you have run these checks and determined follow-up vs new.** +Do not proceed until you have determined follow-up vs new. -**For new projects:** -- Using the list from check 1, pick a unique short name (1-2 lowercase words, hyphen-separated) that doesn't collide with existing projects. -- Create `.ai//` and `.ai//a/` and `logs/`. +For new projects: +- Using the list of existing projects, pick a unique short name (1-2 lowercase words, hyphen-separated) that does not collide. +- Create `.ai//`, `.ai//a/`, and `.ai//a/logs/`. - Set `` = `a`. -**For follow-up tasks:** +For follow-up tasks: - Scan `.ai//` for existing task folders (`a/`, `b/`, ...). Find the latest one (highest letter). - The previous task letter = that highest letter. - The new task letter = next letter in sequence. -- Create `.ai///` and `logs/`. +- Create `.ai///` and `.ai///logs/`. -Then proceed to Phase 1. Follow-up tasks do NOT skip context gathering — they go through a modified version of it. +Then proceed to Phase 1. Follow-up tasks do not skip context gathering. They use a modified Phase 1F prompt. ## Phase 1: Context (New Project, letter = `a`) @@ -38,50 +104,50 @@ You are a context-gathering agent for a large C++ codebase (Telegram Desktop). TASK: -YOUR JOB: Read AGENTS.md, inspect the codebase, find ALL files and code relevant to this task, and write two documents. +YOUR JOB: Read AGENTS.md, inspect the codebase, find all files and code relevant to this task, and write two documents. Steps: 1. Read AGENTS.md for project conventions and build instructions. 2. Search the codebase for files, classes, functions, and patterns related to the task. -3. Read all potentially relevant files. Be thorough - read more rather than less. +3. Read all potentially relevant files. Be thorough and prefer reading more rather than less. 4. For each relevant file, note: - - File path - - Relevant line ranges - - What the code does and how it relates to the task - - Key data structures, function signatures, patterns used + - file path + - relevant line ranges + - what the code does and how it relates to the task + - key data structures, function signatures, and patterns used 5. Look for similar existing features that could serve as a reference implementation. 6. Check api.tl if the task involves Telegram API. 7. Check .style files if the task involves UI. 8. Check lang.strings if the task involves user-visible text. -Write TWO files: +Write two files. -### File 1: .ai//about.md +File 1: .ai//about.md -NOTE: This file is NOT used by any agent in the current task. It exists solely as a starting point for a FUTURE follow-up task's context gatherer. No planning, implementation, or review agent will ever read it. Only the context-gathering agent of the next follow-up task reads about.md (together with the latest context.md) to produce a fresh context.md for that next task. +This file is not used by any agent in the current task. It exists solely as a starting point for a future follow-up task's context gatherer. No planning, implementation, or review phase should rely on it during the current task. Write it as if the project is already fully implemented and working. It should contain: -- **Project**: What this project does (feature description, goals, scope) -- **Architecture**: High-level architectural decisions, which modules are involved, how they interact -- **Key Design Decisions**: Important choices made about the approach -- **Relevant Codebase Areas**: Which parts of the codebase this project touches, key types and APIs involved +- Project: What this project does (feature description, goals, scope) +- Architecture: High-level architectural decisions, which modules are involved, how they interact +- Key Design Decisions: Important choices made about the approach +- Relevant Codebase Areas: Which parts of the codebase this project touches, key types and APIs involved -Do NOT include temporal state like "Current State", "Pending Changes", "Not yet implemented", "TODO", or any other framing that distinguishes between "done" and "not done". Describe the project as a complete, coherent whole — as if everything is already working. This is a project overview, not a status tracker. Task-specific work belongs exclusively in context.md. +Do not include temporal state like "Current State", "Pending Changes", "Not yet implemented", or "TODO". Describe the project as a complete, coherent whole. -### File 2: .ai//a/context.md +File 2: .ai//a/context.md -This is the task-specific implementation context. This is the PRIMARY document — all downstream agents (planning, implementation, review) will read ONLY this file. It must be completely self-contained. It should contain: -- **Task Description**: The full task restated clearly -- **Relevant Files**: Every file path with line ranges and descriptions of what's there -- **Key Code Patterns**: How similar things are done in the codebase (with code snippets) -- **Data Structures**: Relevant types, structs, classes -- **API Methods**: Any TL schema methods involved (copied from api.tl) -- **UI Styles**: Any relevant style definitions -- **Localization**: Any relevant string keys -- **Build Info**: Build command and any special notes -- **Reference Implementations**: Similar features that can serve as templates +This is the primary task-specific implementation context. All downstream phases should be able to work from this file plus the referenced source files. It must be self-contained. Include: +- Task Description: The full task restated clearly +- Relevant Files: Every file path with line ranges and descriptions +- Key Code Patterns: How similar things are done in the codebase, with snippets when useful +- Data Structures: Relevant types, structs, classes +- API Methods: Any TL schema methods involved, copied from api.tl when useful +- UI Styles: Any relevant style definitions +- Localization: Any relevant string keys +- Build Info: Build command and any special notes +- Reference Implementations: Similar features that can serve as templates -Be extremely thorough. Another agent with NO prior context will read this file and must be able to understand everything needed to implement the task. +Be extremely thorough. Another agent with no prior context will rely on this file. Do not implement code in this phase. ``` @@ -97,49 +163,44 @@ YOUR JOB: Read the existing project state, gather any additional context needed, Steps: 1. Read AGENTS.md for project conventions and build instructions. -2. Read .ai//about.md — this is the project-level blueprint describing everything done so far. -3. Read .ai///context.md — this is the previous task's gathered context. +2. Read .ai//about.md. This is the project-level blueprint describing everything done so far. +3. Read .ai///context.md. This is the previous task's gathered context. 4. Understand what has already been implemented by reading the actual source files referenced in about.md and the previous context. -5. Based on the NEW TASK description, search the codebase for any ADDITIONAL files, classes, functions, and patterns that are relevant to the new task but not already covered. +5. Based on the new task description, search the codebase for any additional files, classes, functions, and patterns that are relevant to the new task but not already covered. 6. Read all newly relevant files thoroughly. -Write TWO files: +Write two files. -### File 1: .ai//about.md (REWRITE) +File 1: .ai//about.md (rewrite) -NOTE: This file is NOT used by any agent in the current task. It exists solely as a starting point for a FUTURE follow-up task's context gatherer. No planning, implementation, or review agent will ever read it. You are rewriting it now so that the next follow-up has an accurate project overview to start from. - -REWRITE this file (not append). The new about.md must be a single coherent document that describes the project as if everything — including this new task's changes — is already fully implemented and working. +Rewrite this file instead of appending to it. The new about.md must be a single coherent document that describes the project as if everything, including this new task's changes, is already fully implemented and working. It should incorporate: -- Everything from the old about.md that is still accurate and relevant -- The new task's functionality described as part of the project (not as "changes to make") -- Any changed design decisions or architectural updates from the new task requirements +- everything from the old about.md that is still accurate and relevant +- the new task's functionality described as part of the project, not as a pending change +- any changed design decisions or architectural updates from the new task requirements -It should NOT contain: -- Any temporal state: "Current State", "Pending Changes", "TODO", "Not yet implemented" -- History of how requirements changed between tasks -- References to "the old approach" vs "the new approach" -- Task-by-task changelog or timeline -- Any distinction between "what was done before" and "what this task adds" -- Information that contradicts the new task requirements (if the new task changes direction, the about.md should reflect the NEW direction as if it was always the plan) +It should not contain: +- temporal state such as "Current State", "Pending Changes", or "TODO" +- history of how requirements changed between tasks +- references to "the old approach" versus "the new approach" +- task-by-task changelog or timeline +- information that contradicts the new task requirements -### File 2: .ai///context.md +File 2: .ai///context.md -This is the PRIMARY document — all downstream agents (planning, implementation, review) will read ONLY this file. It must be completely self-contained. about.md will NOT be available to them. +This is the primary document for the new task. It must be self-contained and should include: +- Task Description: The new task restated clearly, with enough project background that an implementation agent can understand it without reading any other .ai files +- Relevant Files: Every file path with line ranges relevant to this task +- Key Code Patterns: How similar things are done in the codebase +- Data Structures: Relevant types, structs, classes +- API Methods: Any TL schema methods involved +- UI Styles: Any relevant style definitions +- Localization: Any relevant string keys +- Build Info: Build command and any special notes +- Reference Implementations: Similar features that can serve as templates -It should contain: -- **Task Description**: The new task restated clearly, with enough project background (from about.md and previous context.md) that an implementation agent can understand it without reading any other .ai/ files -- **Relevant Files**: Every file path with line ranges relevant to THIS task (including files modified by previous tasks and any newly relevant files) -- **Key Code Patterns**: How similar things are done in the codebase -- **Data Structures**: Relevant types, structs, classes -- **API Methods**: Any TL schema methods involved -- **UI Styles**: Any relevant style definitions -- **Localization**: Any relevant string keys -- **Build Info**: Build command and any special notes -- **Reference Implementations**: Similar features that can serve as templates - -Be extremely thorough. Another agent with NO prior context will read ONLY this file and must be able to understand everything needed to implement the new task. Do NOT assume the reader has seen about.md or any previous task files. The context.md is the single source of truth for all downstream agents — it must include all relevant project background, not just the delta. +Be extremely thorough. Another agent with no prior context should be able to work from this file alone. Do not implement code in this phase. ``` @@ -150,7 +211,7 @@ Do not implement code in this phase. You are a planning agent. You must create a detailed implementation plan. Read these files: -- .ai///context.md - Contains all gathered context for this task +- .ai///context.md - Then read the specific source files referenced in context.md to understand the code deeply. Create a detailed plan in: .ai///plan.md @@ -172,24 +233,23 @@ The plan.md should contain: ## Implementation Steps Each step must be specific enough that an agent can execute it without ambiguity: -- Exact file paths -- Exact function names -- What code to add/modify/remove -- Where exactly in the file (after which function, in which class, etc.) +- exact file paths +- exact function names +- what code to add, modify, or remove +- where exactly in the file (after which function, in which class, and so on) -Number every step. Group steps into phases if there are more than ~8 steps. +Number every step. Group steps into phases if there are more than about eight steps. ### Phase 1: 1. 2. -... ### Phase 2: (if needed) -... +1. ## Build Verification -- Build command to run -- Expected outcome +- build command to run +- expected outcome ## Status - [ ] Phase 1: @@ -212,59 +272,68 @@ Read these files: Assess the plan: -1. **Correctness**: Are the file paths and line references accurate? Does the plan reference real functions and types? -2. **Completeness**: Are there missing steps? Edge cases not handled? -3. **Code quality**: Will the plan minimize code duplication? Does it follow existing codebase patterns from AGENTS.md? -4. **Design**: Could the approach be improved? Are there better patterns already used in the codebase? -5. **Phase sizing**: Each phase should be implementable by a single agent in one session. If a phase has more than ~8-10 substantive code changes, split it further. +1. Correctness: Are the file paths and line references accurate? Does the plan reference real functions and types? +2. Completeness: Are there missing steps? Edge cases not handled? +3. Code quality: Will the plan minimize code duplication? Does it follow existing codebase patterns from AGENTS.md? +4. Design: Could the approach be improved? Are there better patterns already used in the codebase? +5. Phase sizing: Each phase should be implementable by a single agent in one session. If a phase has more than about 8-10 substantive code changes, split it further. Update plan.md with your refinements. Keep the same structure but: -- Fix any inaccuracies -- Add missing steps -- Improve the approach if you found better patterns -- Ensure phases are properly sized for single-agent execution -- Add a line at the top of the Status section: `Phases: ` indicating how many implementation phases there are -- Add `Assessed: yes` at the bottom of the file +- fix any inaccuracies +- add missing steps +- improve the approach if you found better patterns +- ensure phases are properly sized for single-agent execution +- add a line at the top of the Status section: `Phases: ` +- add `Assessed: yes` at the bottom of the file -If the plan is small enough for a single agent (roughly <=8 steps), mark it as a single phase. +If the plan is small enough for a single agent (roughly 8 steps or fewer), mark it as a single phase. Do not implement code in this phase. ``` ## Phase 4: Implementation -For each phase in the plan that is not yet marked as done, run a separate child session: +Run one implementation unit per plan phase. Keep implementation phases sequential by default. Parallelize only if their write sets are disjoint and the plan makes that safe. + +For each phase in the plan that is not yet marked as done, use this prompt: ```text You are an implementation agent working on phase of an implementation plan. Read these files first: -- .ai///context.md - Full codebase context -- .ai///plan.md - Implementation plan +- .ai///context.md +- .ai///plan.md -Then read the source files you'll be modifying. +Then read the source files you will be modifying. -YOUR TASK: Implement ONLY Phase from the plan: +Your owned write set for this phase: + + +YOUR TASK: Implement only Phase from the plan: Rules: -- Follow the plan precisely -- Follow AGENTS.md coding conventions (no comments except complex algorithms, use auto, empty line before closing brace, etc.) -- Do NOT modify .ai/ files except to update the Status section in plan.md +- Follow the plan precisely. +- Follow AGENTS.md coding conventions. +- You are not alone in the codebase. Respect existing changes and do not revert unrelated work. +- Do not modify .ai/ files except to update the Status section in plan.md. - When done, update plan.md Status section: change `- [ ] Phase : ...` to `- [x] Phase : ...` -- Do NOT work on other phases +- Do not work on other phases. -When finished, report what you did and any issues encountered. +When finished, report what you did, which files you changed, and any issues encountered. ``` -After each implementation agent returns: -1. Read `plan.md` to check the status was updated. -2. If more phases remain, run the next implementation child session. -3. If all phases are done, proceed to build verification. +After each implementation phase: +1. Use a narrow read or search to confirm the status line was updated. +2. Verify the owned write set and touched files with a small diff summary such as `git diff --name-only`. +3. If more phases remain, run the next implementation phase. +4. If all phases are done, proceed to build verification. ## Phase 5: Build Verification -Only run this phase if the task involved modifying project source code (not just docs or config). +Only run this phase if the task modified project source code. + +Prefer running the build in the main session because it is critical-path work. If you delegate it, use a worker subagent and wait immediately for the result. ```text You are a build verification agent. @@ -273,7 +342,7 @@ Read these files: - .ai///context.md - .ai///plan.md -The implementation is complete. Your job is to build the project and fix any build errors. +The implementation is complete. Your job is to build the project and fix any build errors that block the planned work. Steps: 1. Run (from repository root): cmake --build ./out --config Debug --target Telegram @@ -286,76 +355,66 @@ Steps: e. Update plan.md status when done Rules: -- Only fix build errors, do not refactor or improve code -- Follow AGENTS.md conventions -- If build fails with file-locked errors (C1041, LNK1104), STOP and report - do not retry +- Only fix build errors. Do not refactor or improve code beyond what is needed for a passing build. +- Follow AGENTS.md conventions. +- If build fails with file-locked errors (C1041, LNK1104, "cannot open output file", or similar access-denied lock issues), stop and report the lock. Do not retry. +- You are not alone in the codebase. Respect existing changes and do not revert unrelated work. -When finished, report the build result. +When finished, report the build result and which files, if any, you changed. ``` ## Phase 6: Code Review Loop -After build verification passes, run up to 3 review-fix iterations to improve code quality. Set iteration counter `R = 1`. +After build verification passes, run up to 3 review-fix iterations. Set iteration counter `R = 1`. -### Review Loop +Review loop: -``` +```text LOOP: - 1. Run review agent (Step 6a) with iteration R + 1. Run review phase 6a with iteration R. 2. Read review.md verdict: - - "APPROVED" → go to FINISH - - Has improvement suggestions → run fix agent (Step 6b) - 3. After fix agent completes and build passes: + - "APPROVED" -> go to FINISH + - "NEEDS_CHANGES" -> run fix phase 6b + 3. After fix work completes and build passes: R = R + 1 - If R > 3 → go to FINISH (stop iterating, accept current state) - Otherwise → go to step 1 + If R > 3 -> go to FINISH + Otherwise -> go to step 1 FINISH: - Update plan.md: change `- [ ] Code review` to `- [x] Code review` - - Proceed to Completion + - Proceed to Phase 7 on Windows, otherwise proceed to Completion ``` -### Step 6a: Code Review Agent +### Step 6a: Code Review ```text You are a code review agent for Telegram Desktop (C++ / Qt). Read these files: -- .ai///context.md - Codebase context -- .ai///plan.md - Implementation plan -- REVIEW.md - Style and formatting rules to enforce - 1, also read:> -- .ai///review.md - Previous review (to see what was already addressed) +- .ai///context.md +- .ai///plan.md +- REVIEW.md +- If R > 1, also read .ai///review.md -Then run `git diff` to see all uncommitted changes made by the implementation. Implementation agents do not commit, so `git diff` shows exactly the current feature's changes. +Then run `git diff` to see the current uncommitted changes for this task. -Then read the modified source files in full to understand changes in context. +Read the modified source files in full to understand the changes in context. -Perform a thorough code review. +Perform a focused code review using these criteria, in order: -REVIEW CRITERIA (in order of importance): +1. Correctness and safety: Obvious logic errors, missing null checks at API boundaries, potential crashes, use-after-free, dangling references, race conditions. +2. Dead code: Added or left-behind code that is never used within the scope of the changes. +3. Redundant changes: Diff hunks that have no functional effect. +4. Code duplication: Repeated logic that should be shared. +5. Wrong placement: Code added to a module where it does not logically belong. +6. Function decomposition: Whether an extracted helper would clearly improve readability. +7. Module structure: Only in exceptional cases where a large new chunk of code clearly belongs elsewhere. +8. Style compliance: REVIEW.md rules and AGENTS.md conventions. -1. **Correctness and safety**: Obvious logic errors, missing null checks at API boundaries, potential crashes, use-after-free, dangling references, race conditions. This is the highest priority — bugs and safety issues must be caught first. Do NOT nitpick internal code that relies on framework guarantees. - -2. **Dead code**: Any code added or left behind that is never called or used, within the scope of the changes. Unused variables, unreachable branches, leftover scaffolding. - -3. **Redundant changes**: Changes in the diff that have no functional effect — moving declarations or code blocks to a different location without reason, reformatting untouched code, reordering includes or fields with no purpose. Every line in the diff should serve the feature. If a file appears in `git diff` but contains only no-op rearrangements, flag it for revert. - -4. **Code duplication**: Unnecessary repetition of logic that should be shared. Look for near-identical blocks that differ only in minor details and could be unified. - -5. **Wrong placement**: Code added to a module where it doesn't logically belong. If another existing module is a clearly better fit for the new code, flag it. Consider the existing module boundaries and responsibilities visible in context.md. - -6. **Function decomposition**: For longer functions (roughly 50+ lines), consider whether a logical sub-task could be cleanly extracted into a separate function. This is NOT a hard rule — a 100-line function that flows naturally and isn't easily divisible is perfectly fine. But sometimes even a 20-line function contains a clear isolated subtask that reads better as two 10-line functions. The key is to think about it each time: does extracting improve readability and reduce cognitive load, or does it just scatter logic across call sites for no real benefit? Only suggest extraction when there's a genuinely self-contained piece of logic with a clear name and purpose. - -7. **Module structure**: Only in exceptional cases — if a large amount of newly added code (hundreds of lines) is logically distinct from the rest of its host module, suggest extracting it into a new module. But do NOT suggest new modules lightly: every module adds significant build overhead due to PCH and heavy template usage. Only suggest this when the new code is both large enough AND logically separated enough to justify it. At the same time, don't let modules grow into multi-thousand-line monoliths either. - -8. **Style compliance**: Verify adherence to REVIEW.md rules (empty line before closing brace, operators at start of continuation lines, minimize type checks with direct cast instead of is+as, no if-with-initializer when simpler alternatives exist) and AGENTS.md conventions (no unnecessary comments, `auto` usage, no hardcoded sizes — must use .style definitions), etc. - -IMPORTANT GUIDELINES: -- Review ONLY the changes made, not pre-existing code in the repository. -- Be pragmatic. Don't suggest changes for the sake of it. Each suggestion should have a clear, concrete benefit. -- Don't suggest adding comments, docstrings, or type annotations — the codebase style avoids these. -- Don't suggest error handling for impossible scenarios or over-engineering. +Important guidelines: +- Review only the changes made, not pre-existing code outside the scope of the task. +- Be pragmatic. Each suggestion should have a clear, concrete benefit. +- Do not suggest comments, docstrings, or over-engineering. Write your review to: .ai///review.md @@ -368,87 +427,133 @@ The review document should contain: ## Verdict: - - - +If the verdict is NEEDS_CHANGES, continue with: ## Changes Required ### -- **Category**: -- **File(s)**: -- **Problem**: -- **Fix**: +- Category: +- File(s): +- Problem: +- Fix: -### -... - -Keep the list focused. Only include issues that genuinely improve the code. If you find yourself listing more than ~5-6 issues, prioritize the most impactful ones. +Keep the list focused. Prioritize the most impactful issues. When finished, report your verdict clearly as: APPROVED or NEEDS_CHANGES. ``` -### Step 6b: Review Fix Agent +### Step 6b: Review Fix ```text You are a review fix agent. You implement improvements identified during code review. Read these files: -- .ai///context.md - Codebase context -- .ai///plan.md - Original implementation plan -- .ai///review.md - Code review with required changes +- .ai///context.md +- .ai///plan.md +- .ai///review.md Then read the source files mentioned in the review. -YOUR TASK: Implement ALL changes listed in review.md. +YOUR TASK: Implement all changes listed in review.md. -For each issue in the review: -1. Read the relevant source file(s). -2. Make the specified change. -3. Verify the change makes sense in context. +Rules: +- Implement exactly the review changes, nothing more. +- Follow AGENTS.md coding conventions. +- You are not alone in the codebase. Respect existing changes and do not revert unrelated work. +- Do not modify .ai/ files except where the review process explicitly requires it. After all changes are made: 1. Build (from repository root): cmake --build ./out --config Debug --target Telegram 2. If the build fails, fix build errors and rebuild until it passes. -3. If build fails with file-locked errors (C1041, LNK1104), STOP and report - do not retry. +3. If build fails with file-locked errors (C1041, LNK1104, "cannot open output file", or similar access-denied lock issues), stop and report the lock. Do not retry. + +When finished, report what changes were made and which files you touched. +``` + +## Phase 7: Windows Text Normalization + +Run this phase only on Windows hosts and only after the review loop has finished. + +Use the current task's result logs as the source of truth for what Codex touched. Do not sweep the whole repo and do not rewrite unrelated files from a dirty worktree. + +```text +You are performing the final Windows-only text normalization phase for task-think. + +Read these files: +- .ai///plan.md +- .ai///logs/phase-4*.result.md +- .ai///logs/phase-5*.result.md +- .ai///logs/phase-6*.result.md + +Your job: +- Collect the union of repo file paths listed under "Touched files" in those result logs. +- Keep only files inside the repository that currently exist and are textual project files: source, headers, build/config files, localization files, style files, and similar text assets. +- Exclude `.ai/`, `out/`, binary files, and unrelated user files that were not touched by Codex in this task. +- Rewrite each kept file so all line endings are CRLF. +- If a kept file is UTF-8 or ASCII text, write it back as UTF-8 without BOM. Never add a UTF-8 BOM to source/config/project text files. +- Preserve file content otherwise. Preserve whether the file ended with a trailing newline. Rules: -- Implement exactly the changes from the review, nothing more. -- Follow AGENTS.md coding conventions. -- Do NOT modify .ai/ files. +- Run this phase in the main session on Windows. +- Do not modify files outside the touched-file set for the current task. +- Do not rewrite binary files. +- When scripting this phase, do not use writer APIs or defaults that emit UTF-8 with BOM. +- If a file cannot be normalized safely, record it as a failure instead of silently skipping it. -When finished, report what changes were made. +When finished: +1. Write `.ai///logs/phase-7-line-endings.result.md` +2. Include: + - whether the phase completed + - which files were normalized + - which files were skipped and why + - whether any UTF-8 BOMs were removed or verified absent + - any failures that need to be mentioned in the final summary ``` ## Completion -When all phases including build verification and code review are done: +When all phases, including build verification, code review, and Windows line ending normalization when applicable, are done: 1. Read the final `plan.md` and report the summary to the user. -2. Show which files were modified/created. +2. Show which files were modified or created. 3. Note any issues encountered during implementation. -4. Summarize code review iterations: how many rounds, what was found and fixed, or if it was approved on first pass. -5. Calculate and display the total elapsed time since `$START_TIME` (format as `Xh Ym Zs`, omitting zero components — e.g. `12m 34s` or `1h 5m 12s`). -6. Remind the user of the project name so they can request follow-up tasks within the same project. +4. Summarize the code review iterations: how many rounds, what was found and fixed, or whether it was approved on the first pass. +5. On Windows, mention the text-normalization result briefly: which project files were normalized, whether any BOMs were removed, or whether nothing needed changes. +6. Calculate and display the total elapsed time since `$START_TIME` (format as `Xh Ym Zs`, omitting zero components). +7. Remind the user of the project name so they can request follow-up tasks within the same project. ## Error Handling -- If any phase fails or gets stuck, report the issue to the user and ask how to proceed. -- If context.md or plan.md is not written properly by a phase, re-run that phase with more specific instructions. +- If any phase fails or gets stuck, follow the timeout and retry rules above. Do not close an agent solely because the final artifact is missing while its progress file is still advancing. For Phase 1, Phase 2, Phase 3, Phase 4, and Phase 6, do not rerun locally after delegated retries fail; ask the user instead. +- If `context.md` or `plan.md` is not written properly by a phase, rerun that phase in a fresh subagent with more specific instructions. - If build errors persist after the build phase's attempts, report the remaining errors to the user. -- If a review fix phase introduces new build errors that it cannot resolve, report to the user. +- If a review-fix phase introduces new build errors that it cannot resolve, report to the user. -## Reasoning Effort +## Prompt Delivery And Logs -Phases 2 (Plan), 3 (Assessment), and 6a (Review) require elevated reasoning. Pass `-c model_reasoning_effort="xhigh"` on those `codex exec` invocations. All other phases use the default reasoning effort. +For each phase: +1. Write the full prompt to `.ai///logs/phase-.prompt.md` +2. Delegate by sending that prompt text to a fresh subagent, or use it as a same-session checklist only for the designated main-session phases or when delegation was unavailable from the start +3. For delegated phases, expect a matching `.ai///logs/phase-.progress.md` heartbeat while work is in flight +4. Save a concise completion note to `.ai///logs/phase-.result.md` -## Example Runner Commands +For review iterations, include the iteration in the file name, for example: +- `phase-6a-review-1.prompt.md` +- `phase-6a-review-1.result.md` +- `phase-6b-fix-1.prompt.md` +- `phase-6b-fix-1.result.md` -```powershell -codex exec --json -C "" | Tee-Object .ai///logs/phase-1-context.jsonl -codex exec --json -C -c model_reasoning_effort="xhigh" "" | Tee-Object .ai///logs/phase-2-plan.jsonl -codex exec --json -C -c model_reasoning_effort="xhigh" "" | Tee-Object .ai///logs/phase-3-assess.jsonl -codex exec --json -C "" | Tee-Object .ai///logs/phase-4-impl-N.jsonl -codex exec --json -C "" | Tee-Object .ai///logs/phase-5-build.jsonl -codex exec --json -C -c model_reasoning_effort="xhigh" "" | Tee-Object .ai///logs/phase-6a-review-R.jsonl -codex exec --json -C "" | Tee-Object .ai///logs/phase-6b-fix-R.jsonl -``` +## Subagent Pattern + +Use this pattern conceptually for delegated phases: + +1. Write the phase prompt file. +2. Spawn a fresh subagent with the phase prompt, usually with `fork_context: false`. +3. Require the agent to create the matching progress file early and refresh it sparingly: at natural milestones when possible, otherwise only after a longer quiet stretch such as roughly 5-10 minutes. +4. Wait in 5-minute intervals when the next step is blocked on that phase, checking both the final artifact and the progress file on timeout. +5. When the phase looks close to finishing, switch to 1-2 minute waits. +6. Prefer filesystem mtime checks on the progress file first. If its mtime moved or the heartbeat counter increased, keep waiting; do not treat that as a stall. +7. If neither the artifact nor the progress file moves, send one short follow-up to the same agent, then retry once with a fresh subagent before involving the user. +8. Validate the expected artifact or code changes with small shell summaries and the completion checks above. +9. Write the result log from the validated outcome and the compact reply block. + +Do not replace this pattern with shell-launched `codex exec`. diff --git a/.agents/skills/task-think/SKILL.md b/.agents/skills/task-think/SKILL.md index 3f332e7887..939e78a427 100644 --- a/.agents/skills/task-think/SKILL.md +++ b/.agents/skills/task-think/SKILL.md @@ -1,6 +1,6 @@ --- name: task-think -description: Orchestrate a multi-phase implementation workflow for this repository with artifact files under .ai/// and fresh codex exec child runs per phase. Use when the user wants one prompt to drive context gathering, planning, plan assessment, implementation, build verification, and review iterations while keeping the main session context clean. +description: Orchestrate a multi-phase implementation workflow for this repository with artifact files under .ai/// using Codex subagents instead of shell-spawned child processes. Use when the user wants one prompt to drive context gathering, planning, plan assessment, implementation, build verification, and review with persistent artifacts, clear phase handoffs, and a thin parent thread. Prefer spawn_agent/send_input/wait_agent, keep heavy pre-build work delegated when possible, and avoid pulling timed-out phases back into the main session. --- # Task Pipeline @@ -15,14 +15,14 @@ Collect: - optional constraints (files, architecture, risk tolerance) - optional screenshot paths -If screenshots are attached in UI but not present as files, write a brief textual summary in `.ai//about.md` so child runs can consume the requirements. +If screenshots are attached in UI but not present as files, write a brief textual summary into the task artifacts before spawning fresh subagents so later phases can read the requirements without inheriting the whole parent thread. ## Overview -The workflow is organized around **projects**. Each project lives in `.ai//` and can contain multiple sequential **tasks** (labeled `a`, `b`, `c`, ... `z`). +The workflow is organized around projects. Each project lives in `.ai//` and can contain multiple sequential tasks (labeled `a`, `b`, `c`, ... `z`). Project structure: -``` +```text .ai// about.md # Single source of truth for the entire project a/ # First task @@ -31,16 +31,22 @@ Project structure: review1.md # Code review documents (up to 3 iterations) review2.md review3.md + logs/ + phase-*.prompt.md + phase-*.progress.md + phase-*.result.md b/ # Follow-up task context.md plan.md review1.md + logs/ + ... c/ # Another follow-up task ... ``` -- `about.md` is the project-level blueprint — a single comprehensive document describing what this project does and how it works, written as if everything is already fully implemented. It contains no temporal state ("current state", "pending changes", "not yet implemented"). It is **rewritten** (not appended to) each time a new task starts, incorporating the new task's changes as if they were always part of the design. -- Each task folder (`a/`, `b/`, ...) contains self-contained files for that task. The task's `context.md` carries all task-specific information: what specifically needs to change, the delta from the current codebase, gathered file references and code patterns. Planning, implementation, and review agents only read the current task's folder. +- `about.md` is the project-level blueprint: a single comprehensive document describing what this project does and how it works, written as if everything is already fully implemented. It contains no temporal state ("current state", "pending changes", "not yet implemented"). It is rewritten, not appended to, each time a new task starts, incorporating the new task's changes as if they were always part of the design. +- Each task folder (`a/`, `b/`, ...) contains self-contained files for that task. The task's `context.md` carries all task-specific information: what specifically needs to change, the delta from the current codebase, gathered file references, and code patterns. Planning, implementation, and review phases should rely on the current task folder. ## Artifacts @@ -49,38 +55,64 @@ Create and maintain: - `.ai///context.md` - `.ai///plan.md` - `.ai///review.md` (up to 3 review iterations) -- `.ai///logs/phase-*.jsonl` (when running child `codex exec`) +- `.ai///logs/phase-.prompt.md` +- `.ai///logs/phase-.progress.md` for delegated phases +- `.ai///logs/phase-.result.md` + +Each `phase-.result.md` should capture a concise outcome summary: whether the phase completed, which files it touched, and any follow-up notes or blockers. +Each delegated `phase-.progress.md` should act as a heartbeat: a tiny monotonic counter plus current step, files being read or edited, concrete findings so far, and the next checkpoint. It is not a final artifact; it exists so the parent can distinguish active research from a truly stuck subagent without rereading large context. ## Phases -The workflow runs these phases sequentially via `codex exec --json` child sessions: +Run these phases sequentially: -1. **Phase 0: Setup** — Record start time, detect follow-up vs new project, create directories. -2. **Phase 1: Context Gathering** — Read codebase, write `about.md` and `context.md`. (Phase 1F for follow-ups.) -3. **Phase 2: Planning** — Read context, write detailed `plan.md` with numbered steps grouped into phases. -4. **Phase 3: Plan Assessment** — Review and refine the plan for correctness, completeness, code quality, and phase sizing. -5. **Phase 4: Implementation** — One child session per plan phase. Each implements only its assigned phase and updates `plan.md` status. -6. **Phase 5: Build Verification** — Build the project, fix any build errors. Skip if no source code was modified. -7. **Phase 6: Code Review Loop** — Up to 3 review-fix iterations: - - 6a: Review agent writes `review.md` with verdict (APPROVED or NEEDS_CHANGES). - - 6b: Fix agent implements review changes and rebuilds. - - Loop until APPROVED or R > 3. +1. Phase 0: Setup - Record start time, detect follow-up vs new project, create directories. +2. Phase 1: Context Gathering - Read codebase, write `about.md` and `context.md`. Use Phase 1F for follow-up tasks. +3. Phase 2: Planning - Read context, write detailed `plan.md` with numbered steps grouped into phases. +4. Phase 3: Plan Assessment - Review and refine the plan for correctness, completeness, code quality, and phase sizing. +5. Phase 4: Implementation - Execute one implementation unit per plan phase. +6. Phase 5: Build Verification - Build the project, fix any build errors. Skip if no source code was modified. +7. Phase 6: Code Review Loop - Run review and fix iterations until approved or the iteration limit is reached. +8. Phase 7: Windows Text Normalization - On Windows only, after review passes and before the final summary, normalize LF to CRLF for the text source/config files Codex edited in this task and ensure rewritten UTF-8 project files are saved without BOM. Use the phase prompt templates in `PROMPTS.md`. ## Execution Mode -Run `codex exec --json` child sessions for each phase. Wait for each to finish before starting the next. After each phase, validate that the expected artifact file exists and has substantive content. +Use Codex subagents as the primary orchestration mechanism. -Phases that require elevated reasoning (Planning, Plan Assessment, Code Review) must use `-c model_reasoning_effort="xhigh"`. See example commands in `PROMPTS.md`. +- When delegation is available, Phase 1, Phase 2, Phase 3, each Phase 4 implementation unit, and each Phase 6 review or review-fix pass must run in fresh subagents. Do not rerun those phases in the main session midstream just because a wait timed out or an artifact is missing. +- Run Phase 7 in the main session on Windows because it depends on the final local file state and the exact touched-file set for the current task. +- When any same-session helper rewrites Windows project text files, preserve CRLF and write UTF-8 without BOM. Avoid writer APIs or defaults that silently inject a UTF-8 BOM. +- The main session may read `context.md` once after Phase 1 and `plan.md` once after Phase 3. After that, prefer narrow shell checks, file existence checks, and status-line reads instead of rereading full documents or diffs. +- Prefer `worker` for phases that write files. Use `explorer` only for narrow read-only questions that unblock your next local step. +- Keep `fork_context` off by default. Pass the phase prompt and explicit file paths instead of the whole thread unless the phase truly needs prior conversational context or thread-only attachments. +- When the platform supports it, request `model: gpt-5.4` and `reasoning_effort: xhigh` for spawned phase agents. If overrides are unavailable, inherit the current session settings. +- Write the exact phase prompt to the matching `logs/phase-.prompt.md` file before you delegate. Use the same prompt file as a checklist if you later need to fall back to same-session execution. +- For delegated phases, require an early `logs/phase-.progress.md` heartbeat before deep work. The subagent should create or update it early, keep it tiny, and refresh it sparingly: preferably at natural milestones, and otherwise only after a longer quiet stretch such as roughly 5-10 minutes. +- In every delegated prompt, require a compact final reply with only status, artifact paths, touched files, and blocker or `none`. Detailed reasoning belongs in `.ai/` artifacts, not in the chat reply. +- After a subagent finishes, verify that the expected artifacts or code changes exist, then write a short result log in `logs/phase-.result.md`. +- For delegated phases, use `wait_agent` with a 5-minute timeout by default while a phase is still clearly in progress. Successful completion may wake earlier, so this does not add latency to finished phases. +- When a phase looks close to completion — for example the final artifact has appeared, a build is in its final pass, or the agent said it is wrapping up — switch to 1-2 minute waits until it lands. +- A timeout is not a failure; it only means no final status arrived yet. Do not treat short waits as stall detection for research-heavy phases. +- On timeout, inspect the expected artifact, the phase progress file mtime, and the worktree for movement. Prefer mtime checks first; only reread the progress file when you need detail. +- If the progress file mtime moved or its heartbeat counter increased since the previous check, treat that as active progress and wait again. +- If no usable final artifact exists yet but the progress file is appearing or advancing, keep the same subagent alive. Progress-file movement does not count toward the retry limit. +- If no usable final artifact exists yet and neither the expected artifact nor the progress file has moved since the previous blocked check, send one short follow-up asking the same subagent to refresh the progress file, finish the artifact, and return the compact status block, then wait again. +- Only if the same subagent still shows no meaningful movement in either the expected artifact or the progress file after two full default waits and one follow-up should you close it and rerun that phase in a fresh subagent. +- Use `wait_agent` only when the next step is blocked on the result. While the delegated phase runs, do small non-overlapping local tasks such as validating directory structure or preparing the next prompt file. +- Build verification is critical-path work. Prefer running the build in the main session, and only delegate a bounded build-fix phase when there is a concrete reason. +- If subagents are unavailable in the current environment, or current policy does not allow delegation from the start, run the phase in the main session using the same prompt files. Otherwise, do not switch a pre-build phase to same-session midstream. Never fall back to shell-spawned `codex exec` child processes from this skill. ## Verification Rules - If build or test commands fail due to file locks or access-denied outputs (C1041, LNK1104), stop and ask the user to close locking processes before retrying. +- Treat a delegated phase as complete only when the required artifact or status update exists on disk and matches the phase goals; do not rely on the chat reply alone. - Never claim completion without: - implemented code changes present - build attempt results recorded - review pass documented with any follow-up fixes + - on Windows, if the task edited project source/config text files, a CRLF / no-BOM normalization pass recorded after review ## Completion Criteria @@ -88,24 +120,26 @@ Mark complete only when: - All plan phases are done - Build verification is recorded - Review issues are addressed or explicitly deferred with rationale +- On Windows, Codex-edited project source/config text files have been normalized to CRLF, any UTF-8 rewrites were saved without BOM, and the result is logged - Display total elapsed time since start (format: `Xh Ym Zs`, omitting zero components) - Remind the user of the project name so they can request follow-up tasks within the same project ## Error Handling -- If any phase fails or gets stuck, report the issue to the user and ask how to proceed. -- If context.md or plan.md is not written properly by a phase, re-run that phase with more specific instructions. +- If any phase fails, times out, or gets stuck, follow the retry ladder from Execution Mode. Do not close an agent solely because the final artifact is missing while its progress file is still moving. After two delegated attempts remain blocked with no meaningful progress, report the issue to the user. Do not absorb the phase into the main session before build unless delegation was unavailable from the start. +- If `context.md` or `plan.md` is not written properly by a phase, rerun that phase in a fresh subagent with more specific instructions. Do not repair it locally before build unless delegation was unavailable from the start. - If build errors persist after the build phase's attempts, report the remaining errors to the user. -- If a review fix phase introduces new build errors that it cannot resolve, report to the user. +- If a review-fix phase introduces new build errors that it cannot resolve, report to the user. +- If Phase 7 cannot safely normalize a touched file on Windows or remove an introduced UTF-8 BOM from a touched project text file, record the failure in the result log and report it in the final summary instead of silently skipping it. ## User Invocation Use plain language with the skill name in the request, for example: -`Use local task-think skill: make sure FileLoadTask::process does not create or read QPixmap on background threads; use QImage with ARGB32_Premultiplied instead.` +`Use local task-think skill with subagents: make sure FileLoadTask::process does not create or read QPixmap on background threads; use QImage with ARGB32_Premultiplied instead.` For follow-up tasks on an existing project: -`Use local task-think skill: my-project also handle the case where the file is already cached` +`Use local task-think skill with subagents: my-project also handle the case where the file is already cached` -If screenshots are relevant, include file paths in the same prompt. +If screenshots are relevant, include file paths in the same prompt when possible. diff --git a/.claude/commands/icon.md b/.claude/commands/icon.md new file mode 100644 index 0000000000..7e7bde46c3 --- /dev/null +++ b/.claude/commands/icon.md @@ -0,0 +1,306 @@ +--- +description: Generate an SVG icon from a design mockup using vectosolve vectorization +allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, AskUserQuestion, TodoWrite, mcp__vectosolve__vectorize +--- + +# Icon - SVG Icon Generation from Design Mockup + +You generate production-quality SVG icons for Telegram Desktop by vectorizing design mockup screenshots using the vectosolve MCP service, then post-processing the result to match the Telegram icon format. + +**Arguments:** `$ARGUMENTS` = "$ARGUMENTS" + +If `$ARGUMENTS` is empty, ask the user to describe the icon they want and paste a cropped screenshot of it. + +## Overview + +The workflow takes a cropped screenshot of an icon from a design mockup (grabbed from the clipboard), vectorizes it via the vectosolve MCP, then post-processes the SVG (recolor to white-on-transparent, restructure to minimal format, set 24x24 output size). + +Working directory: `.ai/icon_{name}/` with iterations labeled by letter (`a/`, `b/`, ...), each containing `source.png`. Output SVGs are in the icon root: `a.svg`, `b.svg`, etc. + +Follow-ups are supported: `/icon {icon_name} ` continues from where the previous run left off. + +## Phase 0: Setup + +**Record the current time** (using `date` or equivalent) as `$START_TIME`. + +### Step 0a: Clipboard grab (MUST be the VERY FIRST action) + +If there is an image attached to the user's message: + +1. Generate a random 8-character hex string for `HASH` (use `openssl rand -hex 4` or similar). +2. **IMMEDIATELY** — before any other processing — run this Bash command to save the clipboard image: + ```bash + HASH=$(openssl rand -hex 4) && if [[ "$OSTYPE" == darwin* ]]; then bash .claude/grab_clipboard.sh ".ai/icon_${HASH}.png"; else powershell -ExecutionPolicy Bypass -File .claude/grab_clipboard.ps1 ".ai/icon_${HASH}.png"; fi + ``` + On macOS `.claude/grab_clipboard.sh` is used; on Windows `.claude/grab_clipboard.ps1`. Both grab the current clipboard image and save it to the specified path. + +3. If the command fails (exit 1 / no image on clipboard): + - Tell the user: **"Clipboard doesn't contain an image. Please copy the icon area first, then retry."** (On macOS: Cmd+Ctrl+Shift+4 to snip to clipboard; on Windows: Win+Shift+S.) + - **STOP IMMEDIATELY. Do NOT continue.** You cannot use the image pasted in the conversation — it exists only as pixels in the chat, not as a file you can send to vectosolve. The clipboard grab is the ONLY way to get the image to disk. Do not attempt any workaround. + +4. Read back the saved `.ai/icon_HASH.png` using the Read tool. +5. Compare it visually with the image pasted in the conversation. They should depict the same thing. + - If they look **completely different**: delete `.ai/icon_HASH.png` and fail: + > "The clipboard image doesn't match what you pasted. Please re-copy and retry." + - If they look the same (or close enough): proceed. Store the temp path. + +If NO image is attached to the message, skip this step entirely. + +### Step 0b: Fail-fast — verify vectosolve MCP + +Check that the `mcp__vectosolve__vectorize` tool is available by looking at your available tools list. If it is NOT available, fail immediately with: + +> vectosolve MCP is not configured. Set it up with: +> ``` +> claude mcp add vectosolve --scope user -e VECTOSOLVE_API_KEY=vs_xxx -- npx @vectosolve/mcp +> ``` +> Then restart Claude Code. + +### Step 0c: Follow-up detection + +Extract the first word/token from `$ARGUMENTS` (everything before the first space or newline). Call it `FIRST_TOKEN`. + +Run these TWO commands using the Bash tool, **IN PARALLEL**: +1. `ls .ai/` — to see all existing icon project names +2. `ls .ai/icon_{FIRST_TOKEN}/context.md` — to check if this specific icon project exists + +**Evaluate the results:** +- If command 2 **succeeds** (context.md exists): this is a **follow-up**. The icon name is `FIRST_TOKEN`. The follow-up description is everything in `$ARGUMENTS` after `FIRST_TOKEN`. +- If command 2 **fails** (not found): this is a **new icon**. The full `$ARGUMENTS` is the icon description. + +### Step 0d: New icon setup + +1. Parse `$ARGUMENTS` to determine: + - **Icon description**: what the icon should depict + - **Icon type**: default is `menu` (24x24 menu/button icon). User may specify otherwise. + - **Target subfolder**: `menu/` by default, or another subfolder if specified. + +2. Choose an icon file name: + - Lowercase letters and underscores only — **NO hyphens** + - Match existing naming conventions (check `Telegram/Resources/icons/{subfolder}/`) + - Must NOT conflict with existing icons + - Must NOT collide with existing `.ai/icon_{name}/` directories + +3. Create `.ai/icon_{name}/` and `.ai/icon_{name}/a/`. + +4. Write `.ai/icon_{name}/context.md` with: + ``` + ## Icon: {icon_name} + Type: {menu/other} + Target: Telegram/Resources/icons/{subfolder}/{icon_name}.svg + + ## Original Request + {full $ARGUMENTS text} + + ## Follow-ups + (none yet) + ``` + +5. Set `LETTER` to `a`. + +### Step 0e: Follow-up setup + +1. Read `.ai/icon_{name}/context.md` to get the icon type, subfolder, and full history. +2. Find the latest existing letter folder in `.ai/icon_{name}/` (highest letter). +3. Set `LETTER` to the next letter after the latest. +4. Create `.ai/icon_{name}/{LETTER}/`. +5. Update `.ai/icon_{name}/context.md` — append the follow-up description to the `## Follow-ups` section: + ``` + ### Follow-up (starting at letter {LETTER}) + {follow-up description} + ``` + +### Step 0f: Place source image + +If a clipboard image was grabbed in Step 0a: +1. Copy (or move) `.ai/icon_HASH.png` → `.ai/icon_{name}/source.png` (overwrite if exists — this is always the latest source). +2. Copy it to `.ai/icon_{name}/{LETTER}/source.png` (archive per-iteration source). +3. Delete the temp `.ai/icon_HASH.png` if it was copied (not moved). + +If NO image was grabbed: +- **New icon with no image**: Ask the user to provide a screenshot. STOP. +- **Follow-up with no image**: The existing `source.png` in the icon root carries forward. Copy it to `.ai/icon_{name}/{LETTER}/source.png`. If no source.png exists at all, ask the user for an image. + +### Step 0g: Verify renderer + +Locate the render tool (`codegen_style` with `--render-svg` mode): + +```bash +if [[ "$OSTYPE" == darwin* ]]; then + ls out/Debug/codegen_style +else + ls out/Telegram/codegen/codegen/style/Debug/codegen_style.exe +fi +``` + +If missing, build it: `cmake --build out --config Debug --target codegen_style` + +Test on a known good SVG (use the appropriate binary path for the OS): +```bash +CODEGEN=$(if [[ "$OSTYPE" == darwin* ]]; then echo out/Debug/codegen_style; else echo out/Telegram/codegen/codegen/style/Debug/codegen_style.exe; fi) +$CODEGEN --render-svg Telegram/Resources/icons/menu/tag_add.svg .ai/icon_{name}/test_render.png 512 +``` + +If works → delete test render, set `RENDER_AVAILABLE = true`. If fails → `RENDER_AVAILABLE = false`. + +## Phase 1: Vectorize & Post-process + +### Step 1a: Call vectosolve + +Use the `mcp__vectosolve__vectorize` tool with `file_path` set to the **absolute path** of `.ai/icon_{name}/{LETTER}/source.png`. + +**If this fails, STOP IMMEDIATELY.** Do NOT try to generate the SVG manually or by any other means. Report the error to the user and let them fix the issue (bad API key, no credits, network error, etc.). + +Save the returned SVG content to `.ai/icon_{name}/{LETTER}/raw_vectosolve.svg`. + +The MCP tool calls the vectosolve API ($0.20/call). The API key is stored in `~/.claude.json` MCP config (never in the repository). + +### Step 1b: Post-process the SVG + +The vectosolve SVG will have colors from the mockup, arbitrary dimensions, and possibly a non-square aspect ratio from a non-square screenshot crop. Post-processing fixes this by adjusting the **viewBox** — leave path coordinates untouched. + +**Do NOT transform path coordinates.** Vectosolve's paths are correct — the only thing wrong is the framing. All geometry adjustments are done by manipulating the `viewBox` and the `width`/`height` attributes. + +#### Sub-step 1: Read the request and determine parameters + +Before touching the SVG, determine these from the user's request and context.md: + +1. **Output size** (`OUT_W × OUT_H`): default is `24px × 24px` for menu icons. The user may request different dimensions (e.g., 36×36, 48×48, or non-square). Always check the request. +2. **Content padding**: default is ~2px equivalent on each side at the output scale (so content fills roughly (OUT_W-4) × (OUT_H-4)). The user may request different padding or edge-to-edge. +3. **Centering**: default is centered both horizontally and vertically. The user may request specific alignment (e.g., "align to bottom"). + +#### Sub-step 2: Parse the raw SVG + +1. Extract the `viewBox`: `viewBox="VB_X VB_Y VB_W VB_H"` (typically `0 0 W H`). +2. Identify ALL paths. Classify each: + - **Background**: a rect or path spanning the full viewBox (first path that's a simple rectangle matching the viewBox bounds). **Remove it entirely.** + - **Content**: the actual icon shapes. **Keep these, paths unchanged.** +3. If paths have `transform="translate(TX,TY)"` attributes, that's fine — keep them as-is. The viewBox framing will work regardless. + +#### Sub-step 3: Compute the content bounding box + +Estimate the bounding box of the content paths (after removing the background). You can either: +- Eyeball it from the path coordinates (look at first/last M commands and extremes of curves) +- Or for precision, write a quick script to parse the paths and find min/max X/Y + +Call the result: `CX_MIN, CY_MIN, CX_MAX, CY_MAX`. Content dimensions: `CW = CX_MAX - CX_MIN`, `CH = CY_MAX - CY_MIN`. + +#### Sub-step 4: Compute the new viewBox + +The viewBox determines what part of the SVG coordinate space maps to the output rectangle. By expanding the viewBox beyond the content bounds, we add padding. By making the viewBox aspect ratio match the output aspect ratio, we prevent stretching. + +1. **Output aspect ratio**: `OUT_AR = OUT_W / OUT_H` (for 24×24 this is 1.0). +2. **Padding in SVG coordinates**: we want ~2px padding at output scale. The scale factor is `OUT_W / VB_CONTENT_W` approximately, so padding in SVG coords = `2 * (CW / (OUT_W - 4))` (or similar — the exact formula depends on which dimension is dominant). Simpler approach: aim for content to occupy ~83% of the viewBox (≈ 20/24), so: + - `PADDED_W = CW / 0.83` + - `PADDED_H = CH / 0.83` +3. **Match output aspect ratio**: the viewBox aspect ratio must equal `OUT_AR` to avoid stretching. + - If `PADDED_W / PADDED_H > OUT_AR`: width is dominant → `VB_W = PADDED_W`, `VB_H = VB_W / OUT_AR` + - If `PADDED_W / PADDED_H < OUT_AR`: height is dominant → `VB_H = PADDED_H`, `VB_W = VB_H * OUT_AR` + - If equal: `VB_W = PADDED_W`, `VB_H = PADDED_H` +4. **Center the content** in the new viewBox: + - `VB_X = CX_MIN - (VB_W - CW) / 2` + - `VB_Y = CY_MIN - (VB_H - CH) / 2` + - (Adjust if the user requested non-centered alignment) + +The new viewBox is: `viewBox="VB_X VB_Y VB_W VB_H"`. + +#### Sub-step 5: Recolor to white-on-transparent + +- Replace ALL `fill` color values (anything that isn't `none`) with `#FFFFFF`. +- Remove ALL `stroke` and `stroke-width` attributes entirely. +- Remove `opacity` attributes if present. + +#### Sub-step 6: Determine path composition + +Look at the icon's visual structure and decide how paths should combine: +- **Outlined shape** (e.g., circle outline with something inside): combine outer + inner cutout into one `` with `fill-rule="evenodd"`. +- **Separate distinct parts** (e.g., magnifying glass + checkmark): keep as separate `` elements. +- **Filled shape with cutout** (e.g., filled circle with checkmark punched out): combine into one path with `fill-rule="evenodd"`. + +#### Sub-step 7: Assemble final SVG + +```xml + + + + + + +``` + +- `width`/`height` = the output size from the request (default `24px`/`24px`). +- `viewBox` = the computed viewBox from Sub-step 4. The SVG renderer maps this coordinate region to the output size. +- Path `d` attributes are **unchanged** from vectosolve output (just background removed, colors replaced). +- No ``, `id`, `xmlns:xlink`, `version`, `class`, `style`, XML comments, `<metadata>`, or `preserveAspectRatio`. +- No `<circle>`, `<rect>`, `<line>` — only `<path>`. + +Write the final SVG to `.ai/icon_{name}/{LETTER}.svg`. + +### Step 1c: Render + +If `RENDER_AVAILABLE`: +```bash +$CODEGEN --render-svg ".ai/icon_{name}/{LETTER}.svg" ".ai/icon_{name}/render_{LETTER}.png" 512 +``` + +Read the render to visually verify the result. + +## Phase 2: Review + +After rendering, assess the result: + +1. **Recognizable?** The icon should be clearly identifiable as the intended symbol. +2. **Scale reasonable?** Should fill the space appropriately with ~2-3px padding. +3. **Clean lines?** No broken paths, artifacts, or unwanted elements. +4. **Correct colors?** All white on transparent (no leftover colors from the mockup). + +If the result looks good → proceed to Phase 3 (Output). + +If there are fixable issues (stray element, missed color, etc.) → fix the SVG directly, re-render, and re-check. + +If the result is poor (vectosolve couldn't handle the input well) → report to the user and suggest: +- Trying a cleaner/larger crop of the icon +- Providing a different screenshot +- Following up: `/icon {icon_name} <description of what to change>` + +## Phase 3: Output + +1. Read the `Target:` line from `.ai/icon_{name}/context.md` to get the output path. + +2. Copy the final SVG to that target path (e.g., `Telegram/Resources/icons/menu/{icon_name}.svg`). + +3. Update `.ai/icon_{name}/context.md` — append to the end: + ``` + ## Latest Output + Letter: {LETTER} + Written to: {target_path} + ``` + +4. Report to the user: + - Final icon file path + - Number of vectosolve calls made (cost at $0.20/call) + - Suggest verifying visually + - Working directory `.ai/icon_{name}/` has all iterations + - Elapsed time since `$START_TIME` (format `Xm Ys`) + - Follow-up: `/icon {icon_name} <description of what to change>` + +## Text-only Follow-ups (no new image) + +When a follow-up has no attached image, the user wants to refine the existing SVG based on text feedback. In this case: + +1. Skip Phase 1 (no vectosolve call needed). +2. Read the latest SVG (`.ai/icon_{name}/{prev_letter}.svg`). +3. Read the latest render if available. +4. Apply the user's requested changes by editing the SVG directly. +5. Save as `.ai/icon_{name}/{LETTER}.svg`. +6. Render, review, and output as normal (Phases 1c → 3). + +If the changes are too complex for manual SVG editing, suggest the user provide a new screenshot instead. + +## Error Handling + +- If clipboard grab fails → tell user to re-copy and retry. +- If vectosolve returns an error → report it and suggest a different/cleaner screenshot. +- If vectosolve returns SVG that can't be parsed → save raw output for debugging, report to user. +- If the render helper fails → set `RENDER_AVAILABLE = false`, continue with SVG-only review. +- If post-processing produces a broken SVG → fall back to the raw vectosolve output and do lighter cleanup. diff --git a/.claude/commands/reflect.md b/.claude/commands/reflect.md new file mode 100644 index 0000000000..f05d292bea --- /dev/null +++ b/.claude/commands/reflect.md @@ -0,0 +1,136 @@ +--- +description: Learn from corrections — examine staged vs unstaged diffs and optionally distill insights into AGENTS.md or REVIEW.md +allowed-tools: Read, Edit, Bash(git diff:*), Bash(git status:*), Bash(git log:*), Bash(ls:*), AskUserQuestion +--- + +# Reflect — Learn from Corrections + +You are a reflection agent. Your job is to examine the difference between what an AI agent produced (staged changes) and what the user corrected (unstaged changes), and determine whether any **general, reusable insight** can be extracted and added to the project's coding guidelines. + +**CRITICAL: Use extended thinking ultrathink for your analysis. This requires deep, careful reasoning.** + +## Arguments + +`$ARGUMENTS` = "$ARGUMENTS" + +If `$ARGUMENTS` is provided, it is a task name (project name from the `/task` workflow). This means the agent was working within `.ai/<task-name>/` and you should read the task context for deeper understanding of what the agent was trying to do. + +If `$ARGUMENTS` is empty, skip the task context step — just work from the diffs alone. + +## Context + +The workflow is: +1. An AI agent implemented something and its changes were staged (`git add`). +2. The user reviewed and corrected the agent's work. These corrections are unstaged. +3. You are now invoked to reflect on what went wrong and whether it reveals a pattern. + +## Step 1: Gather the Diffs and Task Context + +Run these commands in parallel: + +```bash +git diff --cached # What the agent wrote (staged) +git diff # What the user corrected (unstaged, on top of staged) +git status # Which files are involved +``` + +If either diff is empty, tell the user and stop. Both diffs must be non-empty for reflection to be meaningful. + +### Task context (only if `$ARGUMENTS` is non-empty) + +The task name is `$ARGUMENTS`. Read the task's project context: + +1. Read `.ai/$ARGUMENTS/about.md` — the project-level description of what this feature does. +2. Find the latest task iteration folder: list `.ai/$ARGUMENTS/` and pick the folder with the highest letter (`a`, `b`, `c`, ...). +3. Read `.ai/$ARGUMENTS/<latest-letter>/context.md` — the detailed implementation context the agent was working from. + +This helps you distinguish between: +- **Task-specific mistakes** — the agent misunderstood this particular feature's requirements or made a wrong choice within the specific problem. These are NOT documentation-worthy. +- **General convention mistakes** — the agent did something that violates a pattern the codebase follows broadly, regardless of which feature is being implemented. These ARE potentially documentation-worthy. + +Having the task context makes this distinction much sharper. Without it, you might mistake a task-specific correction for a general pattern or vice versa. + +## Step 2: Read the Current Guidelines + +Read both files: +- `AGENTS.md` — development guidelines: build system, coding style, API usage patterns, UI styling, localization, rpl, architectural conventions, "how to do things" +- `REVIEW.md` — mechanical style and formatting rules: brace placement, operator position, type checks, variable initialization, call formatting + +Read them carefully. You need to know exactly what's already documented to avoid duplicates and detect contradictions. + +## Step 3: Analyze the Corrections + +Now think deeply. For each correction the user made, ask yourself: + +1. **What did the agent do wrong?** Understand the specific mistake. +2. **Why was it wrong?** Identify the underlying principle. +3. **Is this already covered by AGENTS.md or REVIEW.md?** Check carefully: + - If the existing rule's scope, title, and examples **clearly cover** this exact scenario and the agent just ignored it — that's not a documentation problem. Skip it. + - If the existing rule **technically applies** but its scope is too narrow, its examples don't illustrate this usage pattern, or its wording would reasonably lead an agent to think it doesn't apply here — **the rule needs improvement**. Treat this as a potential insight (broaden the scope, add examples, adjust wording). A rule that agents repeatedly violate is an ineffective rule. +4. **Is this specific to this particular task, or is it general?** Most corrections are task-specific ("wrong variable here", "this should call that function instead"). These are NOT documentation-worthy. Only patterns that would apply across many different tasks are worth capturing. +5. **Would documenting this actually help a future agent?** Some things are too context-dependent or too obvious to be useful as a written rule. Be honest about this. + +## Step 4: Decision + +After analysis, you MUST reach one of these conclusions: + +### Conclusion A: No actionable insight + +The corrections are purely task-specific, or the existing documentation clearly and specifically covers the exact scenario and the agent simply ignored it. Say what the corrections were and why no doc changes are needed. Then **stop**. + +### Conclusion B: New insight found + +You can articulate a **concise, general rule** that: +- Applies broadly (not just to this one task) +- Is not already documented +- Would genuinely help a future agent avoid the same class of mistake +- Can be expressed in a few sentences with a clear code example + +If you have a new insight, proceed to Step 5. + +### Conclusion C: Existing rule needs improvement + +A rule already exists in AGENTS.md or REVIEW.md, but its **scope is too narrow**, its **examples don't cover** the pattern the agent encountered, or its **wording** would reasonably lead an agent to think the rule doesn't apply. The agent's mistake is evidence the rule isn't effective. + +This is NOT the same as Conclusion A. The test: would a careful agent, reading the existing rule, clearly know it applies to this specific situation? If no — the rule needs to be broadened, its examples expanded, or its title/scope adjusted. Proceed to Step 5. + +**Common signs of an ineffective rule:** +- The rule's title or scope restricts it to a context narrower than the actual principle (e.g., "in localization calls" when the pattern applies generally) +- The examples only show one usage pattern, and the agent encountered a different one +- The wording describes *what* to use but not *when* — so agents only apply it in situations that look like the examples + +## Step 5: Categorize and Check for Contradictions + +### Where does it belong? + +- **REVIEW.md** — if it's a mechanical/style rule: formatting, naming, syntax preferences, call structure, brace/operator placement, type usage patterns. Rules that can be checked by looking at code locally without understanding the broader feature. +- **AGENTS.md** — if it's an architectural/behavioral guideline: how to use APIs, where to place code, design patterns, build conventions, module organization, reactive patterns (rpl), localization usage, style system usage. Rules that require understanding the broader context. + +### Does it contradict existing content? + +Read the target file again carefully. Check if: +1. The new insight **contradicts** an existing rule — if so, do NOT just append or just remove. Instead, use AskUserQuestion to present both the existing rule and the new insight to the user, explain the contradiction, and ask how to reconcile them. +2. The new insight **overlaps** with an existing rule — if so, consider whether the existing rule should be extended/refined rather than adding a separate entry. +3. The new insight is **complementary** — it adds something new without conflicting. This is the simplest case. + +## Step 6: Propose the Change + +**Do NOT silently edit the files.** First, present your proposed change to the user: + +- Quote the exact text you want to add or modify +- Explain which file and where in the file +- Explain why this is general enough to document +- If modifying existing text, show the before and after + +Use AskUserQuestion to get the user's approval before making any edit. + +Only after the user approves, apply the edit using the Edit tool. + +## Rules + +- **Keep docs lean and high-signal.** Don't add vague or overly specific rules. But don't default to inaction either — if the user had to manually fix something that a better-worded rule would have prevented, improving that rule is high-signal work. +- **Never dump corrections verbatim.** The goal is distilled principles, not a changelog of mistakes. +- **One insight per reflection, maximum.** If you think you see multiple insights, pick the strongest one. You can always run `/reflect` again next time. +- **Keep the same style.** Match the formatting, tone, and level of detail of the target file. REVIEW.md uses specific before/after code examples. AGENTS.md uses explanatory sections with code snippets. +- **Don't add "don't do X" rules.** Frame rules positively: "do Y" is better than "don't do X." Show the right way, not just the wrong way. +- **No meta-commentary.** Don't add notes like "Added after reflection on..." — the rule should read as if it was always there. diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 0000000000..35774b79cd --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,116 @@ +--- +description: Prepare changelog, set version, and commit a new release +allowed-tools: Read, Bash, Edit, Grep, AskUserQuestion +--- + +# Release — Changelog, Set Version, Commit + +Full release flow: generate changelog entry, run `set_version`, and commit. + +**Arguments:** `$ARGUMENTS` = "$ARGUMENTS" + +Parse `$ARGUMENTS` for two optional parts (in any order): +- A **version number** like `6.7` or `6.7.0` — if provided, use it as the new version exactly. +- The word **"beta"** — if present, mark the release as beta. + +If no version number is given, auto-increment the patch component (see step 2). + +## Steps + +### 1. Check git status is clean + +Run `git status --porcelain`. If there are any uncommitted changes, **stop** and ask the user to commit or discard them before proceeding. Do not continue until status is clean. + +### 2. Read the current changelog + +Read `changelog.txt` from the repository root. Note the **latest version number** on the first line (e.g. `6.6.3 beta (12.03.26)`). Parse its major.minor.patch components. + +### 3. Determine the new version number + +- **If a version was provided in arguments**, use it directly (append `.0` if only major.minor was given). +- **If no version was provided**, auto-increment from the latest changelog version: + - If it was a beta, and the new release is **not** beta, reuse the same version number but drop "beta". + - If the new release is beta and the latest was also beta with the same major.minor, bump patch. + - Otherwise bump the patch component by 1. +- Present the chosen version to the user and ask for confirmation before proceeding. If the user suggests a different version, use that instead. + +### 4. Fetch tags and determine the last release tag + +Run `git fetch origin --tags` first to ensure all tags from the public repository are available locally. Then run `git tag --sort=-v:refname` and find the most recent `v*` tag. This is the baseline for the diff. + +### 5. Collect commits + +Run `git log <last-tag>..HEAD --oneline` to get all commits since the last release. + +### 6. Write the changelog entry + +Analyze every commit message. Group them mentally into features, improvements, and bug fixes. Then produce **brief, user-facing bullet points** following these rules: + +- **Style:** Match the existing changelog tone exactly — short, imperative sentences starting with a verb (Fix, Add, Allow, Show, Improve, Support…). Keep the trailing periods (the existing changelog uses them). +- **Brevity:** Each bullet should be one short sentence, around 80 characters when possible. No implementation details. No commit hashes. +- **Selection:** Only include changes that matter to end users. Skip CI, build infra, submodule bumps, code style, refactors, and intermediate WIP commits. Collapse many related commits (e.g. a dozen image-editor commits) into one or two bullets. +- **Ordering:** Features first, then improvements, then bug fixes. +- **Quantity:** Aim for 4-12 bullets total depending on the amount of changes. + +### 7. Format and insert into changelog.txt + +Use this exact format (date is today in DD.MM.YY): + +``` +<version> [beta ](DD.MM.YY) + +- Bullet one. +- Bullet two. +``` + +Prepend the new entry at the very top of `changelog.txt`, separated by a blank line from the previous first entry. Use the Edit tool. + +### 8. Show the entry and wait for approval + +Print the full changelog entry in chat and ask the user to review it. Tell them they can: +- Approve as-is. +- Edit `changelog.txt` directly in the IDE and tell you to continue. +- Tell you what to change in chat. + +**Do NOT proceed until the user explicitly approves.** If they request changes, apply them and show the updated entry again. + +### 9. Run set_version + +Once approved, run the `set_version` script from the repository root. On Windows: + +``` +.\Telegram\build\set_version.bat <version_arg> +``` + +Where `<version_arg>` is formatted as the `set_version` script expects: +- Stable: `6.7.0` or `6.7` +- Beta: `6.7.0.beta` + +Verify the script exits successfully (exit code 0). If it fails, show the error and stop. + +### 10. Commit + +Stage all changes and create a commit. The commit message format: + +**First line:** +- For stable: `Version <major>.<minor>.` if patch is 0, otherwise `Version <major>.<minor>.<patch>.` +- For beta: `Beta version <major>.<minor>.<patch>.` + +**Then an empty line, then the changelog bullets.** Each bullet line (starting with `- `) must be wrapped at around 77-78 characters. When wrapping, break at logically correct places (between words/phrases) and indent continuation lines with two spaces. + +Example commit message: +``` +Beta version 6.6.3. + +- Drawing tools in image editor + (brush, marker, eraser, arrow). +- Draw-to-reply button in media viewer. +- Trim recorded voice messages. +- Fix reorder freeze in chats list. +``` + +Use a HEREDOC to pass the message to `git commit -a`. + +### 11. Done + +Run `git log -1` to show the resulting commit and confirm success. diff --git a/.claude/grab_clipboard.ps1 b/.claude/grab_clipboard.ps1 new file mode 100644 index 0000000000..bece778c60 --- /dev/null +++ b/.claude/grab_clipboard.ps1 @@ -0,0 +1,11 @@ +param([string]$outPath) +Add-Type -AssemblyName System.Windows.Forms +$img = [System.Windows.Forms.Clipboard]::GetImage() +if ($img) { + $img.Save($outPath, [System.Drawing.Imaging.ImageFormat]::Png) + Write-Host "Saved to $outPath" + exit 0 +} else { + Write-Host "No image on clipboard" + exit 1 +} diff --git a/.claude/grab_clipboard.sh b/.claude/grab_clipboard.sh new file mode 100755 index 0000000000..80c0c6d05c --- /dev/null +++ b/.claude/grab_clipboard.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Grab clipboard image on macOS and save as PNG. +outPath="$1" +if [ -z "$outPath" ]; then + echo "Usage: grab_clipboard.sh <output.png>" + exit 1 +fi + +osascript -e ' +set theFile to POSIX file "'"$outPath"'" +try + set theImage to the clipboard as «class PNGf» +on error + return "no image" +end try +set fh to open for access theFile with write permission +write theImage to fh +close access fh +return "ok" +' 2>/dev/null | grep -q "ok" + +if [ $? -eq 0 ]; then + echo "Saved to $outPath" + exit 0 +else + echo "No image on clipboard" + exit 1 +fi diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 8e708e1f5d..3c81a42947 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -143,7 +143,7 @@ jobs: tool-cache: true - name: Set up Docker Buildx. - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Libraries cache. uses: actions/cache@v5 @@ -153,7 +153,7 @@ jobs: restore-keys: ${{ runner.OS }}-libs- - name: Libraries. - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: Telegram/build/docker/centos_env load: ${{ env.ONLY_CACHE == 'false' }} diff --git a/.github/workflows/mac.yml b/.github/workflows/mac.yml index 456fc0335a..c80ff4db98 100644 --- a/.github/workflows/mac.yml +++ b/.github/workflows/mac.yml @@ -125,7 +125,7 @@ jobs: mkdir artifact mv Telegram.app artifact/ mv Updater artifact/ - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 if: env.UPLOAD_ARTIFACT == 'true' name: Upload artifact. with: diff --git a/.github/workflows/mac_packaged.yml b/.github/workflows/mac_packaged.yml index e331d3d596..5153f3492a 100644 --- a/.github/workflows/mac_packaged.yml +++ b/.github/workflows/mac_packaged.yml @@ -176,7 +176,7 @@ jobs: cd $REPO_NAME/build mkdir artifact mv Telegram.app artifact/ - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 if: env.UPLOAD_ARTIFACT == 'true' name: Upload artifact. with: diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index af6ef27e7e..15b34bd38a 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -40,7 +40,7 @@ jobs: echo "SNAP_FILE=$SNAP_FILE" >> "$GITHUB_ENV" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ env.SNAP_FILE }} path: ${{ env.SNAP_FILE }} diff --git a/.github/workflows/win.yml b/.github/workflows/win.yml index cbdff19e04..0e36af68c5 100644 --- a/.github/workflows/win.yml +++ b/.github/workflows/win.yml @@ -54,6 +54,8 @@ jobs: exclude: - arch: arm64 qt: "" + - arch: x64_x86 + qt: qt6 env: UPLOAD_ARTIFACT: "true" @@ -226,7 +228,7 @@ jobs: mkdir artifact move %OUT%\Telegram.exe artifact/ move %OUT%\Updater.exe artifact/ - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 name: Upload artifact. if: (env.UPLOAD_ARTIFACT == 'true') || (github.ref == 'refs/heads/nightly') with: diff --git a/.gitmodules b/.gitmodules index c9d7158cd0..0b0084def2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -91,3 +91,6 @@ [submodule "Telegram/ThirdParty/xdg-desktop-portal"] path = Telegram/ThirdParty/xdg-desktop-portal url = https://github.com/flatpak/xdg-desktop-portal.git +[submodule "Telegram/lib_translate"] + path = Telegram/lib_translate + url = https://github.com/desktop-app/lib_translate diff --git a/AGENTS.md b/AGENTS.md index 3a7e4039b2..3694a358a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Agent Guide for Telegram Desktop +# Agent Guide for Telegram Desktop This guide defines repository-wide instructions for coding agents working with the Telegram Desktop codebase. @@ -96,6 +96,21 @@ Retrying builds wastes time and context. The ONLY fix is for the user to close t 1. **Always use Debug builds** - Release builds are extremely heavy 2. **Don't build Release configuration** - it's too heavy for testing +## Text File Format + +- On Windows, keep project text files with CRLF line endings. +- Do not save source, header, build/config, style, or localization files as UTF-8 with BOM. Use UTF-8 without BOM. +- When rewriting project text files for normalization, preserve file content otherwise and do not introduce a BOM. + +## Local Storage Serialization + +Both app-level (`Core::Settings`) and session-level (`Main::SessionSettings`) use sequential binary serialization via `QDataStream`. Key rules: + +- New fields must ALWAYS be appended at the **end** of the stream, never inserted in the middle +- Reading new fields must be guarded with `!stream.atEnd()` and provide a meaningful default/fallback +- Inserting in the middle breaks reading of data saved by older versions (the new read code consumes bytes that belong to subsequent fields) +- For simple flags and values, prefer using the generic KV prefs facility (`writePref<Type>` / `readPref<Type>`) instead of adding to the binary stream -- this avoids serialization ordering issues entirely + --- # Development Guidelines diff --git a/REVIEW.md b/REVIEW.md index 092dcb124f..87ebe458e4 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -114,7 +114,7 @@ bool _expanded = false; SomeType *_pointer = nullptr; ``` -## Prefer tr:: projections over Ui::Text:: in localization calls +## Use tr:: projections for TextWithEntities Inside `tr::lng_...()` calls, always use the `tr::` projection helpers instead of their `Ui::Text::` equivalents. The `tr::` helpers are shorter and work uniformly as both placeholder wrappers and final projectors. @@ -145,6 +145,19 @@ tr::lng_some_key( tr::rich) ``` +Also use `tr::marked()` as the standard way to create `TextWithEntities` — not just as a projector: + +```cpp +// BAD - verbose constructor: +auto text = TextWithEntities(); +auto text = TextWithEntities{ u"hello"_q }; +auto text = TextWithEntities().append(u"hello"_q); + +// GOOD - concise: +auto text = tr::marked(); +auto text = tr::marked(u"hello"_q); +``` + ## Multi-line calls — one argument per line When a function call doesn't fit on one line, put each argument on its own line. Don't group "logical pairs" on the same line — it creates inconsistent line lengths and makes diffs noisier. @@ -173,4 +186,329 @@ auto text = tr::lng_settings_title(tr::now); ## std::optional access — avoid value() Do not call `std::optional::value()` because it throws an exception that is not available on older macOS targets. Use `has_value()`, `value_or()`, `operator bool()`, or `operator*` instead. + +## Sort includes alphabetically, nested folders first + +After the file's own header, sort `#include` directives alphabetically with two special rules: + +1. **Nested folders before files** in the same directory — like Finder / File Explorer (folders first, then files). E.g. `ui/controls/button.h` sorts before `ui/abstract_button.h`. +2. **Style includes (`styles/style_*.h`) always go last**, separated from the rest. + +```cpp +// BAD - arbitrary order, style mixed in: +#include "media/audio/media_audio.h" +#include "styles/style_media_player.h" +#include "data/data_document.h" +#include "apiwrap.h" + +// GOOD - alphabetical, folders first, styles last: +#include "apiwrap.h" +#include "data/data_document.h" +#include "media/audio/media_audio.h" + +#include "styles/style_media_player.h" +``` + +## Use C++17 nested namespace syntax + +Use `namespace A::B {` instead of nesting `namespace A { namespace B {`. The closing comment mirrors the opening: `} // namespace A::B`. + +```cpp +// BAD - old-style nesting: +namespace Media { +namespace Player { +... +} // namespace Player +} // namespace Media + +// GOOD - C++17 nested: +namespace Media::Player { +... +} // namespace Media::Player +``` + +## Merge consecutive branches with identical bodies + +When two or more consecutive `if` / `else if` branches execute the same code, combine their conditions into a single branch. + +```cpp +// BAD - duplicated body: +if (!document) { + finalize(); + return; +} +if (!document->isSong()) { + finalize(); + return; +} + +// GOOD - combined: +if (!document || !document->isSong()) { + finalize(); + return; +} +``` + +## Use base::take for read-and-reset + +When you need to read a variable's current value and reset it in one step, use `base::take(var)` instead of manually copying and clearing. `base::take` returns the old value and resets the variable to its default-constructed state. + +```cpp +// BAD - manual read + reset: +if (_playing) { + _listenedMs += crl::now() - _playStartedAt; + _playing = false; +} + +// GOOD: +if (base::take(_playing)) { + _listenedMs += crl::now() - _playStartedAt; +} + +// BAD - copy fields then clear them one by one: +const auto document = _document; +const auto contextId = _contextId; +_document = nullptr; +_listenedMs = 0; +if (!document) { + return; +} + +// GOOD - take everything upfront, then validate: +const auto document = base::take(_document); +const auto contextId = base::take(_contextId); +const auto duration = static_cast<int>(base::take(_listenedMs) / 1000); +if (!document || duration <= 0) { + return; +} +``` + +## Don't wrap tr:: lang keys in rpl::single + +`tr::lng_*()` (without `tr::now`) already returns an `rpl::producer`. Wrapping a snapshot in `rpl::single()` defeats live language switching — the value is captured once and never updates. Just call the lang key without `tr::now`. + +```cpp +// BAD - frozen snapshot, won't update on language change: +rpl::single(tr::lng_ai_compose_title(tr::now)) + +// GOOD - live producer that updates automatically: +tr::lng_ai_compose_title() +``` + +## Extract method definitions from local classes + +When defining local classes (e.g. in anonymous namespaces), keep the class body compact — only declarations. Put all method definitions **after** all class definitions. This avoids unnecessary nesting inside the class body and keeps methods at the same indentation level as free functions. + +```cpp +// BAD - methods defined inline, adding a nesting level: +class MyWidget final : public Ui::RpWidget { +public: + MyWidget(QWidget *parent) + : RpWidget(parent) { + // ... 20 lines of setup + } + + void setActive(bool active) { + _active = active; + update(); + } + +protected: + void paintEvent(QPaintEvent *e) override { + // ... 30 lines of painting + } + +private: + bool _active = false; + +}; + +// GOOD - class is a compact declaration, methods defined after: +class MyWidget final : public Ui::RpWidget { +public: + MyWidget(QWidget *parent, QString label); + + void setActive(bool active); + +protected: + void paintEvent(QPaintEvent *e) override; + +private: + bool _active = false; + +}; + +MyWidget::MyWidget(QWidget *parent, QString label) +: RpWidget(parent) { + // ... 20 lines of setup +} + +void MyWidget::setActive(bool active) { + _active = active; + update(); +} + +void MyWidget::paintEvent(QPaintEvent *e) { + // ... 30 lines of painting +} +``` + +When there are multiple local classes, put **all class definitions first**, then **all method definitions** after. This keeps the declarations readable as an overview. + +## Use RAII for resource cleanup + +When working with raw resources (Win32 HANDLEs, file descriptors, COM objects), use `gsl::finally` or a dedicated RAII wrapper for cleanup instead of calling release functions manually. Manual cleanup breaks when early returns are added later. + +```cpp +// BAD - manual cleanup, fragile with early returns: +const auto snapshot = CreateToolhelp32Snapshot(...); +if (snapshot != INVALID_HANDLE_VALUE) { + // ... logic that might grow early returns ... + CloseHandle(snapshot); +} + +// GOOD - RAII guard, cleanup runs on any exit path: +const auto snapshot = CreateToolhelp32Snapshot(...); +if (snapshot == INVALID_HANDLE_VALUE) { + return; +} +const auto guard = gsl::finally([&] { + CloseHandle(snapshot); +}); +// ... logic, early returns are safe ... +``` + +## Extract substantial logic from lambdas + +When a lambda grows beyond a few lines of self-contained logic, extract it into a named function (free function in anonymous namespace, or a private method). Lambdas should primarily be glue — captures, dispatch, short transforms. This applies when the lambda's captures are minimal and can easily become function parameters. When a lambda captures many variables from its surrounding context, it may be cleaner to keep it inline. + +```cpp +// BAD - substantial logic buried in a lambda: +crl::async([=] { + auto found = false; + auto pe = PROCESSENTRY32(); + pe.dwSize = sizeof(PROCESSENTRY32); + const auto snapshot = CreateToolhelp32Snapshot(...); + if (snapshot != INVALID_HANDLE_VALUE) { + for (...) { + if (/* match */) { + found = true; + break; + } + } + CloseHandle(snapshot); + } + crl::on_main(weak, [=] { handle(found); }); +}); + +// GOOD - logic extracted, lambda is just glue: +crl::async([=] { + const auto found = FindRunningReader(); + crl::on_main(weak, [=] { handle(found); }); +}); +``` + +## Data-driven matching over chained conditions + +When comparing a value against multiple known constants, store them in a collection and loop instead of chaining `||` conditions. Easier to extend, less repetition, and reads as data rather than logic. + +```cpp +// BAD - repetitive chain, hard to extend: +if (_wcsicmp(name, L"Narrator.exe") == 0 + || _wcsicmp(name, L"nvda.exe") == 0 + || _wcsicmp(name, L"jfw.exe") == 0 + || _wcsicmp(name, L"Zt.exe") == 0) { + +// GOOD - data-driven, easy to extend: +const auto list = std::array{ + L"Narrator.exe", + L"nvda.exe", + L"jfw.exe", + L"Zt.exe", +}; +for (const auto &entry : list) { + if (_wcsicmp(name, entry) == 0) { + return true; + } +} +``` + +## Use !isHidden() for logic checks, not isVisible() + +When you call `show()` / `hide()` / `setVisible()` on a widget and later branch on that state, always check `!isHidden()` (the widget's own flag) — never `isVisible()`. `isVisible()` returns `true` only when the widget **and every ancestor** are visible, so it silently returns `false` during parent show-animations, before the parent is laid out, etc. `isHidden()` reflects exactly the flag you set. + +```cpp +// BAD — breaks when parent is still animating / not yet shown: +child->setVisible(true); +// ... later, in resizeGetHeight or similar: +if (child->isVisible()) { // false if parent isn't visible yet! + child->moveToRight(x, y, w); +} + +// GOOD — checks the widget's own state: +if (!child->isHidden()) { + child->moveToRight(x, y, w); +} +``` + +The same applies to any logic that depends on a previous `show()`/`hide()` call: skip blocks, layout branches, opacity decisions, etc. + +## Consolidate make_state calls into a single State struct + +Every `make_state` is a separate heap allocation. When a function needs multiple pieces of lambda-captured mutable state, define a local `struct State` with all fields and call `make_state<State>()` once, then capture the resulting pointer everywhere. + +```cpp +// BAD - two allocations: +const auto shown = lifetime.make_state<bool>(false); +const auto count = lifetime.make_state<int>(0); + +// GOOD - one allocation: +struct State { + bool shown = false; + int count = 0; +}; +const auto state = lifetime.make_state<State>(); +``` + +## Use trailing return type when the return type doesn't fit on one line + +When a function's return type is long enough that the declaration would need a line break between the return type and the function name, use trailing return type syntax (`auto ... -> Type`) to keep the function name on the opening line. + +```cpp +// BAD - return type orphaned on its own line: +not_null<HistoryView::Controls::ComposeAiButton*> +SetupCaptionAiButton(SetupCaptionAiButtonArgs &&args); + +// GOOD - trailing return type keeps name visible: +auto SetupCaptionAiButton(SetupCaptionAiButtonArgs &&args) +-> not_null<HistoryView::Controls::ComposeAiButton*>; +``` + +## Mind data structure sizes and alignment + +When adding fields to a class or struct, consider the memory layout. A standalone `bool` between two pointer-sized fields wastes 7 bytes to alignment padding. Review new fields for packing opportunities: + +- If the struct already has bitfields, pack new boolean flags as `: 1` members rather than standalone `bool`. +- If alignment leaves a gap (e.g., an `int` followed by a pointer), consider whether a new small field can fill it. +- For classes instantiated in large quantities (per-message, per-element, per-row), every wasted byte is multiplied thousands of times. + +```cpp +// BAD - standalone bool adds 8 bytes (1 + 7 padding) before the next pointer: +mutable bool _myFlag = false; +mutable std::unique_ptr<Foo> _foo; + +// GOOD - packed into existing bitfield group, no extra bytes: +mutable uint32 _myFlag : 1 = 0; +``` + +## Static member functions use PascalCase + +Non-static member functions use camelCase (`startBatch`, `finalize`). Static member functions use PascalCase (`ShouldTrack`, `Parse`, `Create`), matching the convention for free functions. + +```cpp +// BAD - camelCase for static method: +[[nodiscard]] static bool shouldTrack(not_null<HistoryItem*> item); + +// GOOD - PascalCase for static method: +[[nodiscard]] static bool ShouldTrack(not_null<HistoryItem*> item); ``` diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 7523cdcee1..7b1dd94e4b 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -16,6 +16,7 @@ add_subdirectory(lib_spellcheck) add_subdirectory(lib_storage) add_subdirectory(lib_lottie) add_subdirectory(lib_qr) +add_subdirectory(lib_translate) add_subdirectory(lib_webrtc) add_subdirectory(lib_webview) add_subdirectory(codegen) @@ -35,6 +36,7 @@ include(cmake/td_mtproto.cmake) include(cmake/td_scheme.cmake) include(cmake/td_tde2e.cmake) include(cmake/td_ui.cmake) +include(cmake/telegram_apple_swift_runtime.cmake) include(cmake/generate_appstream_changelog.cmake) include(cmake/td_forkgram.cmake) @@ -80,6 +82,7 @@ PRIVATE desktop-app::lib_storage desktop-app::lib_lottie desktop-app::lib_qr + desktop-app::lib_translate desktop-app::lib_webview desktop-app::lib_ffmpeg desktop-app::lib_stripe @@ -96,6 +99,8 @@ PRIVATE desktop-app::external_xxhash ) +telegram_add_apple_swift_runtime(Telegram) + target_precompile_headers(Telegram PRIVATE $<$<COMPILE_LANGUAGE:CXX,OBJCXX>:${src_loc}/stdafx.h>) nice_target_sources(Telegram ${src_loc} PRIVATE @@ -129,6 +134,8 @@ PRIVATE api/api_common.h api/api_confirm_phone.cpp api/api_confirm_phone.h + api/api_compose_with_ai.cpp + api/api_compose_with_ai.h api/api_credits.cpp api/api_credits.h api/api_credits_history_entry.cpp @@ -162,6 +169,10 @@ PRIVATE api/api_premium.h api/api_premium_option.cpp api/api_premium_option.h + api/api_reactions_notify_settings.cpp + api/api_reactions_notify_settings.h + api/api_read_metrics.cpp + api/api_read_metrics.h api/api_report.cpp api/api_report.h api/api_ringtones.cpp @@ -222,6 +233,8 @@ PRIVATE boxes/peers/channel_ownership_transfer.h boxes/peers/choose_peer_box.cpp boxes/peers/choose_peer_box.h + boxes/peers/create_managed_bot_box.cpp + boxes/peers/create_managed_bot_box.h boxes/peers/edit_contact_box.cpp boxes/peers/edit_contact_box.h boxes/peers/edit_forum_topic_box.cpp @@ -367,6 +380,8 @@ PRIVATE boxes/stickers_box.h boxes/transfer_gift_box.cpp boxes/transfer_gift_box.h + boxes/compose_ai_box.cpp + boxes/compose_ai_box.h boxes/translate_box.cpp boxes/translate_box.h boxes/url_auth_box.cpp @@ -492,6 +507,7 @@ PRIVATE chat_helpers/ttl_media_layer_widget.h core/application.cpp core/application.h + core/cached_webview_availability.h core/bank_card_click_handler.cpp core/bank_card_click_handler.h core/base_integration.cpp @@ -682,6 +698,8 @@ PRIVATE data/data_photo_media.h data/data_poll.cpp data/data_poll.h + data/data_poll_messages.cpp + data/data_poll_messages.h data/data_premium_limits.cpp data/data_premium_limits.h data/data_pts_waiter.cpp @@ -816,6 +834,8 @@ PRIVATE history/admin_log/history_admin_log_section.cpp history/admin_log/history_admin_log_section.h history/view/controls/compose_controls_common.h + history/view/controls/history_view_compose_ai_button.cpp + history/view/controls/history_view_compose_ai_button.h history/view/controls/history_view_compose_controls.cpp history/view/controls/history_view_compose_controls.h history/view/controls/history_view_compose_media_edit_manager.cpp @@ -878,6 +898,8 @@ PRIVATE history/view/media/history_view_photo.h history/view/media/history_view_poll.cpp history/view/media/history_view_poll.h + history/view/media/menu/history_view_poll_menu.cpp + history/view/media/menu/history_view_poll_menu.h history/view/media/history_view_premium_gift.cpp history/view/media/history_view_premium_gift.h history/view/media/history_view_save_document_action.cpp @@ -939,6 +961,12 @@ PRIVATE history/view/history_view_corner_buttons.h history/view/history_view_cursor_state.cpp history/view/history_view_cursor_state.h + history/view/history_view_draw_to_reply.cpp + history/view/history_view_draw_to_reply.h + history/view/history_view_add_poll_option.cpp + history/view/history_view_add_poll_option.h + history/view/history_view_element_overlay.cpp + history/view/history_view_element_overlay.h history/view/history_view_element.cpp history/view/history_view_element.h history/view/history_view_emoji_interactions.cpp @@ -969,6 +997,8 @@ PRIVATE history/view/history_view_quick_action.h history/view/history_view_reaction_preview.cpp history/view/history_view_reaction_preview.h + history/view/history_view_read_metrics_tracker.cpp + history/view/history_view_read_metrics_tracker.h history/view/history_view_reply.cpp history/view/history_view_reply.h history/view/history_view_reply_button.cpp @@ -1101,6 +1131,8 @@ PRIVATE info/peer_gifts/info_peer_gifts_common.h info/peer_gifts/info_peer_gifts_widget.cpp info/peer_gifts/info_peer_gifts_widget.h + info/polls/info_polls_list_widget.cpp + info/polls/info_polls_list_widget.h info/polls/info_polls_results_inner_widget.cpp info/polls/info_polls_results_inner_widget.h info/polls/info_polls_results_widget.cpp @@ -1242,6 +1274,12 @@ PRIVATE lang/lang_numbers_animation.h lang/lang_translator.cpp lang/lang_translator.h + lang/translate_mtproto_provider.cpp + lang/translate_mtproto_provider.h + lang/translate_provider.cpp + lang/translate_provider.h + lang/translate_url_provider.cpp + lang/translate_url_provider.h layout/layout_document_generic_preview.cpp layout/layout_document_generic_preview.h layout/layout_item_base.cpp @@ -1264,6 +1302,8 @@ PRIVATE main/session/session_show.h media/audio/media_audio.cpp media/audio/media_audio.h + media/audio/media_audio_edit.cpp + media/audio/media_audio_edit.h media/audio/media_audio_capture.cpp media/audio/media_audio_capture.h media/audio/media_audio_capture_common.h @@ -1283,6 +1323,8 @@ PRIVATE media/player/media_player_float.h media/player/media_player_instance.cpp media/player/media_player_instance.h + media/player/media_player_listen_tracker.cpp + media/player/media_player_listen_tracker.h media/player/media_player_panel.cpp media/player/media_player_panel.h media/player/media_player_volume_controller.cpp @@ -1375,6 +1417,8 @@ PRIVATE media/system_media_controls_manager.cpp menu/menu_antispam_validator.cpp menu/menu_antispam_validator.h + menu/menu_dock.cpp + menu/menu_dock.h menu/menu_item_download_files.cpp menu/menu_item_download_files.h menu/menu_item_rate_transcribe_session.cpp @@ -1417,6 +1461,8 @@ PRIVATE overview/overview_layout.cpp overview/overview_layout.h overview/overview_layout_delegate.h + poll/poll_media_upload.cpp + poll/poll_media_upload.h passport/passport_encryption.cpp passport/passport_encryption.h passport/passport_form_controller.cpp @@ -1458,6 +1504,8 @@ PRIVATE platform/linux/overlay_widget_linux.h platform/linux/specific_linux.cpp platform/linux/specific_linux.h + platform/linux/translate_provider_linux.cpp + platform/linux/translate_provider_linux.h platform/linux/tray_linux.cpp platform/linux/tray_linux.h platform/linux/webauthn_linux.cpp @@ -1478,8 +1526,10 @@ PRIVATE platform/mac/specific_mac.h platform/mac/specific_mac_p.mm platform/mac/specific_mac_p.h - platform/mac/tray_mac.mm + platform/mac/translate_provider_mac.h + platform/mac/translate_provider_mac.mm platform/mac/tray_mac.h + platform/mac/tray_mac.mm platform/mac/webauthn_mac.mm platform/mac/window_title_mac.mm platform/mac/touchbar/items/mac_formatter_item.h @@ -1513,6 +1563,7 @@ PRIVATE platform/win/overlay_widget_win.h platform/win/specific_win.cpp platform/win/specific_win.h + platform/win/translate_provider_win.h platform/win/tray_win.cpp platform/win/tray_win.h platform/win/webauthn_win.cpp @@ -1533,6 +1584,7 @@ PRIVATE platform/platform_overlay_widget.cpp platform/platform_overlay_widget.h platform/platform_specific.h + platform/platform_translate_provider.h platform/platform_tray.h platform/platform_webauthn.h platform/platform_window_title.h @@ -1598,6 +1650,8 @@ PRIVATE settings/settings_codes.h settings/settings_common_session.cpp settings/settings_common_session.h + settings/detailed_settings_button.cpp + settings/detailed_settings_button.h settings/sections/settings_credits.cpp settings/sections/settings_credits.h settings/settings_credits_graphics.cpp @@ -1628,6 +1682,8 @@ PRIVATE settings/sections/settings_notifications.h settings/sections/settings_privacy_security.cpp settings/sections/settings_privacy_security.h + settings/sections/settings_notifications_reactions.cpp + settings/sections/settings_notifications_reactions.h settings/sections/settings_notifications_type.cpp settings/sections/settings_notifications_type.h settings/sections/settings_passkeys.cpp @@ -1715,6 +1771,8 @@ PRIVATE ui/chat/choose_theme_controller.h ui/chat/sponsored_message_bar.cpp ui/chat/sponsored_message_bar.h + ui/controls/compose_ai_button_factory.cpp + ui/controls/compose_ai_button_factory.h ui/controls/emoji_button_factory.cpp ui/controls/emoji_button_factory.h ui/controls/location_picker.cpp @@ -1744,8 +1802,6 @@ PRIVATE ui/image/image_location_factory.h ui/text/format_song_document_name.cpp ui/text/format_song_document_name.h - ui/toast/toast_lottie_icon.cpp - ui/toast/toast_lottie_icon.h ui/widgets/expandable_peer_list.cpp ui/widgets/expandable_peer_list.h ui/widgets/chat_filters_tabs_strip.cpp diff --git a/Telegram/Resources/animations/chat/sparkles_emoji.tgs b/Telegram/Resources/animations/chat/sparkles_emoji.tgs new file mode 100644 index 0000000000..9a97d10b76 Binary files /dev/null and b/Telegram/Resources/animations/chat/sparkles_emoji.tgs differ diff --git a/Telegram/Resources/animations/chat/white_flag_emoji.tgs b/Telegram/Resources/animations/chat/white_flag_emoji.tgs new file mode 100644 index 0000000000..4c7837d24e Binary files /dev/null and b/Telegram/Resources/animations/chat/white_flag_emoji.tgs differ diff --git a/Telegram/Resources/animations/photo_editor/arrow.tgs b/Telegram/Resources/animations/photo_editor/arrow.tgs new file mode 100644 index 0000000000..9a9b65a13f Binary files /dev/null and b/Telegram/Resources/animations/photo_editor/arrow.tgs differ diff --git a/Telegram/Resources/animations/photo_editor/eraser.tgs b/Telegram/Resources/animations/photo_editor/eraser.tgs new file mode 100644 index 0000000000..f8119d19da Binary files /dev/null and b/Telegram/Resources/animations/photo_editor/eraser.tgs differ diff --git a/Telegram/Resources/animations/photo_editor/marker.tgs b/Telegram/Resources/animations/photo_editor/marker.tgs new file mode 100644 index 0000000000..47bdec5e1d Binary files /dev/null and b/Telegram/Resources/animations/photo_editor/marker.tgs differ diff --git a/Telegram/Resources/animations/photo_editor/pen.tgs b/Telegram/Resources/animations/photo_editor/pen.tgs new file mode 100644 index 0000000000..4c739f679a Binary files /dev/null and b/Telegram/Resources/animations/photo_editor/pen.tgs differ diff --git a/Telegram/Resources/icons/chat/ai_letters.svg b/Telegram/Resources/icons/chat/ai_letters.svg new file mode 100644 index 0000000000..47c8ce91d6 --- /dev/null +++ b/Telegram/Resources/icons/chat/ai_letters.svg @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="24px" height="24px" viewBox="96.5 111.3 766.5 766.5" xmlns="http://www.w3.org/2000/svg"> + <g stroke="none" fill="none" fill-rule="evenodd"> + <path d="M 498.706 365.42 C 515.594 363.348 538.563 365.861 548.485 381.651 C 566.807 410.809 576.751 446.661 588.311 479.176 L 637.399 618.626 L 665.78 698.579 C 676.659 729.146 687.226 748.313 682.54 781.481 C 675.846 788.102 668.071 795.055 658.868 797.629 C 651.616 799.698 643.834 798.72 637.319 794.919 C 631.617 791.632 622.75 782.719 620.419 776.738 C 608.192 745.354 594.733 707.965 586.262 675.669 C 569.761 672.672 533.607 673.757 515.5 673.803 L 465.584 673.663 C 429.007 673.61 428.662 668.91 417.869 707.395 C 410.414 728.146 405.333 752.038 396.071 771.94 C 379.585 807.367 355.479 802.277 330.202 783.696 C 330.019 782.164 329.86 780.629 329.726 779.091 C 328.901 769.751 329.769 760.338 332.289 751.306 C 338.538 728.396 348.963 702.053 357.021 679.401 L 394.065 573.806 L 430.927 469.343 C 440.963 440.571 450.771 403.685 469.355 379.052 C 476.858 369.108 486.798 366.389 498.706 365.42 z M 504.968 436.525 C 508.866 440.798 524.908 486.278 526.975 494.054 C 537.563 533.876 560.589 582.995 564.841 623.288 C 546.777 622.864 528.711 622.571 510.642 622.408 C 489.848 622.27 469.054 622.56 448.271 623.279 C 449.463 605.6 468.851 551.806 475.731 531.511 C 486.173 500.704 494.943 466.856 504.968 436.525 z" fill="#FFFFFF"></path> + <path d="M 754.216 524.167 C 763.771 523.439 778.578 525.381 783.67 533.512 C 790.57 544.531 788.595 576.397 788.599 589.29 L 788.562 654.629 C 788.553 691.578 790.329 746.525 787.335 782.106 C 781.652 787.948 775.21 792.333 768.254 796.474 C 752.507 799.502 746.874 792.906 735.396 783.22 C 733.424 762.938 733.05 546.574 736.372 537.446 C 738.677 531.114 748.456 526.925 754.216 524.167 z" fill="#FFFFFF"></path> + <path d="M 757.56 426.311 C 768.63 427.03 775.49 429.481 785.226 434.328 C 797.887 454.333 796.6 461.896 785.005 482.127 C 777.16 485.341 772.393 487.022 764.241 489.32 C 731.637 497.151 705.869 438.865 757.56 426.311 z" fill="#FFFFFF"></path> + </g> +</svg> diff --git a/Telegram/Resources/icons/chat/ai_star1.svg b/Telegram/Resources/icons/chat/ai_star1.svg new file mode 100644 index 0000000000..4c62840ce6 --- /dev/null +++ b/Telegram/Resources/icons/chat/ai_star1.svg @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="24px" height="24px" viewBox="96.5 111.3 766.5 766.5" xmlns="http://www.w3.org/2000/svg"> + <g stroke="none" fill="none" fill-rule="evenodd"> + <path d="M 361.507 181.705 C 372.454 181.635 380.002 234.986 387.127 242.295 C 407.219 262.908 437.857 263.914 463.429 280.408 C 440.851 302.031 406.127 295.636 389.728 315.608 C 381.989 325.033 369.855 367.603 363.046 382.355 C 351.465 373.05 343.119 333.179 332.642 316.843 C 318.951 295.494 264.524 299.57 262.849 280.314 C 270.405 265.304 317.273 260.399 329.146 248.809 C 346.343 232.022 342.909 197.93 361.507 181.705 z" fill="#FFFFFF"></path> + </g> +</svg> diff --git a/Telegram/Resources/icons/chat/ai_star2.svg b/Telegram/Resources/icons/chat/ai_star2.svg new file mode 100644 index 0000000000..45d06aefd7 --- /dev/null +++ b/Telegram/Resources/icons/chat/ai_star2.svg @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="24px" height="24px" viewBox="96.5 111.3 766.5 766.5" xmlns="http://www.w3.org/2000/svg"> + <g stroke="none" fill="none" fill-rule="evenodd"> + <path d="M 227.159 367.788 C 233.561 369.307 240.228 369.662 246.771 369.89 C 248.488 420.574 264.795 427.64 311.005 436.455 C 310.491 445.011 310.17 446.339 311.45 454.821 C 305.224 455.372 297.895 455.922 291.885 456.866 C 243.817 464.419 258.4 489.944 240.267 514.706 C 237.443 517.55 234.726 520.712 231.065 522.27 C 229.488 521.415 227.953 519.681 227.797 517.859 C 223.678 469.953 209.284 457.548 161.759 455.794 C 162.291 447.329 162.683 442.73 161.662 434.383 C 213.477 429.408 223.054 418.438 227.159 367.788 z" fill="#FFFFFF"></path> + </g> +</svg> diff --git a/Telegram/Resources/icons/chat/code_tags.svg b/Telegram/Resources/icons/chat/code_tags.svg new file mode 100644 index 0000000000..f03e9ce1b3 --- /dev/null +++ b/Telegram/Resources/icons/chat/code_tags.svg @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="24px" height="24px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> + <g stroke="none" fill="none" fill-rule="evenodd"> + <path d="M5.9010,12.0 L8.9994,8.4976 A0.6,0.6 0 0,0 8.1006,7.7024 L4.5919,11.6690 A0.5,0.5 0 0,0 4.5919,12.3310 L8.1006,16.2976 A0.6,0.6 0 0,0 8.9994,15.5024 Z" fill="#FFFFFF" fill-rule="nonzero"></path> + <path d="M18.0990,12.0 L15.0006,8.4976 A0.6,0.6 0 0,1 15.8994,7.7024 L19.4081,11.6690 A0.5,0.5 0 0,1 19.4081,12.3310 L15.8994,16.2976 A0.6,0.6 0 0,1 15.0006,15.5024 Z" fill="#FFFFFF" fill-rule="nonzero"></path> + <path d="M13.7387,6.2657 L11.4387,17.9657 A0.6,0.6 0 0,1 10.2613,17.7343 L12.5613,6.0343 A0.6,0.6 0 0,1 13.7387,6.2657 Z" fill="#FFFFFF" fill-rule="nonzero"></path> + </g> +</svg> \ No newline at end of file diff --git a/Telegram/Resources/icons/chat/menu_sidebar1.svg b/Telegram/Resources/icons/chat/menu_sidebar1.svg new file mode 100644 index 0000000000..ae5d1b0c45 --- /dev/null +++ b/Telegram/Resources/icons/chat/menu_sidebar1.svg @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="72px" height="72px" viewBox="0 0 72 72" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> + <title>General / menu_sidebar1 + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/chat/menu_sidebar2.svg b/Telegram/Resources/icons/chat/menu_sidebar2.svg new file mode 100644 index 0000000000..177ce3e81d --- /dev/null +++ b/Telegram/Resources/icons/chat/menu_sidebar2.svg @@ -0,0 +1,7 @@ + + + General / menu_sidebar2 + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/chat/menu_sidebar3.svg b/Telegram/Resources/icons/chat/menu_sidebar3.svg new file mode 100644 index 0000000000..7d47fa7682 --- /dev/null +++ b/Telegram/Resources/icons/chat/menu_sidebar3.svg @@ -0,0 +1,7 @@ + + + General / menu_sidebar3 + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/dialogs/dialogs_chatlist_mention.svg b/Telegram/Resources/icons/dialogs/dialogs_chatlist_mention.svg new file mode 100644 index 0000000000..7eaf976ae6 --- /dev/null +++ b/Telegram/Resources/icons/dialogs/dialogs_chatlist_mention.svg @@ -0,0 +1,7 @@ + + + Filled / filled_chatlist_mention + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/dialogs/dialogs_chatlist_poll.svg b/Telegram/Resources/icons/dialogs/dialogs_chatlist_poll.svg new file mode 100644 index 0000000000..95226d86f3 --- /dev/null +++ b/Telegram/Resources/icons/dialogs/dialogs_chatlist_poll.svg @@ -0,0 +1,7 @@ + + + Filled / filled_chatlist_poll + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/dialogs/dialogs_chatlist_reaction.svg b/Telegram/Resources/icons/dialogs/dialogs_chatlist_reaction.svg new file mode 100644 index 0000000000..901a7a9825 --- /dev/null +++ b/Telegram/Resources/icons/dialogs/dialogs_chatlist_reaction.svg @@ -0,0 +1,7 @@ + + + Filled / filled_chatlist_reaction + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/dialogs/dialogs_mute.png b/Telegram/Resources/icons/dialogs/dialogs_mute.png new file mode 100644 index 0000000000..3b37e1172d Binary files /dev/null and b/Telegram/Resources/icons/dialogs/dialogs_mute.png differ diff --git a/Telegram/Resources/icons/dialogs/dialogs_mute@2x.png b/Telegram/Resources/icons/dialogs/dialogs_mute@2x.png new file mode 100644 index 0000000000..96681eb66f Binary files /dev/null and b/Telegram/Resources/icons/dialogs/dialogs_mute@2x.png differ diff --git a/Telegram/Resources/icons/dialogs/dialogs_mute@3x.png b/Telegram/Resources/icons/dialogs/dialogs_mute@3x.png new file mode 100644 index 0000000000..a246b7e6a5 Binary files /dev/null and b/Telegram/Resources/icons/dialogs/dialogs_mute@3x.png differ diff --git a/Telegram/Resources/icons/history_unread_poll_vote.svg b/Telegram/Resources/icons/history_unread_poll_vote.svg new file mode 100644 index 0000000000..ccc1f67190 --- /dev/null +++ b/Telegram/Resources/icons/history_unread_poll_vote.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Telegram/Resources/icons/mediaview/draw.png b/Telegram/Resources/icons/mediaview/draw.png new file mode 100644 index 0000000000..96a1d8086e Binary files /dev/null and b/Telegram/Resources/icons/mediaview/draw.png differ diff --git a/Telegram/Resources/icons/mediaview/draw@2x.png b/Telegram/Resources/icons/mediaview/draw@2x.png new file mode 100644 index 0000000000..40da849c7e Binary files /dev/null and b/Telegram/Resources/icons/mediaview/draw@2x.png differ diff --git a/Telegram/Resources/icons/mediaview/draw@3x.png b/Telegram/Resources/icons/mediaview/draw@3x.png new file mode 100644 index 0000000000..22ee4fb7bb Binary files /dev/null and b/Telegram/Resources/icons/mediaview/draw@3x.png differ diff --git a/Telegram/Resources/icons/menu/edit_stars.svg b/Telegram/Resources/icons/menu/edit_stars.svg new file mode 100644 index 0000000000..228638acb7 --- /dev/null +++ b/Telegram/Resources/icons/menu/edit_stars.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/menu/quality_hd.svg b/Telegram/Resources/icons/menu/quality_hd.svg new file mode 100644 index 0000000000..1d4378221f --- /dev/null +++ b/Telegram/Resources/icons/menu/quality_hd.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/search_check.svg b/Telegram/Resources/icons/menu/search_check.svg new file mode 100644 index 0000000000..b5e6fa0dd2 --- /dev/null +++ b/Telegram/Resources/icons/menu/search_check.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Telegram/Resources/icons/menu/spoiler_off.png b/Telegram/Resources/icons/menu/spoiler_off.png deleted file mode 100644 index c694fd15da..0000000000 Binary files a/Telegram/Resources/icons/menu/spoiler_off.png and /dev/null differ diff --git a/Telegram/Resources/icons/menu/spoiler_off@2x.png b/Telegram/Resources/icons/menu/spoiler_off@2x.png deleted file mode 100644 index 9400628b8d..0000000000 Binary files a/Telegram/Resources/icons/menu/spoiler_off@2x.png and /dev/null differ diff --git a/Telegram/Resources/icons/menu/spoiler_off@3x.png b/Telegram/Resources/icons/menu/spoiler_off@3x.png deleted file mode 100644 index ce6598ad85..0000000000 Binary files a/Telegram/Resources/icons/menu/spoiler_off@3x.png and /dev/null differ diff --git a/Telegram/Resources/icons/menu/star.png b/Telegram/Resources/icons/menu/star.png new file mode 100644 index 0000000000..4bb1ea334b Binary files /dev/null and b/Telegram/Resources/icons/menu/star.png differ diff --git a/Telegram/Resources/icons/menu/star@2x.png b/Telegram/Resources/icons/menu/star@2x.png new file mode 100644 index 0000000000..a63b6a439a Binary files /dev/null and b/Telegram/Resources/icons/menu/star@2x.png differ diff --git a/Telegram/Resources/icons/menu/star@3x.png b/Telegram/Resources/icons/menu/star@3x.png new file mode 100644 index 0000000000..d2829cba8c Binary files /dev/null and b/Telegram/Resources/icons/menu/star@3x.png differ diff --git a/Telegram/Resources/icons/photo_editor/ratio.svg b/Telegram/Resources/icons/photo_editor/ratio.svg new file mode 100644 index 0000000000..357b6dd6ef --- /dev/null +++ b/Telegram/Resources/icons/photo_editor/ratio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Telegram/Resources/icons/poll/filled/filled_poll_add.svg b/Telegram/Resources/icons/poll/filled/filled_poll_add.svg new file mode 100644 index 0000000000..6e080bc9f8 --- /dev/null +++ b/Telegram/Resources/icons/poll/filled/filled_poll_add.svg @@ -0,0 +1,7 @@ + + + Filled / filled_poll_add + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/filled/filled_poll_correct.svg b/Telegram/Resources/icons/poll/filled/filled_poll_correct.svg new file mode 100644 index 0000000000..3ae580ee8c --- /dev/null +++ b/Telegram/Resources/icons/poll/filled/filled_poll_correct.svg @@ -0,0 +1,7 @@ + + + Filled / filled_poll_correct + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/filled/filled_poll_deadline.svg b/Telegram/Resources/icons/poll/filled/filled_poll_deadline.svg new file mode 100644 index 0000000000..b7ce751224 --- /dev/null +++ b/Telegram/Resources/icons/poll/filled/filled_poll_deadline.svg @@ -0,0 +1,7 @@ + + + Filled / filled_poll_deadline + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/filled/filled_poll_multiple.svg b/Telegram/Resources/icons/poll/filled/filled_poll_multiple.svg new file mode 100644 index 0000000000..3b6e509f98 --- /dev/null +++ b/Telegram/Resources/icons/poll/filled/filled_poll_multiple.svg @@ -0,0 +1,7 @@ + + + Filled / filled_poll_multiple + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/filled/filled_poll_revote.svg b/Telegram/Resources/icons/poll/filled/filled_poll_revote.svg new file mode 100644 index 0000000000..caeedd891c --- /dev/null +++ b/Telegram/Resources/icons/poll/filled/filled_poll_revote.svg @@ -0,0 +1,7 @@ + + + Filled / filled_poll_revote + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/filled/filled_poll_shuffle.svg b/Telegram/Resources/icons/poll/filled/filled_poll_shuffle.svg new file mode 100644 index 0000000000..5aaf664f55 --- /dev/null +++ b/Telegram/Resources/icons/poll/filled/filled_poll_shuffle.svg @@ -0,0 +1,7 @@ + + + Filled / filled_poll_shuffle + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/filled/filled_poll_view.svg b/Telegram/Resources/icons/poll/filled/filled_poll_view.svg new file mode 100644 index 0000000000..514b04efbd --- /dev/null +++ b/Telegram/Resources/icons/poll/filled/filled_poll_view.svg @@ -0,0 +1,7 @@ + + + Filled / filled_poll_view + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/general/menu_poll_order.svg b/Telegram/Resources/icons/poll/general/menu_poll_order.svg new file mode 100644 index 0000000000..a680acd98c --- /dev/null +++ b/Telegram/Resources/icons/poll/general/menu_poll_order.svg @@ -0,0 +1,7 @@ + + + General / menu_poll_order + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/general/outline_poll_add.svg b/Telegram/Resources/icons/poll/general/outline_poll_add.svg new file mode 100644 index 0000000000..afb9d1a4d9 --- /dev/null +++ b/Telegram/Resources/icons/poll/general/outline_poll_add.svg @@ -0,0 +1,7 @@ + + + General / outline_poll_add + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/general/outline_poll_attach.svg b/Telegram/Resources/icons/poll/general/outline_poll_attach.svg new file mode 100644 index 0000000000..5e2de24f5a --- /dev/null +++ b/Telegram/Resources/icons/poll/general/outline_poll_attach.svg @@ -0,0 +1,7 @@ + + + General / outline_poll_attach + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/general/outline_poll_emoji.svg b/Telegram/Resources/icons/poll/general/outline_poll_emoji.svg new file mode 100644 index 0000000000..2a52749d7d --- /dev/null +++ b/Telegram/Resources/icons/poll/general/outline_poll_emoji.svg @@ -0,0 +1,7 @@ + + + General / outline_poll_emoji + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/poll/toast_hide_results.tgs b/Telegram/Resources/icons/poll/toast_hide_results.tgs new file mode 100644 index 0000000000..7e489655e3 Binary files /dev/null and b/Telegram/Resources/icons/poll/toast_hide_results.tgs differ diff --git a/Telegram/Resources/icons/poll/uploading.tgs b/Telegram/Resources/icons/poll/uploading.tgs new file mode 100644 index 0000000000..77973c5563 Binary files /dev/null and b/Telegram/Resources/icons/poll/uploading.tgs differ diff --git a/Telegram/Resources/icons/send_media/send_media_cross.svg b/Telegram/Resources/icons/send_media/send_media_cross.svg new file mode 100644 index 0000000000..50abac8af8 --- /dev/null +++ b/Telegram/Resources/icons/send_media/send_media_cross.svg @@ -0,0 +1,7 @@ + + + Icon / SendMedia / cross + + + + diff --git a/Telegram/Resources/icons/send_media/send_media_more.svg b/Telegram/Resources/icons/send_media/send_media_more.svg new file mode 100644 index 0000000000..b7ddcad3ec --- /dev/null +++ b/Telegram/Resources/icons/send_media/send_media_more.svg @@ -0,0 +1,9 @@ + + + Icon / SendMedia / more_vertical + + + + + + diff --git a/Telegram/Resources/icons/settings/toast_auction.png b/Telegram/Resources/icons/toast/auction.png similarity index 100% rename from Telegram/Resources/icons/settings/toast_auction.png rename to Telegram/Resources/icons/toast/auction.png diff --git a/Telegram/Resources/icons/settings/toast_auction@2x.png b/Telegram/Resources/icons/toast/auction@2x.png similarity index 100% rename from Telegram/Resources/icons/settings/toast_auction@2x.png rename to Telegram/Resources/icons/toast/auction@2x.png diff --git a/Telegram/Resources/icons/settings/toast_auction@3x.png b/Telegram/Resources/icons/toast/auction@3x.png similarity index 100% rename from Telegram/Resources/icons/settings/toast_auction@3x.png rename to Telegram/Resources/icons/toast/auction@3x.png diff --git a/Telegram/Resources/icons/toast/check.svg b/Telegram/Resources/icons/toast/check.svg new file mode 100644 index 0000000000..fbb3381f90 --- /dev/null +++ b/Telegram/Resources/icons/toast/check.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/toast_info.png b/Telegram/Resources/icons/toast/info.png similarity index 100% rename from Telegram/Resources/icons/toast_info.png rename to Telegram/Resources/icons/toast/info.png diff --git a/Telegram/Resources/icons/toast_info@2x.png b/Telegram/Resources/icons/toast/info@2x.png similarity index 100% rename from Telegram/Resources/icons/toast_info@2x.png rename to Telegram/Resources/icons/toast/info@2x.png diff --git a/Telegram/Resources/icons/toast_info@3x.png b/Telegram/Resources/icons/toast/info@3x.png similarity index 100% rename from Telegram/Resources/icons/toast_info@3x.png rename to Telegram/Resources/icons/toast/info@3x.png diff --git a/Telegram/Resources/icons/toast/star.svg b/Telegram/Resources/icons/toast/star.svg new file mode 100644 index 0000000000..2e8ad94b7e --- /dev/null +++ b/Telegram/Resources/icons/toast/star.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 2448605fb9..50d933173f 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -600,6 +600,19 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_notification_private_chats" = "Private chats"; "lng_notification_groups" = "Groups"; "lng_notification_channels" = "Channels"; +"lng_notification_reactions" = "Reactions"; +"lng_notification_reactions_title" = "Notifications for reactions"; +"lng_notification_reactions_notify_about" = "Notify me about"; +"lng_notification_reactions_messages" = "Messages"; +"lng_notification_reactions_messages_full" = "Reactions to my messages"; +"lng_notification_reactions_poll_votes" = "Poll votes"; +"lng_notification_reactions_poll_votes_full" = "Votes in my polls"; +"lng_notification_reactions_from" = "Notify about reactions from"; +"lng_notification_reactions_from_nobody" = "Off"; +"lng_notification_reactions_from_contacts" = "From my contacts"; +"lng_notification_reactions_from_all" = "From everyone"; +"lng_notification_reactions_settings" = "Settings"; +"lng_notification_reactions_show_sender" = "Show sender's name"; "lng_notification_click_to_change" = "Click here to change"; "lng_notification_on" = "On, {exceptions}"; "lng_notification_off" = "Off, {exceptions}"; @@ -647,6 +660,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_reaction_invoice" = "{reaction} to your invoice"; "lng_reaction_gif" = "{reaction} to your GIF"; +"lng_poll_vote_option" = "voted for \"{option}\" in your poll"; +"lng_poll_vote" = "voted in your poll \"{title}\""; +"lng_poll_vote_notext" = "voted in your poll"; + "lng_effect_add_title" = "Add an animated effect"; "lng_effect_stickers_title" = "Effects from stickers"; "lng_effect_send" = "Send with Effect"; @@ -695,6 +712,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_settings_section_chat_settings" = "Chat Settings"; "lng_settings_replace_emojis" = "Replace emoji automatically"; +"lng_settings_system_text_replace" = "System text replacements"; "lng_settings_suggest_emoji" = "Suggest emoji replacements"; "lng_settings_suggest_animated_emoji" = "Suggest animated emoji"; "lng_settings_suggest_by_emoji" = "Suggest popular stickers by emoji"; @@ -864,6 +882,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_settings_theme_tinted" = "Tinted"; "lng_settings_theme_night" = "Night"; "lng_settings_theme_accent_title" = "Choose accent color"; +"lng_settings_theme_system_accent_color" = "System accent color"; "lng_settings_data_storage" = "Data and storage"; "lng_settings_information" = "Edit profile"; "lng_settings_my_account" = "My Account"; @@ -1654,6 +1673,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_profile_photos#other" = "{count} photos"; "lng_profile_gifs#one" = "{count} GIF"; "lng_profile_gifs#other" = "{count} GIFs"; +"lng_profile_polls#one" = "{count} poll"; +"lng_profile_polls#other" = "{count} polls"; "lng_profile_videos#one" = "{count} video"; "lng_profile_videos#other" = "{count} videos"; "lng_profile_songs#one" = "{count} audio file"; @@ -1822,6 +1843,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_media_type_files" = "Files"; "lng_media_type_audios" = "Voice messages"; "lng_media_type_links" = "Shared links"; +"lng_media_type_polls" = "Polls"; +"lng_polls_search_none" = "No polls found"; "lng_media_type_rounds" = "Video messages"; "lng_media_saved_music_your" = "Your playlist"; "lng_media_saved_music_title" = "Playlist"; @@ -1850,6 +1873,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_media_selected_audio#other" = "{count} Voice messages"; "lng_media_selected_link#one" = "{count} shared link"; "lng_media_selected_link#other" = "{count} shared links"; +"lng_media_selected_poll#one" = "{count} Poll"; +"lng_media_selected_poll#other" = "{count} Polls"; "lng_media_photo_empty" = "No photos here yet"; "lng_media_gif_empty" = "No GIFs here yet"; "lng_media_video_empty" = "No videos here yet"; @@ -2495,11 +2520,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_action_topic_created_inside" = "Topic created"; "lng_action_topic_closed_inside" = "Topic closed"; "lng_action_topic_reopened_inside" = "Topic reopened"; +"lng_action_topic_closed_inside_by" = "{from} closed the topic"; +"lng_action_topic_reopened_inside_by" = "{from} reopened the topic"; "lng_action_topic_hidden_inside" = "Topic hidden"; "lng_action_topic_unhidden_inside" = "Topic unhidden"; "lng_action_topic_created" = "The topic \"{topic}\" was created"; "lng_action_topic_closed" = "\"{topic}\" was closed"; "lng_action_topic_reopened" = "\"{topic}\" was reopened"; +"lng_action_topic_closed_by" = "{from} closed \"{topic}\""; +"lng_action_topic_reopened_by" = "{from} reopened \"{topic}\""; "lng_action_topic_hidden" = "\"{topic}\" was hidden"; "lng_action_topic_unhidden" = "\"{topic}\" was unhidden"; "lng_action_topic_placeholder" = "topic"; @@ -2555,6 +2584,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_action_todo_marked_not_done_self" = "You marked {tasks} as not done."; "lng_action_todo_added" = "{from} added {tasks} to the list."; "lng_action_todo_added_self" = "You added {tasks} to the list."; +"lng_action_poll_added_answer" = "{from} added \"{option}\" to the poll."; +"lng_action_poll_added_answer_self" = "You added \"{option}\" to the poll."; +"lng_action_poll_deleted_answer" = "{from} removed \"{option}\" from the poll."; +"lng_action_poll_deleted_answer_self" = "You removed \"{option}\" from the poll."; "lng_action_todo_tasks_fallback#one" = "task"; "lng_action_todo_tasks_fallback#other" = "{count} tasks"; "lng_action_todo_tasks_and_one" = "{tasks}, {task}"; @@ -2577,6 +2610,33 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_action_stake_game_lost_you" = "You lost {amount}"; "lng_action_change_creator" = "{from} made {user} the new main admin of the group."; "lng_action_new_creator_pending" = "{user} will become the new main admin in 7 days if {from} does not return."; +"lng_action_managed_bot_created" = "{from} created a bot {bot}."; + +"lng_create_bot_title" = "Create Bot"; +"lng_create_bot_subtitle" = "{bot} would like to create and manage a chatbot on your behalf."; +"lng_create_bot_name_placeholder" = "Bot Name"; +"lng_create_bot_username_placeholder" = "Bot Username"; +"lng_create_bot_username_available" = "{username} is available."; +"lng_create_bot_username_link" = "Link: {link}"; +"lng_create_bot_username_taken" = "This username is already taken."; +"lng_create_bot_username_bad_symbols" = "Username can only contain a-z, 0-9, and underscores."; +"lng_create_bot_username_too_short" = "Username must be at least 5 characters."; +"lng_create_bot_button" = "Create"; +"lng_managed_bot_label" = "{icon} Created and managed by {bot}."; +"lng_managed_bot_ready" = "**{name}** is ready!\n\nClick **Start** below to test your new chatbot. Its behavior is defined by **{parent}**."; +"lng_managed_bot_created_title" = "{name} created!"; +"lng_managed_bot_created_text" = "{parent_name} will manage this bot for you."; +"lng_managed_bot_edit_photo" = "You can edit your bot's profile picture {link}"; +"lng_managed_bot_edit_photo_link" = "here {arrow}"; +"lng_managed_bot_set_photo" = "Set Profile Photo"; + +"lng_create_bot_no_manage" = "{bot} doesn't have Bot Management Mode enabled."; + +"lng_bots_create_limit#one" = "Subscribe to {link} to create up to {premium_count} bots, or delete one of your **{count}** bot via {bot}."; +"lng_bots_create_limit#other" = "Subscribe to {link} to create up to {premium_count} bots, or delete one of your **{count}** bots via {bot}."; +"lng_bots_create_limit_link" = "Premium"; +"lng_bots_create_limit_final#one" = "You can create up to **{count}** bot. Delete your current ones via {bot}."; +"lng_bots_create_limit_final#other" = "You can create up to **{count}** bots. Delete your current ones via {bot}."; "lng_stake_game_title" = "Emoji Stake"; "lng_stake_game_beta" = "Beta"; @@ -2980,6 +3040,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_premium_summary_about_gifts" = "Gifts are collectible items you can trade or showcase on your profile."; "lng_premium_summary_subtitle_no_forwards" = "Disable Sharing"; "lng_premium_summary_about_no_forwards" = "Restrict forwarding, copying, and saving content from your private chats."; +"lng_premium_summary_subtitle_ai_compose" = "AI Tools"; +"lng_premium_summary_about_ai_compose" = "Transform your messages and entire chats in your preferred style and language."; "lng_premium_summary_bottom_subtitle" = "About Telegram Premium"; "lng_premium_summary_bottom_about" = "While the free version of Telegram already gives its users more than any other messaging application, **Telegram Premium** pushes its capabilities even further.\n\n**Telegram Premium** is a paid option, because most Premium Features require additional expenses from Telegram to third parties such as data center providers and server manufacturers. Contributions from **Telegram Premium** users allow us to cover such costs and also help Telegram stay free for everyone."; "lng_premium_summary_button" = "Subscribe for {cost} per month"; @@ -4165,9 +4227,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_gift_resale_date" = "Date"; "lng_gift_resale_count#one" = "{count} gift in resale"; "lng_gift_resale_count#other" = "{count} gifts in resale"; +"lng_gift_resale_count_none" = "No listings"; "lng_gift_resale_sort_price" = "Sort by Price"; "lng_gift_resale_sort_date" = "Sort by Date"; "lng_gift_resale_sort_number" = "Sort by Number"; +"lng_gift_resale_all_listings" = "All Listings"; +"lng_gift_resale_stars_only" = "For Stars Only"; "lng_gift_resale_filter_all" = "Select All"; "lng_gift_resale_model" = "Model"; "lng_gift_resale_models#one" = "{count} Model"; @@ -4180,6 +4245,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_gift_resale_symbols#other" = "{count} Symbols"; "lng_gift_resale_switch_to_ton" = "Switch to Ton"; "lng_gift_resale_switch_to_stars" = "Switch to Stars"; +"lng_gift_resale_search_none" = "No gifts found for your search."; "lng_gift_resale_early" = "You will be able to resell this gift in {duration}."; "lng_gift_transfer_early" = "You will be able to transfer this gift in {duration}."; "lng_gift_resale_transfer_early_title" = "Try Later"; @@ -4759,6 +4825,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_url_auth_login_title" = "Log in to {domain}"; "lng_url_auth_login_button" = "Log in"; "lng_url_auth_site_access" = "This site will receive your **name**, **username** and **profile photo**."; +"lng_url_auth_app_access" = "This app will receive your **name**, **username** and **profile photo**."; +"lng_url_auth_unverified_app" = "Unverified App"; "lng_url_auth_device_label" = "Device"; "lng_url_auth_ip_label" = "IP Address"; "lng_url_auth_login_attempt" = "This login attempt came from the device above."; @@ -4925,6 +4993,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_context_mark_read_all_sure_2" = "**This action cannot be undone.**"; "lng_context_mark_read_mentions_all" = "Mark all mentions as read"; "lng_context_mark_read_reactions_all" = "Read all reactions"; +"lng_context_mark_read_poll_votes_all" = "Read all poll votes"; "lng_context_archive_expand" = "Expand"; "lng_context_archive_collapse" = "Collapse"; "lng_context_archive_to_menu" = "Move to main menu"; @@ -4981,8 +5050,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_context_to_msg" = "Go To Message"; "lng_context_reply_msg" = "Reply"; "lng_context_quote_and_reply" = "Quote & Reply"; +"lng_context_reply_with_timecode" = "Reply with timecode"; "lng_context_reply_to_task" = "Reply to Task"; +"lng_context_reply_to_poll_option" = "Reply to Option"; +"lng_context_copy_poll_option" = "Copy Option"; +"lng_context_copy_poll_option_link" = "Copy Option Link"; +"lng_context_delete_poll_option" = "Delete Item"; +"lng_context_poll_message_tab" = "Poll"; +"lng_context_poll_option_tab" = "Option"; "lng_context_edit_msg" = "Edit"; +"lng_context_draw" = "Edit Image"; "lng_context_add_factcheck" = "Add Fact Check"; "lng_context_edit_factcheck" = "Edit Fact Check"; "lng_context_add_offer" = "Add Offer"; @@ -5084,7 +5161,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_context_noforwards_info_mine" = "You disabled copying and forwarding in this chat."; "lng_context_spoiler_effect" = "Hide with Spoiler"; -"lng_context_disable_spoiler" = "Remove Spoiler"; "lng_context_make_paid" = "Make This Content Paid"; "lng_context_change_price" = "Change Price"; "lng_context_edit_cover" = "Edit Cover"; @@ -5201,6 +5277,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_send_grouped" = "Group items"; "lng_send_compressed_one" = "Compress the image"; "lng_send_compressed" = "Compress images"; +"lng_send_high_quality" = "High Quality"; "lng_send_as_documents_one" = "Send as a document"; "lng_send_as_documents" = "Send as documents"; "lng_send_media_invalid_files" = "Sorry, no valid files found."; @@ -5225,6 +5302,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_preview_reply_to" = "Reply to {name}"; "lng_preview_reply_to_quote" = "Reply to quote from {name}"; "lng_preview_reply_to_task" = "Reply to task from {title}"; +"lng_preview_reply_to_poll_option" = "Reply to poll option from {title}"; "lng_suggest_bar_title" = "Suggest a Post Below"; "lng_suggest_bar_text" = "Click to offer a price for publishing."; @@ -5827,6 +5905,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_group_call_screen_share_stop" = "Stop Sharing"; "lng_group_call_screen_title" = "Screen {index}"; "lng_group_call_screen_share_audio" = "Share System Audio"; +"lng_group_call_sharing_screen_options" = "Sharing Options"; +"lng_group_call_choose_source" = "Choose Source"; "lng_group_call_unmute" = "Unmute"; "lng_group_call_unmute_sub" = "Hold space bar to temporarily unmute."; "lng_group_call_you_are_live" = "You are Live"; @@ -6813,9 +6893,13 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_language_not_ready_link" = "translation platform"; "lng_translate_box_error" = "Translate failed."; +"lng_translate_box_error_language_pack_not_installed" = "Translation requires a local language pack. Download it in System Settings."; "lng_translate_settings_subtitle" = "Translate Messages"; "lng_translate_settings_show" = "Show Translate Button"; +"lng_translate_settings_use_platform_mac" = "Use Apple Translations"; +"lng_translate_settings_use_platform_mac_about" = "Translation on macOS won't work until you download local language packs in System Settings."; +"lng_translate_settings_use_platform_linux" = "Use KDE's Crow Translate"; "lng_translate_settings_chat" = "Translate Entire Chats"; "lng_translate_settings_choose" = "Do Not Translate"; "lng_translate_settings_about" = "The 'Translate' button will appear in the context menu of messages containing text."; @@ -6843,6 +6927,18 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_polls_answers_none" = "No answers"; "lng_polls_submit_votes" = "Vote"; "lng_polls_view_results" = "View results"; +"lng_polls_view_votes#one" = "View Votes ({count})"; +"lng_polls_view_votes#other" = "View Votes ({count})"; +"lng_polls_admin_votes#one" = "{count} vote {arrow}"; +"lng_polls_admin_votes#other" = "{count} votes {arrow}"; +"lng_polls_admin_back_vote" = "{arrow} Vote"; +"lng_polls_ends_in_days#one" = "ends in {count} day"; +"lng_polls_ends_in_days#other" = "ends in {count} days"; +"lng_polls_results_in_days#one" = "results in {count} day"; +"lng_polls_results_in_days#other" = "results in {count} days"; +"lng_polls_ends_in_time" = "ends in {time}"; +"lng_polls_results_in_time" = "results in {time}"; +"lng_polls_results_after_close" = "Results will appear after the poll ends."; "lng_polls_retract" = "Retract vote"; "lng_polls_stop" = "Stop poll"; "lng_polls_stop_warning" = "If you stop this poll now, nobody will be able to vote in it anymore. This action cannot be undone."; @@ -6852,12 +6948,36 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_polls_create_title" = "New poll"; "lng_polls_create_question" = "Question"; "lng_polls_create_question_placeholder" = "Ask a question"; +"lng_polls_create_description_placeholder" = "Add Description (optional)"; "lng_polls_create_options" = "Poll options"; "lng_polls_create_option_add" = "Add an option..."; "lng_polls_create_limit#one" = "You can add {count} more option."; "lng_polls_create_limit#other" = "You can add {count} more options."; "lng_polls_create_maximum" = "You have added the maximum number of options."; "lng_polls_create_settings" = "Settings"; +"lng_polls_create_show_who_voted" = "Show Who Voted"; +"lng_polls_create_show_who_voted_about" = "Display voter name on each option."; +"lng_polls_create_allow_multiple_answers" = "Allow Multiple Answers"; +"lng_polls_create_allow_multiple_answers_about" = "Voters can select more than one option."; +"lng_polls_create_allow_adding_options" = "Allow Adding Options"; +"lng_polls_create_allow_adding_options_about" = "Participants can suggest new options."; +"lng_polls_create_allow_revoting" = "Allow Revoting"; +"lng_polls_create_allow_revoting_about" = "Voters can change their vote."; +"lng_polls_create_shuffle_options" = "Shuffle Options"; +"lng_polls_create_shuffle_options_about" = "Answers appear in random order for each voter."; +"lng_polls_create_set_correct_answer" = "Set Correct Answer"; +"lng_polls_create_set_correct_answer_about" = "Mark one option as the right answer."; +"lng_polls_create_set_correct_answer_about_multi" = "Mark one or more options as the right answer."; +"lng_polls_create_limit_duration" = "Limit Duration"; +"lng_polls_create_limit_duration_about" = "Automatically close the poll at a set time."; +"lng_polls_create_poll_duration" = "Poll Duration"; +"lng_polls_create_poll_ends" = "Poll ends"; +"lng_polls_create_hide_results" = "Hide results"; +"lng_polls_create_hide_results_about" = "If you switch this on, results will appear only after the poll closes."; +"lng_polls_create_duration_custom" = "Custom"; +"lng_polls_create_deadline_title" = "Deadline"; +"lng_polls_create_deadline_button" = "Set Deadline"; +"lng_polls_create_deadline_expired" = "The poll deadline has already passed. Please choose a new time."; "lng_polls_create_anonymous" = "Anonymous Voting"; "lng_polls_create_multiple_choice" = "Multiple Answers"; "lng_polls_create_quiz_mode" = "Quiz Mode"; @@ -6869,6 +6989,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_polls_solution_title" = "Explanation"; "lng_polls_solution_placeholder" = "Add a Comment (Optional)"; "lng_polls_solution_about" = "Users will see this comment after choosing a wrong answer, good for educational purposes."; +"lng_polls_media_uploading_toast_title" = "Please wait"; +"lng_polls_media_uploading_toast" = "Poll media is still uploading..."; +"lng_polls_ends_toast" = "Results will appear after the poll ends."; "lng_polls_poll_results_title" = "Poll results"; "lng_polls_quiz_results_title" = "Quiz results"; @@ -6876,6 +6999,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_polls_show_more#other" = "Show more ({count})"; "lng_polls_votes_collapse" = "Collapse"; "lng_polls_vote_yesterday" = "yesterday"; +"lng_polls_option_added_by" = "Added by {user}"; +"lng_polls_context_ends" = "Results will appear after the poll ends."; +"lng_polls_add_option" = "Add an Option"; +"lng_polls_add_option_placeholder" = "Option text..."; +"lng_polls_max_options_reached" = "Maximum number of options reached."; +"lng_polls_add_option_duplicate" = "This option already exists."; +"lng_polls_add_option_closed" = "This poll has been closed."; +"lng_polls_add_option_error" = "Could not add the option. Please try again."; +"lng_polls_add_option_save" = "Save"; "lng_todo_title" = "Checklist"; "lng_todo_title_group" = "Group Checklist"; @@ -6912,6 +7044,13 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_outdated_soon" = "Otherwise, Telegram Desktop will stop updating on {date}."; "lng_outdated_now" = "So Telegram Desktop can update to newer versions."; +"lng_screen_reader_bar_text" = "Telegram is working in Screen Reader mode."; +"lng_screen_reader_bar_disable" = "Disable"; +"lng_screen_reader_confirm_text" = "Telegram detected accessibility software is being used in your system and it is working in Screen Reader friendly mode.\n\nThis may result in unexpected changes to the way Telegram user interface works.\n\nIf you do not use Screen Reader software with Telegram, please disable this mode."; +"lng_screen_reader_confirm_disable" = "Disable"; +"lng_screen_reader_settings_title" = "Screen reader"; +"lng_screen_reader_settings_disable" = "Disable screen reader mode"; + "lng_filters_all" = "All chats"; "lng_filters_all_short" = "All"; "lng_filters_setup" = "Edit"; @@ -7066,6 +7205,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_photo_editor_menu_flip" = "Flip"; "lng_photo_editor_menu_duplicate" = "Duplicate"; +"lng_photo_editor_crop_original" = "Original"; +"lng_photo_editor_crop_square" = "Square"; +"lng_photo_editor_crop_free" = "Free"; + "lng_voice_speed_slow" = "Slow"; "lng_voice_speed_normal" = "Normal"; "lng_voice_speed_medium" = "Medium"; @@ -7095,6 +7238,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_view_button_call" = "Join call"; "lng_view_button_storyalbum" = "View Album"; "lng_view_button_collection" = "View Collection"; +"lng_view_button_newbot" = "Create Bot"; "lng_sponsored_hide_ads" = "Hide"; "lng_sponsored_title" = "What are sponsored messages?"; @@ -7694,6 +7838,41 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_new_window_tooltip_ctrl" = "Use Ctrl+Click to open in New Window."; "lng_new_window_tooltip_cmd" = "Use Cmd+Click to open in New Window."; +"lng_rename_file" = "Rename file"; + +"lng_sr_playback_order" = "Playback order"; +"lng_sr_player_close" = "Close media player"; +"lng_sr_group_call_menu" = "Video chat menu"; +"lng_sr_search_date" = "Search by date"; +"lng_sr_cancel_search" = "Cancel search"; +"lng_sr_clear_search" = "Clear search"; +"lng_sr_scroll_to_top" = "Scroll to top"; +"lng_sr_verified_badge" = "Verified"; +"lng_sr_bot_verified_badge" = "Verified Bot"; +"lng_sr_profile_menu" = "Profile menu"; +"lng_sr_close_panel" = "Close panel"; + +"lng_ai_compose_title" = "AI Editor"; +"lng_ai_compose_apply" = "Apply"; +"lng_ai_compose_tab_translate" = "Translate"; +"lng_ai_compose_tab_style" = "Style"; +"lng_ai_compose_tab_fix" = "Fix"; +"lng_ai_compose_original" = "Original"; +"lng_ai_compose_result" = "Result"; +"lng_ai_compose_to_language" = "To {language}"; +"lng_ai_compose_name_style" = "{name} ({style})"; +"lng_ai_compose_style_neutral" = "Neutral"; +"lng_ai_compose_emojify" = "emojify"; +"lng_ai_compose_error" = "AI request failed."; +"lng_ai_compose_tooltip" = "Rewrite, translate, or correct your text using AI."; +"lng_ai_compose_flood_title" = "Daily limit reached"; +"lng_ai_compose_flood_text" = "Get {link} for **50x** more AI text transformations per day."; +"lng_ai_compose_flood_link" = "Telegram Premium"; +"lng_ai_compose_increase_limit" = "Increase Limit"; +"lng_ai_compose_select_style" = "Select Style"; +"lng_ai_compose_apply_style" = "Apply Style"; +"lng_ai_compose_style_tooltip" = "Choose Style"; + // Wnd specific "lng_wnd_choose_program_menu" = "Choose Default Program..."; @@ -7760,18 +7939,4 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_mac_hold_to_quit" = "Hold {text} to Quit"; -"lng_rename_file" = "Rename file"; - -"lng_sr_playback_order" = "Playback order"; -"lng_sr_player_close" = "Close media player"; -"lng_sr_group_call_menu" = "Video chat menu"; -"lng_sr_search_date" = "Search by date"; -"lng_sr_cancel_search" = "Cancel search"; -"lng_sr_clear_search" = "Clear search"; -"lng_sr_scroll_to_top" = "Scroll to top"; -"lng_sr_verified_badge" = "Verified"; -"lng_sr_bot_verified_badge" = "Verified Bot"; -"lng_sr_profile_menu" = "Profile menu"; -"lng_sr_close_panel" = "Close panel"; - // Keys finished diff --git a/Telegram/Resources/qrc/telegram/animations.qrc b/Telegram/Resources/qrc/telegram/animations.qrc index 989baedd1a..be013f2e59 100644 --- a/Telegram/Resources/qrc/telegram/animations.qrc +++ b/Telegram/Resources/qrc/telegram/animations.qrc @@ -19,6 +19,8 @@ ../../animations/voice_ttl_start.tgs ../../animations/chat/voice_to_video.tgs ../../animations/chat/video_to_voice.tgs + ../../animations/chat/sparkles_emoji.tgs + ../../animations/chat/white_flag_emoji.tgs ../../animations/palette.tgs ../../animations/sleep.tgs ../../animations/greeting.tgs @@ -57,6 +59,8 @@ ../../animations/cocoon.tgs ../../animations/craft_failed.tgs ../../animations/stop.tgs + ../../icons/poll/toast_hide_results.tgs + ../../icons/poll/uploading.tgs ../../animations/profile/profile_muting.tgs ../../animations/profile/profile_unmuting.tgs @@ -81,6 +85,11 @@ ../../animations/star_reaction/effect2.tgs ../../animations/star_reaction/effect3.tgs + ../../animations/photo_editor/pen.tgs + ../../animations/photo_editor/arrow.tgs + ../../animations/photo_editor/marker.tgs + ../../animations/photo_editor/eraser.tgs + ../../animations/swipe_action/archive.tgs ../../animations/swipe_action/unarchive.tgs ../../animations/swipe_action/delete.tgs diff --git a/Telegram/Resources/uwp/AppX/AppxManifest.xml b/Telegram/Resources/uwp/AppX/AppxManifest.xml index c70b27b637..4be61d47e3 100644 --- a/Telegram/Resources/uwp/AppX/AppxManifest.xml +++ b/Telegram/Resources/uwp/AppX/AppxManifest.xml @@ -10,7 +10,7 @@ + Version="6.7.0.0" /> Telegram Desktop Telegram Messenger LLP diff --git a/Telegram/Resources/winrc/Telegram.rc b/Telegram/Resources/winrc/Telegram.rc index d88d295857..06e9f70440 100644 --- a/Telegram/Resources/winrc/Telegram.rc +++ b/Telegram/Resources/winrc/Telegram.rc @@ -44,8 +44,8 @@ IDI_ICON1 ICON "..\\art\\icon256.ico" // VS_VERSION_INFO VERSIONINFO - FILEVERSION 6,6,2,0 - PRODUCTVERSION 6,6,2,0 + FILEVERSION 6,7,0,0 + PRODUCTVERSION 6,7,0,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -62,10 +62,10 @@ BEGIN BEGIN VALUE "CompanyName", "" VALUE "FileDescription", "Telegram Desktop" - VALUE "FileVersion", "6.6.2.0" + VALUE "FileVersion", "6.7.0.0" VALUE "LegalCopyright", "Copyright (C) 2014-2026" VALUE "ProductName", "Telegram Desktop" - VALUE "ProductVersion", "6.6.2.0" + VALUE "ProductVersion", "6.7.0.0" END END BLOCK "VarFileInfo" diff --git a/Telegram/Resources/winrc/Updater.rc b/Telegram/Resources/winrc/Updater.rc index 94120ca6e8..a780c18206 100644 --- a/Telegram/Resources/winrc/Updater.rc +++ b/Telegram/Resources/winrc/Updater.rc @@ -35,8 +35,8 @@ LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US // VS_VERSION_INFO VERSIONINFO - FILEVERSION 6,6,2,0 - PRODUCTVERSION 6,6,2,0 + FILEVERSION 6,7,0,0 + PRODUCTVERSION 6,7,0,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -53,10 +53,10 @@ BEGIN BEGIN VALUE "CompanyName", "" VALUE "FileDescription", "Telegram Desktop Updater" - VALUE "FileVersion", "6.6.2.0" + VALUE "FileVersion", "6.7.0.0" VALUE "LegalCopyright", "Copyright (C) 2014-2026" VALUE "ProductName", "Telegram Desktop" - VALUE "ProductVersion", "6.6.2.0" + VALUE "ProductVersion", "6.7.0.0" END END BLOCK "VarFileInfo" diff --git a/Telegram/SourceFiles/api/api_as_copy.cpp b/Telegram/SourceFiles/api/api_as_copy.cpp index a9d2fdc19e..3dfc0477e6 100644 --- a/Telegram/SourceFiles/api/api_as_copy.cpp +++ b/Telegram/SourceFiles/api/api_as_copy.cpp @@ -71,7 +71,8 @@ MTPinputMedia InputMediaFromItem(not_null i) { return MTP_inputMediaPhoto( MTP_flags(MTPDinputMediaPhoto::Flag(0)), photo->mtpInput(), - MTP_int(0)); + MTP_int(0), + MTPInputDocument()); } else { return MTP_inputMediaEmpty(); } diff --git a/Telegram/SourceFiles/api/api_bot.cpp b/Telegram/SourceFiles/api/api_bot.cpp index af159e3486..236eafad9b 100644 --- a/Telegram/SourceFiles/api/api_bot.cpp +++ b/Telegram/SourceFiles/api/api_bot.cpp @@ -11,10 +11,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_cloud_password.h" #include "api/api_send_progress.h" #include "api/api_suggest_post.h" -#include "boxes/share_box.h" -#include "boxes/passcode_box.h" -#include "boxes/url_auth_box.h" #include "boxes/peers/choose_peer_box.h" +#include "boxes/peers/create_managed_bot_box.h" +#include "boxes/passcode_box.h" +#include "boxes/share_box.h" +#include "boxes/url_auth_box.h" #include "lang/lang_keys.h" #include "chat_helpers/bot_command.h" #include "core/core_cloud_password.h" @@ -39,7 +40,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/toast/toast.h" #include "ui/layers/generic_box.h" #include "ui/text/text_utilities.h" +#include "styles/style_chat.h" +#include #include #include @@ -391,7 +394,7 @@ void ActivateBotCommand(ClickHandlerContext context, int row, int column) { case ButtonType::RequestPoll: { HideSingleUseKeyboard(controller, item); - auto chosen = PollData::Flags(); + auto chosen = kDefaultPollCreateFlags; auto disabled = PollData::Flags(); if (!button->data.isEmpty()) { disabled |= PollData::Flag::Quiz; @@ -420,9 +423,12 @@ void ActivateBotCommand(ClickHandlerContext context, int row, int column) { const auto itemId = item->id; const auto id = int32(button->buttonId); const auto chosen = [=](std::vector> result) { + using Flag = MTPmessages_SendBotRequestedPeer::Flag; peer->session().api().request(MTPmessages_SendBotRequestedPeer( + MTP_flags(Flag::f_msg_id), peer->input(), MTP_int(itemId), + MTPstring(), // request_id MTP_int(id), MTP_vector_from_range( result | ranges::views::transform([]( @@ -543,6 +549,58 @@ void ActivateBotCommand(ClickHandlerContext context, int row, int column) { QVariant::fromValue(context), }); } break; + + case ButtonType::CreateBot: { + HideSingleUseKeyboard(controller, item); + + auto suggestedName = QString(); + auto suggestedUsername = QString(); + { + auto stream = QDataStream(button->data); + stream >> suggestedName >> suggestedUsername; + } + const auto peer = item->history()->peer; + const auto itemId = item->id; + const auto id = int32(button->buttonId); + const auto bot = item->getMessageBot(); + if (!bot) { + break; + } + ShowCreateManagedBotBox({ + .show = controller->uiShow(), + .manager = bot, + .suggestedName = suggestedName, + .suggestedUsername = suggestedUsername, + .done = [=](not_null createdBot) { + using Flag = MTPmessages_SendBotRequestedPeer::Flag; + peer->session().api().request( + MTPmessages_SendBotRequestedPeer( + MTP_flags(Flag::f_msg_id), + peer->input(), + MTP_int(itemId), + MTPstring(), + MTP_int(id), + MTP_vector( + 1, + createdBot->input())) + ).done([=](const MTPUpdates &result) { + peer->session().api().applyUpdates(result); + }).send(); + controller->showPeerHistory(createdBot); + controller->showToast({ + .title = tr::lng_managed_bot_created_title( + tr::now, + lt_name, + createdBot->name()), + .text = { tr::lng_managed_bot_created_text( + tr::now, + lt_parent_name, + bot->name()) }, + .icon = &st::toastCheckIcon, + }); + }, + }); + } break; } } diff --git a/Telegram/SourceFiles/api/api_chat_participants.cpp b/Telegram/SourceFiles/api/api_chat_participants.cpp index 8900ca8dba..9e09257bbe 100644 --- a/Telegram/SourceFiles/api/api_chat_participants.cpp +++ b/Telegram/SourceFiles/api/api_chat_participants.cpp @@ -151,9 +151,8 @@ void ApplyLastList( } if (user->isBot()) { channel->mgInfo->bots.insert(user); - if ((channel->mgInfo->botStatus != 0) - && (channel->mgInfo->botStatus < 2)) { - channel->mgInfo->botStatus = 2; + if (channel->mgInfo->botStatus == Data::BotStatus::NoBots) { + channel->mgInfo->botStatus = Data::BotStatus::HasBots; } } if (!p.rank().isEmpty()) { @@ -189,7 +188,7 @@ void ApplyBotsList( Members list) { const auto history = channel->owner().historyLoaded(channel); channel->mgInfo->bots.clear(); - channel->mgInfo->botStatus = -1; + channel->mgInfo->botStatus = Data::BotStatus::NoBots; auto needBotsInfos = false; auto botStatus = channel->mgInfo->botStatus; @@ -199,7 +198,7 @@ void ApplyBotsList( const auto user = participant->asUser(); if (user && user->isBot()) { channel->mgInfo->bots.insert(user); - botStatus = 2;// (botStatus > 0/* || !i.key()->botInfo->readsAllHistory*/) ? 2 : 1; + botStatus = Data::BotStatus::HasBots; if (!user->botInfo->inited) { needBotsInfos = true; } @@ -516,7 +515,7 @@ void ChatParticipants::requestBots(not_null channel) { _botsRequests.remove(channel); if (error.type() == u"CHANNEL_MONOFORUM_UNSUPPORTED"_q) { channel->mgInfo->bots.clear(); - channel->mgInfo->botStatus = -1; + channel->mgInfo->botStatus = Data::BotStatus::NoBots; channel->session().changes().peerUpdated( channel, Data::PeerUpdate::Flag::FullInfo); diff --git a/Telegram/SourceFiles/api/api_compose_with_ai.cpp b/Telegram/SourceFiles/api/api_compose_with_ai.cpp new file mode 100644 index 0000000000..dfebd67e5f --- /dev/null +++ b/Telegram/SourceFiles/api/api_compose_with_ai.cpp @@ -0,0 +1,117 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "api/api_compose_with_ai.h" + +#include "api/api_text_entities.h" +#include "apiwrap.h" + +namespace Api { +namespace { + +[[nodiscard]] MTPTextWithEntities Serialize( + not_null session, + const TextWithEntities &text) { + return MTP_textWithEntities( + MTP_string(text.text), + EntitiesToMTP(session, text.entities, ConvertOption::SkipLocal)); +} + +} // namespace + +ComposeWithAi::ComposeWithAi(not_null api) +: _session(&api->session()) +, _api(&api->instance()) { +} + +mtpRequestId ComposeWithAi::request( + Request request, + Fn done, + Fn fail) { + using Flag = MTPmessages_composeMessageWithAI::Flag; + auto flags = MTPmessages_composeMessageWithAI::Flags(0); + if (request.proofread) { + flags |= Flag::f_proofread; + } + if (!request.translateToLang.isEmpty()) { + flags |= Flag::f_translate_to_lang; + } + if (!request.changeTone.isEmpty()) { + flags |= Flag::f_change_tone; + } + if (request.emojify) { + flags |= Flag::f_emojify; + } + const auto session = _session; + return _api.request(MTPmessages_ComposeMessageWithAI( + MTP_flags(flags), + Serialize(session, request.text), + request.translateToLang.isEmpty() + ? MTPstring() + : MTP_string(request.translateToLang), + request.changeTone.isEmpty() + ? MTPstring() + : MTP_string(request.changeTone) + )).done([=, done = std::move(done)]( + const MTPmessages_ComposedMessageWithAI &result) mutable { + const auto &data = result.data(); + auto parsed = Result{ + .resultText = ParseTextWithEntities(session, data.vresult_text()), + }; + if (const auto diff = data.vdiff_text()) { + parsed.diffText = ParseDiff(session, *diff); + } + done(std::move(parsed)); + }).fail([=, fail = std::move(fail)](const MTP::Error &error) mutable { + if (fail) { + fail(error); + } + }).send(); +} + +void ComposeWithAi::cancel(mtpRequestId requestId) { + if (requestId) { + _api.request(requestId).cancel(); + } +} + +ComposeWithAi::Diff ComposeWithAi::ParseDiff( + not_null session, + const MTPTextWithEntities &text) { + const auto &data = text.data(); + auto result = Diff{ + .text = ParseTextWithEntities(session, text), + }; + const auto &entities = data.ventities().v; + result.entities.reserve(entities.size()); + for (const auto &entity : entities) { + entity.match([&](const MTPDmessageEntityDiffInsert &data) { + result.entities.push_back({ + .type = DiffEntity::Type::Insert, + .offset = data.voffset().v, + .length = data.vlength().v, + }); + }, [&](const MTPDmessageEntityDiffReplace &data) { + result.entities.push_back({ + .type = DiffEntity::Type::Replace, + .offset = data.voffset().v, + .length = data.vlength().v, + .oldText = qs(data.vold_text()), + }); + }, [&](const MTPDmessageEntityDiffDelete &data) { + result.entities.push_back({ + .type = DiffEntity::Type::Delete, + .offset = data.voffset().v, + .length = data.vlength().v, + }); + }, [](const auto &) { + }); + } + return result; +} + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_compose_with_ai.h b/Telegram/SourceFiles/api/api_compose_with_ai.h new file mode 100644 index 0000000000..9a87aa0542 --- /dev/null +++ b/Telegram/SourceFiles/api/api_compose_with_ai.h @@ -0,0 +1,75 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "mtproto/sender.h" +#include "ui/text/text_entity.h" + +#include +#include + +class ApiWrap; + +namespace Main { +class Session; +} // namespace Main + +namespace Api { + +class ComposeWithAi final { +public: + struct Request { + TextWithEntities text; + QString translateToLang; + QString changeTone; + bool proofread = false; + bool emojify = false; + }; + + struct DiffEntity { + enum class Type { + Insert, + Replace, + Delete, + }; + + Type type = Type::Insert; + int offset = 0; + int length = 0; + QString oldText; + }; + + struct Diff { + TextWithEntities text; + std::vector entities; + }; + + struct Result { + TextWithEntities resultText; + std::optional diffText; + }; + + explicit ComposeWithAi(not_null api); + + [[nodiscard]] mtpRequestId request( + Request request, + Fn done, + Fn fail = nullptr); + void cancel(mtpRequestId requestId); + +private: + [[nodiscard]] static Diff ParseDiff( + not_null session, + const MTPTextWithEntities &text); + + const not_null _session; + MTP::Sender _api; + +}; + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_editing.cpp b/Telegram/SourceFiles/api/api_editing.cpp index 964020e389..006531ec70 100644 --- a/Telegram/SourceFiles/api/api_editing.cpp +++ b/Telegram/SourceFiles/api/api_editing.cpp @@ -207,7 +207,8 @@ mtpRequestId SuggestMessageOrMedia( inputMedia = MTP_inputMediaPhoto( MTP_flags(0), photo->mtpInput(), - MTPint()); // ttl_seconds + MTPint(), // ttl_seconds + MTPInputDocument()); // video } else if (const auto document = wasMedia->document()) { inputMedia = MTP_inputMediaDocument( MTP_flags(0), @@ -479,7 +480,8 @@ mtpRequestId EditTextMessage( return MTP_inputMediaPhoto( MTP_flags(flags), photo->mtpInput(), - MTP_int(media->ttlSeconds())); + MTP_int(media->ttlSeconds()), + MTPInputDocument()); // video }; takeFileReference = [=] { return photo->fileReference(); }; } else if (const auto document = media->document()) { diff --git a/Telegram/SourceFiles/api/api_media.cpp b/Telegram/SourceFiles/api/api_media.cpp index 845aa03894..582dc3e9b8 100644 --- a/Telegram/SourceFiles/api/api_media.cpp +++ b/Telegram/SourceFiles/api/api_media.cpp @@ -93,7 +93,8 @@ MTPInputMedia PrepareUploadedPhoto( info.file, MTP_vector( ranges::to>(info.attachedStickers)), - MTP_int(ttlSeconds)); + MTP_int(ttlSeconds), + MTPInputDocument()); // video } MTPInputMedia PrepareUploadedDocument( diff --git a/Telegram/SourceFiles/api/api_messages_search_merged.cpp b/Telegram/SourceFiles/api/api_messages_search_merged.cpp index 8e15f5255a..49bf50ce07 100644 --- a/Telegram/SourceFiles/api/api_messages_search_merged.cpp +++ b/Telegram/SourceFiles/api/api_messages_search_merged.cpp @@ -66,6 +66,8 @@ MessagesSearchMerged::MessagesSearchMerged(not_null history) void MessagesSearchMerged::disableMigrated() { _migratedSearch = std::nullopt; + _waitingForTotal = false; + _isFull = false; } void MessagesSearchMerged::addFound(const FoundMessages &data) { @@ -85,12 +87,15 @@ const MessagesSearch::Request &MessagesSearchMerged::request() const { void MessagesSearchMerged::clear() { _concatedFound = {}; _migratedFirstFound = {}; + _waitingForTotal = false; + _isFull = false; } void MessagesSearchMerged::search(const Request &search) { _request = search; + _isFull = false; + _waitingForTotal = (_migratedSearch != std::nullopt); if (_migratedSearch) { - _waitingForTotal = true; _migratedSearch->searchMessages(search); } _apiSearch.searchMessages(search); diff --git a/Telegram/SourceFiles/api/api_polls.cpp b/Telegram/SourceFiles/api/api_polls.cpp index 3e0db32755..a1f68a4110 100644 --- a/Telegram/SourceFiles/api/api_polls.cpp +++ b/Telegram/SourceFiles/api/api_polls.cpp @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_polls.h" #include "api/api_common.h" +#include "api/api_text_entities.h" #include "api/api_updates.h" #include "apiwrap.h" #include "base/random.h" @@ -30,9 +31,10 @@ Polls::Polls(not_null api) void Polls::create( const PollData &data, + const TextWithEntities &text, SendAction action, Fn done, - Fn fail) { + Fn fail) { _session->api().sendAction(action); const auto history = action.history; @@ -82,6 +84,13 @@ void Polls::create( if (sendAs) { sendFlags |= MTPmessages_SendMedia::Flag::f_send_as; } + auto sentEntities = Api::EntitiesToMTP( + _session, + text.entities, + Api::ConvertOption::SkipLocal); + if (!sentEntities.v.isEmpty()) { + sendFlags |= MTPmessages_SendMedia::Flag::f_entities; + } auto &histories = history->owner().histories(); const auto randomId = base::RandomValue(); histories.sendPreparedMessage( @@ -93,10 +102,10 @@ void Polls::create( peer->input(), Data::Histories::ReplyToPlaceholder(), PollDataToInputMedia(&data), - MTP_string(), + MTP_string(text.text), MTP_long(randomId), MTPReplyMarkup(), - MTPVector(), + sentEntities, MTP_int(action.options.scheduled), MTP_int(action.options.scheduleRepeatPeriod), (sendAs ? sendAs->input() : MTP_inputPeerEmpty()), @@ -124,7 +133,9 @@ void Polls::create( monoforumPeerId, UnixtimeFromMsgId(response.outerMsgId)); } - fail(); + const auto expired = (error.code() == 400) + && error.type().startsWith(u"FILE_REFERENCE_"_q); + fail(expired); }); } @@ -176,6 +187,73 @@ void Polls::sendVotes( _pollVotesRequestIds.emplace(itemId, requestId); } +void Polls::addAnswer( + FullMsgId itemId, + const TextWithEntities &text, + const PollMedia &media, + Fn done, + Fn fail) { + if (_pollAddAnswerRequestIds.contains(itemId)) { + return; + } + const auto item = _session->data().message(itemId); + if (!item) { + return; + } + const auto sentEntities = Api::EntitiesToMTP( + _session, + text.entities, + Api::ConvertOption::SkipLocal); + using Flag = MTPDinputPollAnswer::Flag; + const auto flags = media + ? Flag::f_media + : Flag(); + const auto answer = MTP_inputPollAnswer( + MTP_flags(flags), + MTP_textWithEntities( + MTP_string(text.text), + sentEntities), + media ? PollMediaToMTP(media) : MTPInputMedia()); + const auto requestId = _api.request(MTPmessages_AddPollAnswer( + item->history()->peer->input(), + MTP_int(item->id), + answer + )).done([=](const MTPUpdates &result) { + _pollAddAnswerRequestIds.erase(itemId); + _session->updates().applyUpdates(result); + if (done) { + done(); + } + }).fail([=](const MTP::Error &error) { + _pollAddAnswerRequestIds.erase(itemId); + if (fail) { + fail(error.type()); + } + }).send(); + _pollAddAnswerRequestIds.emplace(itemId, requestId); +} + +void Polls::deleteAnswer(FullMsgId itemId, const QByteArray &option) { + if (_pollVotesRequestIds.contains(itemId)) { + return; + } + const auto item = _session->data().message(itemId); + if (!item) { + return; + } + const auto requestId = _api.request(MTPmessages_DeletePollAnswer( + item->history()->peer->input(), + MTP_int(item->id), + MTP_bytes(option) + )).done([=](const MTPUpdates &result) { + _pollVotesRequestIds.erase(itemId); + _session->updates().applyUpdates(result); + }).fail([=] { + _pollVotesRequestIds.erase(itemId); + }).send(); + _pollVotesRequestIds.emplace(itemId, requestId); +} + void Polls::close(not_null item) { const auto itemId = item->fullId(); if (_pollCloseRequestIds.contains(itemId)) { @@ -211,9 +289,13 @@ void Polls::reloadResults(not_null item) { if (!item->isRegular() || _pollReloadRequestIds.contains(itemId)) { return; } + const auto media = item->media(); + const auto poll = media ? media->poll() : nullptr; + const auto pollHash = poll ? poll->hash : uint64(0); const auto requestId = _api.request(MTPmessages_GetPollResults( item->history()->peer->input(), - MTP_int(item->id) + MTP_int(item->id), + MTP_long(pollHash) )).done([=](const MTPUpdates &result) { _pollReloadRequestIds.erase(itemId); _session->updates().applyUpdates(result); diff --git a/Telegram/SourceFiles/api/api_polls.h b/Telegram/SourceFiles/api/api_polls.h index f77e34d67c..26d46e09af 100644 --- a/Telegram/SourceFiles/api/api_polls.h +++ b/Telegram/SourceFiles/api/api_polls.h @@ -8,10 +8,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "mtproto/sender.h" +#include "ui/text/text_entity.h" class ApiWrap; class HistoryItem; struct PollData; +struct PollMedia; namespace Main { class Session; @@ -27,12 +29,20 @@ public: void create( const PollData &data, + const TextWithEntities &text, SendAction action, Fn done, - Fn fail); + Fn fail); void sendVotes( FullMsgId itemId, const std::vector &options); + void addAnswer( + FullMsgId itemId, + const TextWithEntities &text, + const PollMedia &media, + Fn done, + Fn fail); + void deleteAnswer(FullMsgId itemId, const QByteArray &option); void close(not_null item); void reloadResults(not_null item); @@ -41,6 +51,7 @@ private: MTP::Sender _api; base::flat_map _pollVotesRequestIds; + base::flat_map _pollAddAnswerRequestIds; base::flat_map _pollCloseRequestIds; base::flat_map _pollReloadRequestIds; diff --git a/Telegram/SourceFiles/api/api_premium.cpp b/Telegram/SourceFiles/api/api_premium.cpp index f628d46e88..215c9640f5 100644 --- a/Telegram/SourceFiles/api/api_premium.cpp +++ b/Telegram/SourceFiles/api/api_premium.cpp @@ -56,6 +56,9 @@ namespace { + QChar(0x00D7) + ' ' + QString::number(tlOption.vusers().v); + options[i].total = Ui::FillAmountAndCurrency( + tlOption.vamount().v, + currency); options[i].currency = currency; } return options; diff --git a/Telegram/SourceFiles/api/api_premium_option.cpp b/Telegram/SourceFiles/api/api_premium_option.cpp index 5cc9c9a125..3acd9aca2b 100644 --- a/Telegram/SourceFiles/api/api_premium_option.cpp +++ b/Telegram/SourceFiles/api/api_premium_option.cpp @@ -15,29 +15,33 @@ constexpr auto kDiscountDivider = 1.; Data::PremiumSubscriptionOption CreateSubscriptionOption( int months, - int monthlyAmount, + float64 monthlyAmount, int64 amount, const QString ¤cy, const QString &botUrl) { + const auto baselineAmount = monthlyAmount * months; const auto discount = [&] { - const auto percent = 1. - float64(amount) / (monthlyAmount * months); - return std::round(percent * 100. / kDiscountDivider) + const auto percent = 1. - float64(amount) / baselineAmount; + return base::SafeRound(percent * 100. / kDiscountDivider) * kDiscountDivider; }(); + const auto hasDiscount = (discount > 0); return { .months = months, .duration = Ui::FormatTTL(months * 86400 * 31), - .discount = (discount > 0) + .discount = hasDiscount ? QString::fromUtf8("\xe2\x88\x92%1%").arg(discount) : QString(), .costPerMonth = Ui::FillAmountAndCurrency( - amount / float64(months), - currency), - .costNoDiscount = Ui::FillAmountAndCurrency( - monthlyAmount * months, + int64(base::SafeRound(amount / float64(months))), currency), + .costNoDiscount = hasDiscount + ? Ui::FillAmountAndCurrency( + int64(base::SafeRound(baselineAmount)), + currency) + : QString(), .costPerYear = Ui::FillAmountAndCurrency( - amount / float64(months / 12.), + int64(base::SafeRound(amount / float64(months / 12.))), currency), .botUrl = botUrl, }; diff --git a/Telegram/SourceFiles/api/api_premium_option.h b/Telegram/SourceFiles/api/api_premium_option.h index 2648a7f9c7..4b265f1fb3 100644 --- a/Telegram/SourceFiles/api/api_premium_option.h +++ b/Telegram/SourceFiles/api/api_premium_option.h @@ -13,7 +13,7 @@ namespace Api { [[nodiscard]] Data::PremiumSubscriptionOption CreateSubscriptionOption( int months, - int monthlyAmount, + float64 monthlyAmount, int64 amount, const QString ¤cy, const QString &botUrl); @@ -24,9 +24,9 @@ template if (tlOpts.isEmpty()) { return {}; } - auto monthlyAmountPerCurrency = base::flat_map(); + auto monthlyAmountPerCurrency = base::flat_map(); auto result = Data::PremiumSubscriptionOptions(); - const auto monthlyAmount = [&](const QString ¤cy) -> int { + const auto monthlyAmount = [&](const QString ¤cy) -> float64 { const auto it = monthlyAmountPerCurrency.find(currency); if (it != end(monthlyAmountPerCurrency)) { return it->second; diff --git a/Telegram/SourceFiles/api/api_reactions_notify_settings.cpp b/Telegram/SourceFiles/api/api_reactions_notify_settings.cpp new file mode 100644 index 0000000000..e28d52ff02 --- /dev/null +++ b/Telegram/SourceFiles/api/api_reactions_notify_settings.cpp @@ -0,0 +1,185 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "api/api_reactions_notify_settings.h" + +#include "apiwrap.h" +#include "main/main_session.h" + +namespace Api { +namespace { + +[[nodiscard]] ReactionsNotifyFrom ParseFrom( + const MTPReactionNotificationsFrom &from) { + return from.match([](const MTPDreactionNotificationsFromContacts &) { + return ReactionsNotifyFrom::Contacts; + }, [](const MTPDreactionNotificationsFromAll &) { + return ReactionsNotifyFrom::All; + }); +} + +[[nodiscard]] MTPReactionNotificationsFrom SerializeFrom( + ReactionsNotifyFrom from) { + switch (from) { + case ReactionsNotifyFrom::Contacts: + return MTP_reactionNotificationsFromContacts(); + case ReactionsNotifyFrom::All: + return MTP_reactionNotificationsFromAll(); + } + Unexpected("Value in SerializeFrom."); +} + +} // namespace + +ReactionsNotifySettings::ReactionsNotifySettings(not_null api) +: _session(&api->session()) +, _api(&api->instance()) { +} + +void ReactionsNotifySettings::reload(Fn callback) { + if (callback) { + _callbacks.push_back(std::move(callback)); + } + if (_requestId) { + return; + } + _requestId = _api.request(MTPaccount_GetReactionsNotifySettings( + )).done([=](const MTPReactionsNotifySettings &result) { + _requestId = 0; + apply(result); + for (const auto &callback : base::take(_callbacks)) { + callback(); + } + }).fail([=] { + _requestId = 0; + for (const auto &callback : base::take(_callbacks)) { + callback(); + } + }).send(); +} + +void ReactionsNotifySettings::updateMessagesFrom(ReactionsNotifyFrom value) { + _messagesFrom = value; + save(); +} + +void ReactionsNotifySettings::updatePollVotesFrom( + ReactionsNotifyFrom value) { + _pollVotesFrom = value; + save(); +} + +void ReactionsNotifySettings::setAllFrom(ReactionsNotifyFrom value) { + _messagesFrom = value; + _pollVotesFrom = value; + save(); +} + +void ReactionsNotifySettings::updateShowPreviews(bool value) { + _showPreviews = value; + save(); +} + +ReactionsNotifyFrom ReactionsNotifySettings::messagesFromCurrent() const { + return _messagesFrom.current(); +} + +rpl::producer ReactionsNotifySettings::messagesFrom() const { + return _messagesFrom.value(); +} + +ReactionsNotifyFrom ReactionsNotifySettings::pollVotesFromCurrent() const { + return _pollVotesFrom.current(); +} + +rpl::producer ReactionsNotifySettings::pollVotesFrom() const { + return _pollVotesFrom.value(); +} + +bool ReactionsNotifySettings::showPreviewsCurrent() const { + return _showPreviews.current(); +} + +rpl::producer ReactionsNotifySettings::showPreviews() const { + return _showPreviews.value(); +} + +bool ReactionsNotifySettings::enabledCurrent() const { + return (_messagesFrom.current() != ReactionsNotifyFrom::None) + || (_pollVotesFrom.current() != ReactionsNotifyFrom::None); +} + +rpl::producer ReactionsNotifySettings::enabled() const { + return rpl::combine( + _messagesFrom.value(), + _pollVotesFrom.value() + ) | rpl::map([]( + ReactionsNotifyFrom messages, + ReactionsNotifyFrom pollVotes) { + return (messages != ReactionsNotifyFrom::None) + || (pollVotes != ReactionsNotifyFrom::None); + }) | rpl::distinct_until_changed(); +} + +void ReactionsNotifySettings::apply( + const MTPReactionsNotifySettings &settings) { + const auto &data = settings.data(); + const auto messages = data.vmessages_notify_from(); + const auto stories = data.vstories_notify_from(); + const auto pollVotes = data.vpoll_votes_notify_from(); + _messagesFrom = messages + ? ParseFrom(*messages) + : ReactionsNotifyFrom::None; + _storiesFrom = stories + ? ParseFrom(*stories) + : ReactionsNotifyFrom::None; + _pollVotesFrom = pollVotes + ? ParseFrom(*pollVotes) + : ReactionsNotifyFrom::None; + _showPreviews = mtpIsTrue(data.vshow_previews()); +} + +void ReactionsNotifySettings::save() { + using Flag = MTPDreactionsNotifySettings::Flag; + const auto messages = _messagesFrom.current(); + const auto stories = _storiesFrom.current(); + const auto pollVotes = _pollVotesFrom.current(); + const auto previews = _showPreviews.current(); + const auto flags = Flag() + | ((messages != ReactionsNotifyFrom::None) + ? Flag::f_messages_notify_from + : Flag()) + | ((stories != ReactionsNotifyFrom::None) + ? Flag::f_stories_notify_from + : Flag()) + | ((pollVotes != ReactionsNotifyFrom::None) + ? Flag::f_poll_votes_notify_from + : Flag()); + _api.request(base::take(_requestId)).cancel(); + _requestId = _api.request(MTPaccount_SetReactionsNotifySettings( + MTP_reactionsNotifySettings( + MTP_flags(flags), + ((messages != ReactionsNotifyFrom::None) + ? SerializeFrom(messages) + : MTPReactionNotificationsFrom()), + ((stories != ReactionsNotifyFrom::None) + ? SerializeFrom(stories) + : MTPReactionNotificationsFrom()), + ((pollVotes != ReactionsNotifyFrom::None) + ? SerializeFrom(pollVotes) + : MTPReactionNotificationsFrom()), + MTP_notificationSoundDefault(), + MTP_bool(previews)) + )).done([=](const MTPReactionsNotifySettings &result) { + _requestId = 0; + apply(result); + }).fail([=] { + _requestId = 0; + }).send(); +} + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_reactions_notify_settings.h b/Telegram/SourceFiles/api/api_reactions_notify_settings.h new file mode 100644 index 0000000000..d29a1a6787 --- /dev/null +++ b/Telegram/SourceFiles/api/api_reactions_notify_settings.h @@ -0,0 +1,65 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "mtproto/sender.h" + +class ApiWrap; + +namespace Main { +class Session; +} // namespace Main + +namespace Api { + +enum class ReactionsNotifyFrom : uchar { + None, + Contacts, + All, +}; + +class ReactionsNotifySettings final { +public: + explicit ReactionsNotifySettings(not_null api); + + void reload(Fn callback = nullptr); + + void updateMessagesFrom(ReactionsNotifyFrom value); + void updatePollVotesFrom(ReactionsNotifyFrom value); + void setAllFrom(ReactionsNotifyFrom value); + void updateShowPreviews(bool value); + + [[nodiscard]] ReactionsNotifyFrom messagesFromCurrent() const; + [[nodiscard]] rpl::producer messagesFrom() const; + [[nodiscard]] ReactionsNotifyFrom pollVotesFromCurrent() const; + [[nodiscard]] rpl::producer pollVotesFrom() const; + [[nodiscard]] bool showPreviewsCurrent() const; + [[nodiscard]] rpl::producer showPreviews() const; + + [[nodiscard]] bool enabledCurrent() const; + [[nodiscard]] rpl::producer enabled() const; + +private: + void apply(const MTPReactionsNotifySettings &settings); + void save(); + + const not_null _session; + MTP::Sender _api; + mtpRequestId _requestId = 0; + rpl::variable _messagesFrom + = ReactionsNotifyFrom::All; + rpl::variable _storiesFrom + = ReactionsNotifyFrom::All; + rpl::variable _pollVotesFrom + = ReactionsNotifyFrom::All; + rpl::variable _showPreviews = true; + std::vector> _callbacks; + +}; + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_read_metrics.cpp b/Telegram/SourceFiles/api/api_read_metrics.cpp new file mode 100644 index 0000000000..8d2f03c3ef --- /dev/null +++ b/Telegram/SourceFiles/api/api_read_metrics.cpp @@ -0,0 +1,73 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "api/api_read_metrics.h" + +#include "apiwrap.h" +#include "data/data_peer.h" + +namespace Api { +namespace { + +constexpr auto kSendTimeout = crl::time(5000); + +} // namespace + +ReadMetrics::ReadMetrics(not_null api) +: _api(&api->instance()) +, _timer([=] { send(); }) { +} + +void ReadMetrics::add( + not_null peer, + FinalizedReadMetric metric) { + _pending[peer].push_back(metric); + if (!_timer.isActive()) { + _timer.callOnce(kSendTimeout); + } +} + +void ReadMetrics::send() { + for (auto i = _pending.begin(); i != _pending.end();) { + if (_requests.contains(i->first)) { + ++i; + continue; + } + + auto metrics = QVector(); + metrics.reserve(i->second.size()); + for (const auto &m : i->second) { + metrics.push_back(MTP_inputMessageReadMetric( + MTP_int(m.msgId.bare), + MTP_long(m.viewId), + MTP_int(m.timeInViewMs), + MTP_int(m.activeTimeInViewMs), + MTP_int(m.heightToViewportRatioPermille), + MTP_int(m.seenRangeRatioPermille))); + } + const auto peer = i->first; + const auto finish = [=] { + _requests.erase(peer); + if (!_pending.empty() && !_timer.isActive()) { + _timer.callOnce(kSendTimeout); + } + }; + const auto requestId = _api.request(MTPmessages_ReportReadMetrics( + peer->input(), + MTP_vector(std::move(metrics)) + )).done([=](const MTPBool &) { + finish(); + }).fail([=](const MTP::Error &) { + finish(); + }).send(); + + _requests.emplace(peer, requestId); + i = _pending.erase(i); + } +} + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_read_metrics.h b/Telegram/SourceFiles/api/api_read_metrics.h new file mode 100644 index 0000000000..dcac63db29 --- /dev/null +++ b/Telegram/SourceFiles/api/api_read_metrics.h @@ -0,0 +1,45 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "mtproto/sender.h" +#include "base/timer.h" + +class ApiWrap; +class PeerData; + +namespace Api { + +struct FinalizedReadMetric { + MsgId msgId = 0; + uint64 viewId = 0; + int timeInViewMs = 0; + int activeTimeInViewMs = 0; + int heightToViewportRatioPermille = 0; + int seenRangeRatioPermille = 0; +}; + +class ReadMetrics final { +public: + explicit ReadMetrics(not_null api); + + void add(not_null peer, FinalizedReadMetric metric); + +private: + void send(); + + MTP::Sender _api; + base::flat_map< + not_null, + std::vector> _pending; + base::flat_map, mtpRequestId> _requests; + base::Timer _timer; + +}; + +} // namespace Api diff --git a/Telegram/SourceFiles/api/api_sending.cpp b/Telegram/SourceFiles/api/api_sending.cpp index cea6816551..a6ecbb82d3 100644 --- a/Telegram/SourceFiles/api/api_sending.cpp +++ b/Telegram/SourceFiles/api/api_sending.cpp @@ -332,7 +332,8 @@ void SendExistingPhoto( return MTP_inputMediaPhoto( MTP_flags(0), photo->mtpInput(), - MTPint()); + MTPint(), + MTPInputDocument()); }; SendExistingMedia( std::move(message), @@ -377,7 +378,8 @@ void SendExistingPhoto( return MTP_inputMediaPhoto( MTP_flags(0), photo->mtpInput(), - MTPint()); + MTPint(), // ttl_seconds + MTPInputDocument()); // video }; SendExistingMedia( std::move(message), @@ -658,7 +660,8 @@ void SendConfirmedFile( MTP_flags(Flag::f_photo | (file->spoiler ? Flag::f_spoiler : Flag())), file->photo, - MTPint()); + MTPint(), // ttl_seconds + MTPDocument()); // video } else if (file->type == SendMediaType::File) { using Flag = MTPDmessageMediaDocument::Flag; return MTP_messageMediaDocument( diff --git a/Telegram/SourceFiles/api/api_text_entities.cpp b/Telegram/SourceFiles/api/api_text_entities.cpp index 8db97edf0c..93a1f467cc 100644 --- a/Telegram/SourceFiles/api/api_text_entities.cpp +++ b/Telegram/SourceFiles/api/api_text_entities.cpp @@ -264,6 +264,9 @@ EntitiesInText EntitiesFromMTP( d.vlength().v, SerializeFormattedDateData(d.vdate().v, flags), }); + }, [&](const MTPDmessageEntityDiffInsert &) { + }, [&](const MTPDmessageEntityDiffReplace &) { + }, [&](const MTPDmessageEntityDiffDelete &) { }); } return result; diff --git a/Telegram/SourceFiles/api/api_transcribes.cpp b/Telegram/SourceFiles/api/api_transcribes.cpp index fae17a7566..bc31e1b998 100644 --- a/Telegram/SourceFiles/api/api_transcribes.cpp +++ b/Telegram/SourceFiles/api/api_transcribes.cpp @@ -257,7 +257,8 @@ void Transcribes::summarize(not_null item) { : MTP_flags(MTPmessages_summarizeText::Flag::f_to_lang), item->history()->peer->input(), MTP_int(item->id), - langCode.isEmpty() ? MTPstring() : MTP_string(langCode) + langCode.isEmpty() ? MTPstring() : MTP_string(langCode), + MTPstring() // tone )).done([=](const MTPTextWithEntities &result) { const auto &data = result.data(); auto &entry = _summaries[id]; diff --git a/Telegram/SourceFiles/api/api_unread_things.cpp b/Telegram/SourceFiles/api/api_unread_things.cpp index f7a43d6414..e871b71faa 100644 --- a/Telegram/SourceFiles/api/api_unread_things.cpp +++ b/Telegram/SourceFiles/api/api_unread_things.cpp @@ -42,6 +42,13 @@ bool UnreadThings::trackReactions(Data::Thread *thread) const { return peer && (peer->isUser() || peer->isChat() || peer->isMegagroup()); } +bool UnreadThings::trackPollVotes(Data::Thread *thread) const { + const auto peer = thread ? thread->peer().get() : nullptr; + return peer + && (peer->isChat() || peer->isMegagroup()) + && !peer->isMonoforum(); +} + void UnreadThings::preloadEnough(Data::Thread *thread) { if (trackMentions(thread)) { preloadEnoughMentions(thread); @@ -49,6 +56,9 @@ void UnreadThings::preloadEnough(Data::Thread *thread) { if (trackReactions(thread)) { preloadEnoughReactions(thread); } + if (trackPollVotes(thread)) { + preloadEnoughPollVotes(thread); + } } void UnreadThings::mediaAndMentionsRead( @@ -84,6 +94,15 @@ void UnreadThings::preloadEnoughReactions(not_null thread) { } } +void UnreadThings::preloadEnoughPollVotes(not_null thread) { + const auto fullCount = thread->unreadPollVotes().count(); + const auto loadedCount = thread->unreadPollVotes().loadedCount(); + const auto allLoaded = (fullCount >= 0) && (loadedCount >= fullCount); + if (fullCount >= 0 && loadedCount < kPreloadIfLess && !allLoaded) { + requestPollVotes(thread, loadedCount); + } +} + void UnreadThings::cancelRequests(not_null thread) { if (const auto requestId = _mentionsRequests.take(thread)) { _api->request(*requestId).cancel(); @@ -91,6 +110,9 @@ void UnreadThings::cancelRequests(not_null thread) { if (const auto requestId = _reactionsRequests.take(thread)) { _api->request(*requestId).cancel(); } + if (const auto requestId = _pollVotesRequests.take(thread)) { + _api->request(*requestId).cancel(); + } } void UnreadThings::requestMentions( @@ -164,4 +186,38 @@ void UnreadThings::requestReactions( _reactionsRequests.emplace(thread, requestId); } +void UnreadThings::requestPollVotes( + not_null thread, + int loaded) { + if (_pollVotesRequests.contains(thread) || thread->asSublist()) { + return; + } + const auto offsetId = loaded + ? std::max(thread->unreadPollVotes().maxLoaded(), MsgId(1)) + : MsgId(1); + const auto limit = loaded ? kNextRequestLimit : kFirstRequestLimit; + const auto addOffset = loaded ? -(limit + 1) : -limit; + const auto maxId = 0; + const auto minId = 0; + const auto history = thread->owningHistory(); + const auto topic = thread->asTopic(); + using Flag = MTPmessages_GetUnreadPollVotes::Flag; + const auto requestId = _api->request(MTPmessages_GetUnreadPollVotes( + MTP_flags(topic ? Flag::f_top_msg_id : Flag()), + history->peer->input(), + MTP_int(topic ? topic->rootId() : 0), + MTP_int(offsetId), + MTP_int(addOffset), + MTP_int(limit), + MTP_int(maxId), + MTP_int(minId) + )).done([=](const MTPmessages_Messages &result) { + _pollVotesRequests.remove(thread); + thread->unreadPollVotes().addSlice(result, loaded); + }).fail([=] { + _pollVotesRequests.remove(thread); + }).send(); + _pollVotesRequests.emplace(thread, requestId); +} + } // namespace UnreadThings diff --git a/Telegram/SourceFiles/api/api_unread_things.h b/Telegram/SourceFiles/api/api_unread_things.h index f3c7e1711e..dfc07459a5 100644 --- a/Telegram/SourceFiles/api/api_unread_things.h +++ b/Telegram/SourceFiles/api/api_unread_things.h @@ -23,6 +23,7 @@ public: [[nodiscard]] bool trackMentions(Data::Thread *thread) const; [[nodiscard]] bool trackReactions(Data::Thread *thread) const; + [[nodiscard]] bool trackPollVotes(Data::Thread *thread) const; void preloadEnough(Data::Thread *thread); @@ -35,14 +36,17 @@ public: private: void preloadEnoughMentions(not_null thread); void preloadEnoughReactions(not_null thread); + void preloadEnoughPollVotes(not_null thread); void requestMentions(not_null thread, int loaded); void requestReactions(not_null thread, int loaded); + void requestPollVotes(not_null thread, int loaded); const not_null _api; base::flat_map, mtpRequestId> _mentionsRequests; base::flat_map, mtpRequestId> _reactionsRequests; + base::flat_map, mtpRequestId> _pollVotesRequests; }; diff --git a/Telegram/SourceFiles/api/api_updates.cpp b/Telegram/SourceFiles/api/api_updates.cpp index bbb0ab721a..1aa8fc9df8 100644 --- a/Telegram/SourceFiles/api/api_updates.cpp +++ b/Telegram/SourceFiles/api/api_updates.cpp @@ -91,6 +91,90 @@ enum class DataIsLoadedResult { Ok = 3, }; +[[nodiscard]] bool PeerDataIsLoaded( + not_null owner, + PeerId peerId) { + return !peerId || owner->peerLoaded(peerId); +} + +[[nodiscard]] bool MentionUsersDataIsLoaded( + not_null owner, + const MTPVector &entities) { + for (const auto &entity : entities.v) { + auto loaded = true; + entity.match([&](const MTPDmessageEntityMentionName &data) { + loaded = owner->userLoaded(data.vuser_id()); + }, [&](const MTPDinputMessageEntityMentionName &data) { + data.vuser_id().match([&](const MTPDinputUser &data) { + loaded = owner->userLoaded(data.vuser_id()); + }, [](const auto &) { + }); + }, [](const auto &) { + }); + if (!loaded) { + return false; + } + } + return true; +} + +[[nodiscard]] bool ForwardedInfoDataIsLoaded( + not_null owner, + const MTPMessageFwdHeader &header) { + return header.match([&](const MTPDmessageFwdHeader &data) { + return (!data.vfrom_id() + || PeerDataIsLoaded(owner, peerFromMTP(*data.vfrom_id()))) + && (!data.vsaved_from_peer() + || PeerDataIsLoaded(owner, peerFromMTP(*data.vsaved_from_peer()))) + && (!data.vsaved_from_id() + || PeerDataIsLoaded(owner, peerFromMTP(*data.vsaved_from_id()))); + }); +} + +[[nodiscard]] bool ReplyDataIsLoaded( + not_null owner, + const MTPMessageReplyHeader &header) { + return header.match([&](const MTPDmessageReplyHeader &data) { + return (!data.vreply_to_peer_id() + || PeerDataIsLoaded(owner, peerFromMTP(*data.vreply_to_peer_id()))) + && (!data.vreply_from() + || ForwardedInfoDataIsLoaded(owner, *data.vreply_from())) + && (!data.vquote_entities() + || MentionUsersDataIsLoaded(owner, *data.vquote_entities())); + }, [&](const MTPDmessageReplyStoryHeader &data) { + return PeerDataIsLoaded(owner, peerFromMTP(data.vpeer())); + }); +} + +[[nodiscard]] bool DataIsLoaded( + not_null owner, + const MTPDupdateShortMessage &data) { + return owner->userLoaded(data.vuser_id()) + && (!data.vfwd_from() + || ForwardedInfoDataIsLoaded(owner, *data.vfwd_from())) + && (!data.vvia_bot_id() + || owner->userLoaded(*data.vvia_bot_id())) + && (!data.vreply_to() + || ReplyDataIsLoaded(owner, *data.vreply_to())) + && (!data.ventities() + || MentionUsersDataIsLoaded(owner, *data.ventities())); +} + +[[nodiscard]] bool DataIsLoaded( + not_null owner, + const MTPDupdateShortChatMessage &data) { + return owner->chatLoaded(data.vchat_id()) + && owner->userLoaded(data.vfrom_id()) + && (!data.vfwd_from() + || ForwardedInfoDataIsLoaded(owner, *data.vfwd_from())) + && (!data.vvia_bot_id() + || owner->userLoaded(*data.vvia_bot_id())) + && (!data.vreply_to() + || ReplyDataIsLoaded(owner, *data.vreply_to())) + && (!data.ventities() + || MentionUsersDataIsLoaded(owner, *data.ventities())); +} + void ProcessScheduledMessageWithElapsedTime( not_null session, bool needToAdd, @@ -1424,9 +1508,9 @@ void Updates::applyUpdates( case mtpc_updateShortMessage: { auto &d = updates.c_updateShortMessage(); - if (!session().data().userLoaded(d.vuser_id())) { + if (!DataIsLoaded(&_session->data(), d)) { MTP_LOG(0, ("getDifference " - "{ good - getting user for updateShortMessage }%1" + "{ good - after not all data loaded in updateShortMessage }%1" ).arg(_session->mtp().isTestMode() ? " TESTMODE" : "")); return getDifference(); } @@ -1439,10 +1523,9 @@ void Updates::applyUpdates( case mtpc_updateShortChatMessage: { auto &d = updates.c_updateShortChatMessage(); - const auto chat = session().data().chatLoaded(d.vchat_id()); - if (!chat) { + if (!DataIsLoaded(&_session->data(), d)) { MTP_LOG(0, ("getDifference " - "{ good - getting chat for updateShortChatMessage }%1" + "{ good - after not all data loaded in updateShortChatMessage }%1" ).arg(_session->mtp().isTestMode() ? " TESTMODE" : "")); return getDifference(); } @@ -1848,7 +1931,53 @@ void Updates::feedUpdate(const MTPUpdate &update) { } break; case mtpc_updateMessagePoll: { - session().data().applyUpdate(update.c_updateMessagePoll()); + const auto &d = update.c_updateMessagePoll(); + const auto wasRecentVoters = session().data().pollRecentVoters( + d.vpoll_id().v); + session().data().applyUpdate(d); + const auto notifyItem = session().data().findItemForPoll( + d.vpoll_id().v); + if (notifyItem) { + CheckPollVoteNotificationSchedule( + notifyItem, + wasRecentVoters); + } + if (const auto tlPeer = d.vpeer()) { + const auto &results = d.vresults(); + const auto hasUnread = results.match([]( + const MTPDpollResults &data) { + return data.is_has_unread_votes(); + }); + const auto isMin = results.match([]( + const MTPDpollResults &data) { + return data.is_min(); + }); + const auto peer = peerFromMTP(*tlPeer); + const auto msgId = d.vmsg_id()->v; + if (const auto history = session().data().historyLoaded(peer)) { + if (const auto item = session().data().message( + peer, + msgId)) { + if (hasUnread) { + if (!item->hasUnreadPollVote()) { + item->setHasUnreadPollVote(); + item->addToUnreadThings( + HistoryUnreadThings::AddType::New); + } + } else if (!isMin && item->hasUnreadPollVote()) { + item->markPollVotesRead(); + } + } else { + if (history->unreadPollVotes().has()) { + if (hasUnread) { + history->unreadPollVotes().checkAdd(msgId); + } + } + history->owner().histories().requestDialogEntry( + history); + } + } + } } break; case mtpc_updateUserTyping: { diff --git a/Telegram/SourceFiles/apiwrap.cpp b/Telegram/SourceFiles/apiwrap.cpp index 72204923aa..4435002cf7 100644 --- a/Telegram/SourceFiles/apiwrap.cpp +++ b/Telegram/SourceFiles/apiwrap.cpp @@ -25,12 +25,15 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_self_destruct.h" #include "api/api_sensitive_content.h" #include "api/api_global_privacy.h" +#include "api/api_reactions_notify_settings.h" #include "api/api_updates.h" #include "api/api_user_privacy.h" +#include "api/api_read_metrics.h" #include "api/api_views.h" #include "api/api_confirm_phone.h" #include "api/api_unread_things.h" #include "api/api_ringtones.h" +#include "api/api_compose_with_ai.h" #include "api/api_transcribes.h" #include "api/api_premium.h" #include "api/api_user_names.h" @@ -177,10 +180,13 @@ ApiWrap::ApiWrap(not_null session) , _selfDestruct(std::make_unique(this)) , _sensitiveContent(std::make_unique(this)) , _globalPrivacy(std::make_unique(this)) +, _reactionsNotifySettings( + std::make_unique(this)) , _userPrivacy(std::make_unique(this)) , _inviteLinks(std::make_unique(this)) , _chatLinks(std::make_unique(this)) , _views(std::make_unique(this)) +, _readMetrics(std::make_unique(this)) , _confirmPhone(std::make_unique(this)) , _peerPhoto(std::make_unique(this)) , _polls(std::make_unique(this)) @@ -188,6 +194,7 @@ ApiWrap::ApiWrap(not_null session) , _chatParticipants(std::make_unique(this)) , _unreadThings(std::make_unique(this)) , _ringtones(std::make_unique(this)) +, _composeWithAi(std::make_unique(this)) , _transcribes(std::make_unique(this)) , _premium(std::make_unique(this)) , _usernames(std::make_unique(this)) @@ -203,6 +210,7 @@ ApiWrap::ApiWrap(not_null session) requestMoreDialogsIfNeeded(); }, _session->lifetime()); + _reactionsNotifySettings->reload(); setupSupportMode(); }); } @@ -3843,6 +3851,7 @@ void ApiWrap::editMedia( .spoiler = false, .album = nullptr, .forceFile = false, + .sendLargePhotos = false, .idOverride = 0, }) : nullptr), @@ -3852,6 +3861,7 @@ void ApiWrap::editMedia( .spoiler = file.spoiler, .album = nullptr, .forceFile = forceFile, + .sendLargePhotos = file.sendLargePhotos, .idOverride = 0, .displayName = file.displayName, })); @@ -3860,32 +3870,8 @@ void ApiWrap::editMedia( void ApiWrap::sendFiles( Ui::PreparedList &&list, SendMediaType type, - TextWithTags &&caption, std::shared_ptr album, const SendAction &action) { - const auto haveCaption = !caption.text.isEmpty(); - const auto captionAttached = !haveCaption - ? false - : (list.files.size() == 1) - ? list.canAddCaption( - album != nullptr, - type == SendMediaType::Photo) - : Ui::CaptionWillBeAttached( - list, - [&] { - auto way = Ui::SendFilesWay(); - way.setGroupFiles(album != nullptr); - way.setSendImagesAsPhotos(type == SendMediaType::Photo); - return way; - }(), - false); - if (haveCaption && !captionAttached) { - auto message = MessageToSend(action); - message.textWithTags = base::take(caption); - message.action.clearDraft = false; - sendMessage(std::move(message)); - } - const auto to = FileLoadTaskOptions(action); if (album) { album->options = to.options; @@ -3919,19 +3905,20 @@ void ApiWrap::sendFiles( .spoiler = false, .album = nullptr, .forceFile = false, + .sendLargePhotos = false, .idOverride = 0, }) : nullptr), .type = uploadWithType, .to = to, - .caption = caption, + .caption = std::move(file.caption), .spoiler = file.spoiler, .album = album, .forceFile = forceFile, + .sendLargePhotos = file.sendLargePhotos, .idOverride = 0, .displayName = file.displayName, })); - caption = TextWithTags(); } if (album) { _sendingAlbums.emplace(album->groupId, album); @@ -4487,7 +4474,8 @@ void ApiWrap::uploadAlbumMedia( fields.vid(), fields.vaccess_hash(), fields.vfile_reference()), - MTP_int(data.vttl_seconds().value_or_empty())); + MTP_int(data.vttl_seconds().value_or_empty()), + MTPInputDocument()); // video sendAlbumWithUploaded(item, groupId, media); } break; @@ -5009,6 +4997,10 @@ Api::GlobalPrivacy &ApiWrap::globalPrivacy() { return *_globalPrivacy; } +Api::ReactionsNotifySettings &ApiWrap::reactionsNotifySettings() { + return *_reactionsNotifySettings; +} + Api::UserPrivacy &ApiWrap::userPrivacy() { return *_userPrivacy; } @@ -5025,6 +5017,10 @@ Api::ViewsManager &ApiWrap::views() { return *_views; } +Api::ReadMetrics &ApiWrap::readMetrics() { + return *_readMetrics; +} + Api::ConfirmPhone &ApiWrap::confirmPhone() { return *_confirmPhone; } @@ -5053,6 +5049,10 @@ Api::Ringtones &ApiWrap::ringtones() { return *_ringtones; } +Api::ComposeWithAi &ApiWrap::composeWithAi() { + return *_composeWithAi; +} + Api::Transcribes &ApiWrap::transcribes() { return *_transcribes; } diff --git a/Telegram/SourceFiles/apiwrap.h b/Telegram/SourceFiles/apiwrap.h index dbd0a4c08c..d968b988c3 100644 --- a/Telegram/SourceFiles/apiwrap.h +++ b/Telegram/SourceFiles/apiwrap.h @@ -69,6 +69,7 @@ class CloudPassword; class SelfDestruct; class SensitiveContent; class GlobalPrivacy; +class ReactionsNotifySettings; class UserPrivacy; class InviteLinks; class ChatLinks; @@ -81,8 +82,10 @@ class TodoLists; class ChatParticipants; class UnreadThings; class Ringtones; +class ComposeWithAi; class Transcribes; class Premium; +class ReadMetrics; class Usernames; class Websites; @@ -337,7 +340,6 @@ public: void sendFiles( Ui::PreparedList &&list, SendMediaType type, - TextWithTags &&caption, std::shared_ptr album, const SendAction &action); void sendFile( @@ -415,10 +417,12 @@ public: [[nodiscard]] Api::SelfDestruct &selfDestruct(); [[nodiscard]] Api::SensitiveContent &sensitiveContent(); [[nodiscard]] Api::GlobalPrivacy &globalPrivacy(); + [[nodiscard]] Api::ReactionsNotifySettings &reactionsNotifySettings(); [[nodiscard]] Api::UserPrivacy &userPrivacy(); [[nodiscard]] Api::InviteLinks &inviteLinks(); [[nodiscard]] Api::ChatLinks &chatLinks(); [[nodiscard]] Api::ViewsManager &views(); + [[nodiscard]] Api::ReadMetrics &readMetrics(); [[nodiscard]] Api::ConfirmPhone &confirmPhone(); [[nodiscard]] Api::PeerPhoto &peerPhoto(); [[nodiscard]] Api::Polls &polls(); @@ -426,6 +430,7 @@ public: [[nodiscard]] Api::ChatParticipants &chatParticipants(); [[nodiscard]] Api::UnreadThings &unreadThings(); [[nodiscard]] Api::Ringtones &ringtones(); + [[nodiscard]] Api::ComposeWithAi &composeWithAi(); [[nodiscard]] Api::Transcribes &transcribes(); [[nodiscard]] Api::Premium &premium(); [[nodiscard]] Api::Usernames &usernames(); @@ -773,10 +778,12 @@ private: const std::unique_ptr _selfDestruct; const std::unique_ptr _sensitiveContent; const std::unique_ptr _globalPrivacy; + const std::unique_ptr _reactionsNotifySettings; const std::unique_ptr _userPrivacy; const std::unique_ptr _inviteLinks; const std::unique_ptr _chatLinks; const std::unique_ptr _views; + const std::unique_ptr _readMetrics; const std::unique_ptr _confirmPhone; const std::unique_ptr _peerPhoto; const std::unique_ptr _polls; @@ -784,6 +791,7 @@ private: const std::unique_ptr _chatParticipants; const std::unique_ptr _unreadThings; const std::unique_ptr _ringtones; + const std::unique_ptr _composeWithAi; const std::unique_ptr _transcribes; const std::unique_ptr _premium; const std::unique_ptr _usernames; diff --git a/Telegram/SourceFiles/boxes/add_contact_box.cpp b/Telegram/SourceFiles/boxes/add_contact_box.cpp index c6becf7bc1..0e81390e3c 100644 --- a/Telegram/SourceFiles/boxes/add_contact_box.cpp +++ b/Telegram/SourceFiles/boxes/add_contact_box.cpp @@ -577,7 +577,8 @@ void GroupInfoBox::prepare() { _title->setMaxLength(Ui::EditPeer::kMaxGroupChannelTitle); _title->setInstantReplaces(Ui::InstantReplaces::Default()); _title->setInstantReplacesEnabled( - Core::App().settings().replaceEmojiValue()); + Core::App().settings().replaceEmojiValue(), + Core::App().settings().systemTextReplaceValue()); Ui::Emoji::SuggestionsController::Init( getDelegate()->outerContainer(), _title, @@ -593,7 +594,8 @@ void GroupInfoBox::prepare() { _description->setMaxLength(Ui::EditPeer::kMaxChannelDescription); _description->setInstantReplaces(Ui::InstantReplaces::Default()); _description->setInstantReplacesEnabled( - Core::App().settings().replaceEmojiValue()); + Core::App().settings().replaceEmojiValue(), + Core::App().settings().systemTextReplaceValue()); _description->setSubmitSettings( Core::App().settings().sendSubmitWay()); diff --git a/Telegram/SourceFiles/boxes/boxes.style b/Telegram/SourceFiles/boxes/boxes.style index 6ca5eda121..1e0cdfa0d7 100644 --- a/Telegram/SourceFiles/boxes/boxes.style +++ b/Telegram/SourceFiles/boxes/boxes.style @@ -688,69 +688,6 @@ themesMenuToggle: IconButton(defaultIconButton) { } themesMenuPosition: point(2px, 25px); -createPollField: InputField(defaultInputField) { - textMargins: margins(0px, 4px, 0px, 4px); - textAlign: align(left); - heightMin: 36px; - heightMax: 86px; - placeholderFg: placeholderFg; - placeholderFgActive: placeholderFgActive; - placeholderFgError: placeholderFgActive; - placeholderMargins: margins(2px, 0px, 2px, 0px); - placeholderAlign: align(topleft); - placeholderScale: 0.; - placeholderFont: boxTextFont; - placeholderShift: -50px; - border: 0px; - borderActive: 0px; - duration: 100; -} -createPollFieldPadding: margins(22px, 5px, 22px, 5px); -createPollOptionField: InputField(createPollField) { - textMargins: margins(22px, 11px, 40px, 11px); - placeholderMargins: margins(2px, 0px, 2px, 0px); - heightMax: 68px; -} -createPollOptionFieldPremium: InputField(createPollOptionField) { - textMargins: margins(22px, 11px, 68px, 11px); -} -createPollOptionFieldPremiumEmojiPosition: point(15px, -1px); -createPollSolutionField: InputField(createPollField) { - textMargins: margins(0px, 4px, 0px, 4px); - border: 1px; - borderActive: 2px; -} -createPollLimitPadding: margins(22px, 10px, 22px, 16px); -createPollOptionRemove: CrossButton { - width: 22px; - height: 22px; - - cross: CrossAnimation { - size: 22px; - skip: 6px; - stroke: 1.5; - minScale: 0.3; - } - crossFg: boxTitleCloseFg; - crossFgOver: boxTitleCloseFgOver; - crossPosition: point(0px, 0px); - - duration: 150; - loadingPeriod: 1000; - ripple: defaultRippleAnimationBgOver; -} -createPollOptionRemovePosition: point(11px, 9px); -createPollOptionEmojiPositionSkip: 4px; -createPollWarning: FlatLabel(defaultFlatLabel) { - textFg: windowSubTextFg; - palette: TextPalette(defaultTextPalette) { - linkFg: boxTextFgError; - } -} -createPollWarningPosition: point(16px, 6px); -createPollCheckboxMargin: margins(22px, 10px, 22px, 10px); -createPollFieldTitlePadding: margins(22px, 7px, 10px, 6px); - sendGifWithCaptionEmojiPosition: point(-30px, 23px); notesFieldWithEmoji: InputField(defaultInputField) { @@ -876,36 +813,6 @@ tagPreviewLineHeight: 8px; tagPreviewLineSpacing: 4px; tagPreviewInputSkip: 16px; -pollResultsQuestion: FlatLabel(defaultFlatLabel) { - minWidth: 320px; - textFg: windowBoldFg; - style: TextStyle(defaultTextStyle) { - font: font(16px semibold); - } -} -pollResultsVotesCount: FlatLabel(defaultFlatLabel) { - textFg: windowSubTextFg; -} -pollResultsHeaderPadding: margins(22px, 22px, 22px, 8px); -pollResultsShowMore: SettingsButton(defaultSettingsButton) { - textFg: lightButtonFg; - textFgOver: lightButtonFgOver; - textBg: windowBg; - textBgOver: windowBgOver; - - style: semiboldTextStyle; - - height: 20px; - padding: margins(71px, 10px, 8px, 8px); - - ripple: defaultRippleAnimation; -} -pollResultsVoteTimeFont: font(fsize); -pollResultsVoteTimeDateFont: font(fsize); -pollResultsVoteTimeRightSkip: 16px; -pollResultsVoteTimeLeftSkip: 8px; -pollResultsVoteTimeGap: 2px; - inviteViaLinkButton: SettingsButton(defaultSettingsButton) { textFg: lightButtonFg; textFgOver: lightButtonFgOver; @@ -1235,3 +1142,150 @@ disableSharingButtonLock: IconEmoji { icon: icon {{ "emoji/premium_lock", activeButtonFg }}; padding: margins(-2px, 1px, 0px, 0px); } + +createBotBox: Box(defaultBox) { + shadowIgnoreTopSkip: true; +} +createBotUserpicPadding: margins(0px, 16px, 0px, 10px); +createBotTitlePadding: margins(0px, 0px, 0px, 8px); +createBotSubtitlePadding: margins(0px, 0px, 0px, 4px); +createBotCenteredText: FlatLabel(defaultFlatLabel) { + align: align(top); + minWidth: 40px; +} +createBotFieldSpacing: 8px; +createBotUsernameField: InputField(defaultInputField) { + textMargins: margins(0px, 28px, 0px, 4px); +} +createBotUsernamePrefix: FlatLabel(defaultFlatLabel) { + style: boxTextStyle; +} +createBotUsernameSuffix: FlatLabel(createBotUsernamePrefix) { + textFg: windowSubTextFg; +} + +managedBotIconEmoji: IconEmoji { + icon: icon {{ "chat/code_tags", windowSubTextFg }}; + padding: margins(-2px, -2px, -2px, 0px); +} + +createBotStatusLabel: FlatLabel(aboutRevokePublicLabel) { + maxHeight: 20px; +} + +aiComposeBoxSectionSkip: 8px; +aiComposeBoxStyleTabsSkip: 8px; +aiComposeContentMargin: margins(16px, 0px, 16px, 0px); + +aiComposeButtonBgActive: windowActiveTextFg; +aiComposeButtonBgActiveOpacity: 0.1; +aiComposeButtonFg: windowBoldFg; +aiComposeButtonFgActive: windowActiveTextFg; +aiComposeButtonRippleInactive: RippleAnimation(defaultRippleAnimation) { + color: windowBoldFg; +} +aiComposeButtonRippleInactiveOpacity: 0.1; +aiComposeButtonRippleActive: RippleAnimation(defaultRippleAnimation) { + color: windowActiveTextFg; +} +aiComposeButtonRippleActiveOpacity: 0.1; + +aiComposeTabsBg: boxBg; +aiComposeTabsHeight: 58px; +aiComposeTabsRadius: 29px; +aiComposeTabsPadding: margins(6px, 6px, 6px, 6px); +aiComposeTabsSkip: 4px; +aiComposeTabButtonBgActive: aiComposeButtonBgActive; +aiComposeTabLabelFg: aiComposeButtonFg; +aiComposeTabLabelFgActive: aiComposeButtonFgActive; +aiComposeTabLabelFont: font(12px semibold); +aiComposeTabIconTop: 6px; +aiComposeTabLabelTop: 26px; +aiComposeTabTranslateIcon: icon {{ "menu/translate", aiComposeButtonFg }}; +aiComposeTabTranslateIconActive: icon {{ "menu/translate", aiComposeButtonFgActive }}; +aiComposeTabStyleIcon: icon {{ "menu/edit_stars", aiComposeButtonFg }}; +aiComposeTabStyleIconActive: icon {{ "menu/edit_stars", aiComposeButtonFgActive }}; +aiComposeTabFixIcon: icon {{ "menu/search_check", aiComposeButtonFg }}; +aiComposeTabFixIconActive: icon {{ "menu/search_check", aiComposeButtonFgActive }}; + +aiComposeStyleTabsBg: boxBg; +aiComposeStyleTabsHeight: 64px; +aiComposeStyleTabsRadius: 14px; +aiComposeStyleTabsPadding: margins(6px, 6px, 6px, 6px); +aiComposeStyleTabsSkip: 4px; +aiComposeStyleButtonBgActive: aiComposeButtonBgActive; +aiComposeStyleLabelFg: aiComposeButtonFg; +aiComposeStyleLabelFgActive: aiComposeButtonFgActive; +aiComposeStyleEmojiTop: 5px; +aiComposeStyleLabelTop: 30px; +aiComposeStyleLabelFont: font(12px semibold); +aiComposeStyleTabsScroll: ScrollArea(defaultScrollArea) { + barHidden: true; +} +aiComposeStyleButtonPadding: margins(12px, 0px, 12px, 0px); +aiComposeStyleFadeWidth: 24px; +aiComposeBadge: RoundButton(customEmojiTextBadge) { + textFg: activeButtonBg; + textBg: activeButtonFg; + width: -8px; + height: 16px; + radius: 4px; + textTop: 1px; + style: TextStyle(semiboldTextStyle) { + font: font(10px semibold); + } +} +aiComposeBadgeMargin: margins(0px, 2px, 0px, 0px); + +aiComposeCardBg: boxBg; +aiComposeCardRadius: 22px; +aiComposeCardPadding: margins(12px, 16px, 16px, 16px); +aiComposeCardDivider: shadowFg; +aiComposeCardSectionSkip: 12px; +aiComposeCardTextSkip: 0px; +aiComposeCardControlSkip: 8px; +aiComposeCardTitle: FlatLabel(defaultSubsectionTitle) { + textFg: windowFg; + minWidth: 0px; + maxHeight: 22px; +} +aiComposeEmojifyCheckbox: Checkbox(defaultBoxCheckbox) { + width: 0px; +} + +aiComposeExpandIcon: icon{{ "info/edit/expand_arrow_small", windowActiveTextFg }}; +aiComposeCollapseIcon: icon{{ "info/edit/expand_arrow_small-flip_vertical", windowActiveTextFg }}; +aiComposeExpandButton: IconButton(defaultIconButton) { + width: 32px; + height: 32px; + icon: aiComposeExpandIcon; + iconOver: aiComposeExpandIcon; + iconPosition: point(5px, 5px); + rippleAreaPosition: point(0px, 0px); + rippleAreaSize: 32px; + ripple: defaultRippleAnimationBgOver; +} + +aiComposeCopyIcon: icon{{ "menu/copy", windowActiveTextFg }}; +aiComposeCopyButton: IconButton(aiComposeExpandButton) { + icon: aiComposeCopyIcon; + iconOver: aiComposeCopyIcon; +} + +aiComposeBoxButton: RoundButton(defaultActiveButton) { + height: 42px; + textTop: 12px; + style: semiboldTextStyle; +} +aiComposeBox: Box(defaultBox) { + buttonPadding: margins(16px, 12px, 16px, 12px); + buttonHeight: 42px; + buttonWide: true; + button: aiComposeBoxButton; + shadowIgnoreTopSkip: true; + bg: boxDividerBg; +} +aiComposeBoxWithSend: Box(aiComposeBox) { + buttonPadding: margins(16px, 12px, 66px, 12px); +} +aiComposeSendButtonSkip: 8px; diff --git a/Telegram/SourceFiles/boxes/compose_ai_box.cpp b/Telegram/SourceFiles/boxes/compose_ai_box.cpp new file mode 100644 index 0000000000..2fb272151a --- /dev/null +++ b/Telegram/SourceFiles/boxes/compose_ai_box.cpp @@ -0,0 +1,1611 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "boxes/compose_ai_box.h" + +#include "api/api_compose_with_ai.h" +#include "apiwrap.h" +#include "boxes/premium_preview_box.h" +#include "chat_helpers/compose/compose_show.h" +#include "chat_helpers/stickers_lottie.h" +#include "core/application.h" +#include "core/click_handler_types.h" +#include "core/core_settings.h" +#include "core/ui_integration.h" +#include "data/data_document.h" +#include "data/data_user.h" +#include "data/stickers/data_custom_emoji.h" +#include "data/data_session.h" +#include "lang/lang_keys.h" +#include "main/session/session_show.h" +#include "main/main_app_config.h" +#include "main/main_session.h" +#include "settings/sections/settings_premium.h" +#include "spellcheck/platform/platform_language.h" +#include "ui/boxes/choose_language_box.h" +#include "ui/chat/chat_style.h" +#include "ui/controls/labeled_emoji_tabs.h" +#include "ui/controls/send_button.h" +#include "ui/effects/ripple_animation.h" +#include "ui/effects/skeleton_animation.h" +#include "ui/layers/generic_box.h" +#include "ui/painter.h" +#include "ui/ui_utility.h" +#include "ui/text/custom_emoji_helper.h" +#include "ui/text/custom_emoji_text_badge.h" +#include "ui/text/text_extended_data.h" +#include "ui/text/text_utilities.h" +#include "ui/vertical_list.h" +#include "ui/wrap/slide_wrap.h" +#include "ui/wrap/vertical_layout.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/checkbox.h" +#include "ui/widgets/labels.h" +#include "ui/widgets/tooltip.h" +#include "styles/style_basic.h" +#include "styles/style_boxes.h" +#include "styles/style_chat_helpers.h" +#include "styles/style_layers.h" +#include "styles/style_widgets.h" + +#include +#include + +namespace HistoryView::Controls { +namespace { + +constexpr auto kAiComposeStyleTooltipHiddenPref = "ai_compose_style_tooltip_hidden"_cs; + +enum class ComposeAiMode { + Translate, + Style, + Fix, +}; + +enum class CardState { + Waiting, + Loading, + Ready, + Failed, +}; + +[[nodiscard]] QColor ComposeAiColorWithAlpha( + const style::color &color, + float64 alpha) { + auto result = color->c; + result.setAlphaF(result.alphaF() * alpha); + return result; +} + +[[nodiscard]] TextWithEntities HighlightDiff(TextWithEntities text) { + return Ui::Text::Colorized( + Ui::Text::Wrapped(std::move(text), EntityType::Underline), 1); +} + +[[nodiscard]] TextWithEntities StrikeOutDiff(TextWithEntities text) { + return Ui::Text::Colorized( + Ui::Text::Wrapped(std::move(text), EntityType::StrikeOut), 2); +} + +[[nodiscard]] TextWithEntities BuildDiffDisplay( + const Api::ComposeWithAi::Diff &diff) { + auto result = TextWithEntities(); + auto entities = diff.entities; + std::stable_sort( + entities.begin(), + entities.end(), + [](const auto &a, const auto &b) { + return a.offset < b.offset; + }); + const auto size = int(diff.text.text.size()); + auto taken = 0; + for (const auto &entity : entities) { + const auto offset = std::clamp(entity.offset, 0, size); + const auto length = std::clamp(entity.length, 0, size - offset); + if (offset > taken) { + result.append(Ui::Text::Mid(diff.text, taken, offset - taken)); + } + auto part = Ui::Text::Mid(diff.text, offset, length); + switch (entity.type) { + case Api::ComposeWithAi::DiffEntity::Type::Insert: + result.append(HighlightDiff(std::move(part))); + break; + case Api::ComposeWithAi::DiffEntity::Type::Replace: + if (!entity.oldText.isEmpty()) { + result.append( + StrikeOutDiff( + TextWithEntities::Simple(entity.oldText))); + } + result.append(HighlightDiff(std::move(part))); + break; + case Api::ComposeWithAi::DiffEntity::Type::Delete: + result.append(StrikeOutDiff(std::move(part))); + break; + } + taken = std::max(taken, offset + length); + } + if (taken < size) { + result.append(Ui::Text::Mid(diff.text, taken)); + } + return result; +} + +[[nodiscard]] QString FromTitle(LanguageId id) { + return tr::lng_ai_compose_original(tr::now); +} + +[[nodiscard]] TextWithEntities ToTitle( + LanguageId id, + const QString &style) { + const auto name = style.isEmpty() + ? tr::link(Ui::LanguageName(id)) + : tr::link(tr::lng_ai_compose_name_style( + tr::now, + lt_name, + tr::marked(Ui::LanguageName(id)), + lt_style, + tr::marked(style), + tr::marked)); + return tr::lng_ai_compose_to_language( + tr::now, + lt_language, + name, + tr::marked); +} + +[[nodiscard]] LanguageId DefaultAiTranslateTo(LanguageId offeredFrom) { + const auto current = LanguageId{ + QLocale(Lang::LanguageIdOrDefault(Lang::Id())).language() + }; + if (current && (current != offeredFrom)) { + return current; + } + const auto english = LanguageId{ QLocale::English }; + if (english != offeredFrom) { + return english; + } + return LanguageId{ QLocale::Spanish }; +} + +[[nodiscard]] const style::icon &ModeIcon( + ComposeAiMode mode, + bool active) { + switch (mode) { + case ComposeAiMode::Translate: + return active + ? st::aiComposeTabTranslateIconActive + : st::aiComposeTabTranslateIcon; + case ComposeAiMode::Style: + return active + ? st::aiComposeTabStyleIconActive + : st::aiComposeTabStyleIcon; + case ComposeAiMode::Fix: + return active + ? st::aiComposeTabFixIconActive + : st::aiComposeTabFixIcon; + } + return active + ? st::aiComposeTabTranslateIconActive + : st::aiComposeTabTranslateIcon; +} + +[[nodiscard]] qreal ComposeAiPillRadius(int height) { + return height / 2.; +} + +[[nodiscard]] QColor ComposeAiActiveBackgroundColor( + const style::color &color) { + return ComposeAiColorWithAlpha( + color, + st::aiComposeButtonBgActiveOpacity); +} + +[[nodiscard]] QColor ComposeAiRippleColor( + const style::RippleAnimation &ripple, + float64 opacity) { + return ComposeAiColorWithAlpha( + ripple.color, + opacity); +} + +[[nodiscard]] Ui::LabeledEmojiTab ResolveStyleDescriptor( + const Main::AppConfig::AiComposeStyle &style) { + return { + .id = style.type, + .label = style.title, + .customEmojiData = Data::SerializeCustomEmojiId(style.emojiId), + }; +} + +[[nodiscard]] std::vector ResolveStyleDescriptors( + const std::vector &styles) { + auto result = std::vector(); + result.reserve(styles.size()); + for (const auto &style : styles) { + result.push_back(ResolveStyleDescriptor(style)); + } + return result; +} + +[[nodiscard]] std::vector ResolveTranslateStyleDescriptors( + not_null session, + const std::vector &styles) { + const auto neutral = ChatHelpers::GenerateLocalTgsSticker( + session, + u"chat/white_flag_emoji"_q); + auto result = std::vector(); + result.reserve(styles.size() + 1); + result.push_back({ + .id = QString(), + .label = tr::lng_ai_compose_style_neutral(tr::now), + .customEmojiData = Data::SerializeCustomEmojiId(neutral->id), + }); + result.insert(end(result), begin(styles), end(styles)); + return result; +} + +[[nodiscard]] TextWithEntities LoadingTitleSparkle( + not_null session) { + const auto sparkles = ChatHelpers::GenerateLocalTgsSticker( + session, + u"chat/sparkles_emoji"_q); + return tr::marked(u" "_q) + .append(Data::SingleCustomEmoji(sparkles->id)); +} + +class ComposeAiModeButton final : public Ui::RippleButton { +public: + ComposeAiModeButton( + QWidget *parent, + ComposeAiMode mode, + QString label); + + void setSelected(bool selected); + [[nodiscard]] ComposeAiMode mode() const; + +protected: + void paintEvent(QPaintEvent *e) override; + [[nodiscard]] QImage prepareRippleMask() const override; + +private: + const ComposeAiMode _mode; + const QString _label; + bool _selected = false; + +}; + +class ComposeAiModeTabs final : public Ui::RpWidget { +public: + ComposeAiModeTabs(QWidget *parent); + + void setActive(ComposeAiMode mode); + void setChangedCallback(Fn callback); + +protected: + int resizeGetHeight(int newWidth) override; + void paintEvent(QPaintEvent *e) override; + +private: + const not_null _translate; + const not_null _style; + const not_null _fix; + Fn _changed; + ComposeAiMode _active = ComposeAiMode::Style; + +}; + +class ComposeAiPreviewCard final : public Ui::RpWidget { +public: + ComposeAiPreviewCard( + QWidget *parent, + not_null session, + TextWithEntities original, + std::shared_ptr chatStyle); + + void setResizeCallback(Fn callback); + void setChooseCallback(Fn callback); + void setCopyCallback(Fn callback); + void setEmojifyChangedCallback(Fn callback); + void setOriginalTitle(const QString &title); + void setOriginalVisible(bool visible); + void setResultTitle(const TextWithEntities &title); + void setEmojifyVisible(bool visible); + void setEmojifyChecked(bool checked); + void setState(CardState state); + void setResultText(TextWithEntities text); + void setShow(std::shared_ptr show); + +protected: + int resizeGetHeight(int newWidth) override; + void paintEvent(QPaintEvent *e) override; + +private: + void refreshGeometry(); + void updateOriginalToggleIcon(); + + const Ui::Text::MarkedContext _context; + const TextWithEntities _original; + const not_null _originalTitle; + const not_null _originalBody; + const not_null _originalToggle; + const not_null _resultTitle; + const not_null _resultBody; + const not_null _copy; + const not_null _emojify; + Fn _resized; + Fn _chooseCallback; + Fn _copyCallback; + Fn _emojifyChanged; + bool _ignoreResizedCallback = false; + bool _originalExpanded = false; + bool _originalVisible = true; + bool _emojifyVisible = false; + bool _dividerVisible = false; + int _dividerTop = 0; + CardState _state = CardState::Waiting; + Ui::SkeletonAnimation _skeleton; + std::array _diffColors; + +}; + +class ComposeAiContent final : public Ui::RpWidget { +public: + ComposeAiContent( + QWidget *parent, + not_null box, + ComposeAiBoxArgs args); + ~ComposeAiContent(); + + [[nodiscard]] bool hasResult() const; + [[nodiscard]] const TextWithEntities &result() const; + [[nodiscard]] const std::vector &stylesData() const; + void setReadyChangedCallback(Fn callback); + void setLoadingChangedCallback(Fn callback); + void setPremiumFloodCallback(Fn callback); + void setModeChangedCallback(Fn callback); + void setStyleSelectedCallback(Fn callback); + [[nodiscard]] ComposeAiMode mode() const; + [[nodiscard]] bool hasStyleSelection() const; + void setModeTabs(not_null tabs); + void setStyleTabs(not_null*> stylesWrap); + void start(); + +protected: + int resizeGetHeight(int newWidth) override; + +private: + void refreshLayout(); + void chooseLanguage(); + void copyResult(); + void setMode(ComposeAiMode mode); + void updateTitles(); + void updatePinnedTabs(anim::type animated); + void cancelRequest(); + void request(); + void applyResult(Api::ComposeWithAi::Result &&result); + void showError(const QString &error = {}); + void notifyLoadingChanged(); + void notifyReadyChanged(); + [[nodiscard]] QString currentTranslateStyle() const; + [[nodiscard]] QString currentTranslateStyleLabel() const; + + const not_null _box; + const not_null _session; + const TextWithEntities _original; + const LanguageId _detectedFrom; + LanguageId _to; + const std::vector _stylesData; + const std::vector _translateStylesData; + QPointer _tabs; + QPointer _styles; + QPointer> _stylesWrap; + const not_null _preview; + Fn _readyChanged; + Fn _loadingChanged; + Fn _premiumFlood; + Fn _modeChanged; + Fn _styleSelected; + ComposeAiMode _mode = ComposeAiMode::Style; + int _styleIndex = -1; + int _translateStyleIndex = 0; + bool _emojify = false; + CardState _state = CardState::Waiting; + mtpRequestId _requestId = 0; + int _requestToken = 0; + TextWithEntities _result; + +}; + +// ComposeAiModeButton + +ComposeAiModeButton::ComposeAiModeButton( + QWidget *parent, + ComposeAiMode mode, + QString label) +: RippleButton(parent, st::aiComposeButtonRippleInactive) +, _mode(mode) +, _label(std::move(label)) { + setCursor(style::cur_pointer); +} + +void ComposeAiModeButton::setSelected(bool selected) { + if (_selected == selected) { + return; + } + _selected = selected; + update(); +} + +ComposeAiMode ComposeAiModeButton::mode() const { + return _mode; +} + +void ComposeAiModeButton::paintEvent(QPaintEvent *e) { + Painter p(this); + PainterHighQualityEnabler hq(p); + + const auto radius = ComposeAiPillRadius(height()); + if (_selected) { + p.setPen(Qt::NoPen); + p.setBrush(ComposeAiActiveBackgroundColor( + st::aiComposeTabButtonBgActive)); + p.drawRoundedRect( + rect(), + radius, + radius); + } + const auto ripple = ComposeAiRippleColor( + _selected + ? st::aiComposeButtonRippleActive + : st::aiComposeButtonRippleInactive, + _selected + ? st::aiComposeButtonRippleActiveOpacity + : st::aiComposeButtonRippleInactiveOpacity); + paintRipple(p, 0, 0, &ripple); + + const auto &icon = ModeIcon(_mode, _selected); + const auto iconLeft = (width() - icon.width()) / 2; + icon.paint(p, iconLeft, st::aiComposeTabIconTop, width()); + + p.setPen(_selected + ? st::aiComposeTabLabelFgActive + : st::aiComposeTabLabelFg); + p.setFont(st::aiComposeTabLabelFont); + p.drawText( + QRect( + 0, + st::aiComposeTabLabelTop, + width(), + height() - st::aiComposeTabLabelTop), + Qt::AlignHCenter | Qt::AlignTop, + _label); +} + +QImage ComposeAiModeButton::prepareRippleMask() const { + return Ui::RippleAnimation::MaskByDrawer(size(), false, [&](QPainter &p) { + p.setPen(Qt::NoPen); + p.setBrush(Qt::white); + const auto radius = ComposeAiPillRadius(height()); + p.drawRoundedRect( + rect(), + radius, + radius); + }); +} + +// ComposeAiModeTabs + +ComposeAiModeTabs::ComposeAiModeTabs(QWidget *parent) +: RpWidget(parent) +, _translate(Ui::CreateChild( + this, + ComposeAiMode::Translate, + tr::lng_ai_compose_tab_translate(tr::now))) +, _style(Ui::CreateChild( + this, + ComposeAiMode::Style, + tr::lng_ai_compose_tab_style(tr::now))) +, _fix(Ui::CreateChild( + this, + ComposeAiMode::Fix, + tr::lng_ai_compose_tab_fix(tr::now))) { + const auto bind = [=](not_null button) { + button->setClickedCallback([=] { + setActive(button->mode()); + if (_changed) { + _changed(button->mode()); + } + }); + }; + bind(_translate); + bind(_style); + bind(_fix); + setActive(ComposeAiMode::Style); +} + +void ComposeAiModeTabs::setActive(ComposeAiMode mode) { + _active = mode; + _translate->setSelected(mode == ComposeAiMode::Translate); + _style->setSelected(mode == ComposeAiMode::Style); + _fix->setSelected(mode == ComposeAiMode::Fix); +} + +void ComposeAiModeTabs::setChangedCallback(Fn callback) { + _changed = std::move(callback); +} + +int ComposeAiModeTabs::resizeGetHeight(int newWidth) { + const auto padding = st::aiComposeTabsPadding; + const auto skip = st::aiComposeTabsSkip; + const auto innerWidth = newWidth - padding.left() - padding.right(); + const auto buttonWidth = (innerWidth - (2 * skip)) / 3; + const auto buttonHeight = st::aiComposeTabsHeight + - padding.top() + - padding.bottom(); + const auto top = padding.top(); + auto left = padding.left(); + for (const auto &button : { _translate, _style, _fix }) { + button->setGeometry(left, top, buttonWidth, buttonHeight); + left += buttonWidth + skip; + } + return st::aiComposeTabsHeight; +} + +void ComposeAiModeTabs::paintEvent(QPaintEvent *e) { + Painter p(this); + PainterHighQualityEnabler hq(p); + p.setPen(Qt::NoPen); + p.setBrush(st::aiComposeTabsBg); + const auto radius = st::aiComposeTabsRadius; + p.drawRoundedRect( + rect(), + radius, + radius); +} + +// ComposeAiPreviewCard + +ComposeAiPreviewCard::ComposeAiPreviewCard( + QWidget *parent, + not_null session, + TextWithEntities original, + std::shared_ptr chatStyle) +: RpWidget(parent) +, _context(Core::TextContext({ .session = session })) +, _original(std::move(original)) +, _originalTitle(Ui::CreateChild( + this, + st::aiComposeCardTitle)) +, _originalBody(Ui::CreateChild( + this, + st::aiComposeBodyLabel)) +, _originalToggle(Ui::CreateChild( + this, + st::aiComposeExpandButton)) +, _resultTitle(Ui::CreateChild( + this, + st::aiComposeCardTitle)) +, _resultBody(Ui::CreateChild( + this, + st::aiComposeBodyLabel)) +, _copy(Ui::CreateChild( + this, + st::aiComposeCopyButton)) +, _emojify( + Ui::CreateChild( + this, + tr::lng_ai_compose_emojify(tr::now), + st::aiComposeEmojifyCheckbox, + std::make_unique(st::defaultCheck,false))) +, _skeleton(_resultBody) { + _originalBody->setSelectable(true); + _originalBody->setMarkedText(_original, _context); + _resultTitle->setClickHandlerFilter([=](const auto &...) { + if (_chooseCallback) { + _chooseCallback(); + } + return false; + }); + _resultBody->setSelectable(true); + const auto watchHeight = [=](not_null label) { + label->heightValue( + ) | rpl::skip(1) | rpl::on_next([=] { + if (_resized && !_ignoreResizedCallback) { + _resized(); + } + }, lifetime()); + }; + watchHeight(_originalBody); + watchHeight(_resultBody); + _diffColors[0] = { &st::boxTextFgGood->p, &st::boxTextFgGood->p }; + _diffColors[1] = { &st::attentionButtonFg->p, &st::attentionButtonFg->p }; + _resultBody->setColors(_diffColors); + _originalToggle->setClickedCallback([=] { + _originalExpanded = !_originalExpanded; + updateOriginalToggleIcon(); + if (_resized) { + _resized(); + } + }); + _copy->setClickedCallback([=] { + if (_copyCallback) { + _copyCallback(); + } + }); + _emojify->checkedChanges( + ) | rpl::on_next([=](bool checked) { + if (_emojifyChanged) { + _emojifyChanged(checked); + } + }, _emojify->lifetime()); + setOriginalTitle(tr::lng_ai_compose_original(tr::now)); + setResultTitle(tr::lng_ai_compose_result(tr::now, tr::marked)); + _resultBody->setMarkedText(_original, _context); + _copy->setVisible(false); + updateOriginalToggleIcon(); + if (chatStyle) { + const auto style = chatStyle; + const auto s = session.get(); + const auto setupCaches = [=](not_null label) { + label->setPreCache([=] { + return style->messageStyle(false, false).preCache.get(); + }); + label->setBlockquoteCache([=] { + return style->coloredQuoteCache( + false, + s->user()->colorIndex()); + }); + }; + setupCaches(_originalBody); + setupCaches(_resultBody); + } +} + +void ComposeAiPreviewCard::setResizeCallback(Fn callback) { + _resized = std::move(callback); +} + +void ComposeAiPreviewCard::setChooseCallback(Fn callback) { + _chooseCallback = std::move(callback); +} + +void ComposeAiPreviewCard::setCopyCallback(Fn callback) { + _copyCallback = std::move(callback); +} + +void ComposeAiPreviewCard::setEmojifyChangedCallback(Fn callback) { + _emojifyChanged = std::move(callback); +} + +void ComposeAiPreviewCard::setOriginalTitle(const QString &title) { + _originalTitle->setText(title); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setOriginalVisible(bool visible) { + if (_originalVisible == visible) { + return; + } + _originalVisible = visible; + _originalTitle->setVisible(visible); + _originalBody->setVisible(visible); + _originalToggle->setVisible(false); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setResultTitle(const TextWithEntities &title) { + _resultTitle->setMarkedText(title); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setEmojifyVisible(bool visible) { + _emojifyVisible = visible; + _emojify->setVisible(visible); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setEmojifyChecked(bool checked) { + _emojify->setChecked(checked, Ui::Checkbox::NotifyAboutChange::DontNotify); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setState(CardState state) { + if (_state == state) { + return; + } + const auto wasLoading = (_state == CardState::Loading); + _state = state; + switch (_state) { + case CardState::Waiting: + case CardState::Failed: + _resultBody->setMarkedText(_original, _context); + _copy->setVisible(false); + if (wasLoading) { + _skeleton.stop(); + } + break; + case CardState::Loading: + _resultBody->setMarkedText(_original, _context); + _copy->setVisible(false); + _skeleton.start(); + break; + case CardState::Ready: + _copy->setVisible(true); + if (wasLoading) { + _skeleton.stop(); + } + break; + } + refreshGeometry(); +} + +void ComposeAiPreviewCard::setResultText(TextWithEntities text) { + _resultBody->setMarkedText(std::move(text), _context); + refreshGeometry(); +} + +void ComposeAiPreviewCard::setShow(std::shared_ptr show) { + const auto setupFilter = [&](not_null label) { + label->setClickHandlerFilter([=]( + const ClickHandlerPtr &handler, + Qt::MouseButton button) { + if (dynamic_cast(handler.get())) { + ActivateClickHandler(label, handler, ClickContext{ + .button = button, + .other = QVariant::fromValue(ClickHandlerContext{ + .show = show, + }) + }); + return false; + } + return true; + }); + }; + setupFilter(_originalBody); + setupFilter(_resultBody); +} + +int ComposeAiPreviewCard::resizeGetHeight(int newWidth) { + const auto padding = st::aiComposeCardPadding; + const auto contentWidth = newWidth - padding.left() - padding.right(); + auto y = padding.top(); + + _dividerVisible = false; + if (_originalVisible) { + _originalTitle->show(); + _originalBody->show(); + _originalTitle->resizeToWidth(contentWidth); + _originalToggle->setVisible(false); + + const auto toggleTop = y + + (_originalTitle->height() - _originalToggle->height()) / 2; + _originalToggle->moveToRight(padding.right(), toggleTop, newWidth); + const auto originalTitleWidth = contentWidth + - _originalToggle->width() + - st::aiComposeCardControlSkip; + _originalTitle->setGeometryToLeft( + padding.left(), + y, + std::max(originalTitleWidth, 0), + _originalTitle->height(), + newWidth); + y = std::max( + y + _originalTitle->height(), + toggleTop + _originalToggle->height()); + + _ignoreResizedCallback = true; + const auto wasOriginalSize = _originalBody->size(); + _originalBody->resizeToWidth(contentWidth); + const auto fullOriginalHeight = _originalBody->height(); + _originalBody->resize(wasOriginalSize); + _ignoreResizedCallback = false; + + const auto lineHeight = _originalBody->st().style.lineHeight; + const auto originalHeight = _originalExpanded + ? fullOriginalHeight + : std::min(fullOriginalHeight, lineHeight); + _originalBody->setGeometryToLeft( + padding.left(), + y, + contentWidth, + originalHeight, + newWidth); + const auto expandable = fullOriginalHeight > lineHeight; + _originalToggle->setVisible(expandable); + y += originalHeight + st::aiComposeCardSectionSkip; + _dividerTop = y; + _dividerVisible = true; + y += st::lineWidth + st::aiComposeCardSectionSkip; + } else { + _originalTitle->hide(); + _originalBody->hide(); + _originalToggle->hide(); + } + + _resultTitle->show(); + auto controlsWidth = 0; + if (_emojifyVisible) { + _emojify->show(); + _emojify->resizeToNaturalWidth(contentWidth); + controlsWidth += _emojify->width() + + st::aiComposeCardControlSkip; + } else { + _emojify->hide(); + } + const auto resultTitleWidth = std::max( + contentWidth - controlsWidth, + 0); + _resultTitle->resizeToWidth(resultTitleWidth); + auto right = padding.right(); + if (_emojifyVisible) { + _emojify->moveToRight(right, y, newWidth); + right += _emojify->width() + st::aiComposeCardControlSkip; + } + _resultTitle->setGeometryToLeft( + padding.left(), + y, + resultTitleWidth, + _resultTitle->height(), + newWidth); + y = std::max( + y + _resultTitle->height(), + (_emojifyVisible + ? (y - _emojify->getMargins().top() + _emojify->height()) + : 0)); + + const auto lineHeight = _resultBody->st().style.lineHeight + ? _resultBody->st().style.lineHeight + : _resultBody->st().style.font->height; + if (!_copy->isHidden()) { + _resultBody->setSkipBlock( + _copy->width(), + lineHeight); + } else { + _resultBody->setSkipBlock(0, 0); + } + _resultBody->resizeToWidth(contentWidth); + _resultBody->setGeometryToLeft( + padding.left(), + y, + contentWidth, + _resultBody->height(), + newWidth); + if (!_copy->isHidden()) { + _copy->moveToRight( + padding.right(), + y + _resultBody->height() - lineHeight, + newWidth); + } + y += _resultBody->height(); + + return y + padding.bottom(); +} + +void ComposeAiPreviewCard::paintEvent(QPaintEvent *e) { + Painter p(this); + PainterHighQualityEnabler hq(p); + p.setPen(Qt::NoPen); + p.setBrush(st::aiComposeCardBg); + p.drawRoundedRect( + rect(), + st::aiComposeCardRadius, + st::aiComposeCardRadius); + if (_dividerVisible) { + p.setBrush(Qt::NoBrush); + p.setPen(st::aiComposeCardDivider); + p.drawLine( + st::aiComposeCardPadding.left(), + _dividerTop, + width() - st::aiComposeCardPadding.right(), + _dividerTop); + } +} + +void ComposeAiPreviewCard::refreshGeometry() { + if (width() > 0) { + resizeToWidth(width()); + } + if (_resized) { + _resized(); + } +} + +void ComposeAiPreviewCard::updateOriginalToggleIcon() { + _originalToggle->setIconOverride( + _originalExpanded ? &st::aiComposeCollapseIcon : nullptr, + _originalExpanded ? &st::aiComposeCollapseIcon : nullptr); +} + +// ComposeAiContent + +ComposeAiContent::ComposeAiContent( + QWidget *parent, + not_null box, + ComposeAiBoxArgs args) +: RpWidget(parent) +, _box(box) +, _session(args.session) +, _original(std::move(args.text)) +, _detectedFrom(Platform::Language::Recognize(_original.text)) +, _to(DefaultAiTranslateTo(_detectedFrom)) +, _stylesData(ResolveStyleDescriptors( + _session->appConfig().aiComposeStyles())) +, _translateStylesData(ResolveTranslateStyleDescriptors(_session, _stylesData)) +, _preview( + Ui::CreateChild( + this, + _session, + _original, + args.chatStyle)) { + _preview->setResizeCallback([=] { refreshLayout(); }); + _preview->setChooseCallback([=] { chooseLanguage(); }); + _preview->setCopyCallback([=] { copyResult(); }); + _preview->setEmojifyChangedCallback([=](bool checked) { + _emojify = checked; + if (_mode != ComposeAiMode::Fix) { + request(); + } + }); + _preview->setShow(_box->uiShow()); +} + +ComposeAiContent::~ComposeAiContent() { + cancelRequest(); +} + +bool ComposeAiContent::hasResult() const { + return _state == CardState::Ready; +} + +const TextWithEntities &ComposeAiContent::result() const { + return _result; +} + +const std::vector &ComposeAiContent::stylesData() const { + return _stylesData; +} + +void ComposeAiContent::setReadyChangedCallback(Fn callback) { + _readyChanged = std::move(callback); +} + +void ComposeAiContent::setLoadingChangedCallback(Fn callback) { + _loadingChanged = std::move(callback); + notifyLoadingChanged(); +} + +void ComposeAiContent::setModeTabs(not_null tabs) { + _tabs = tabs; + _tabs->setChangedCallback([=](ComposeAiMode mode) { + setMode(mode); + }); + _tabs->setActive(_mode); +} + +void ComposeAiContent::setStyleTabs( + not_null*> stylesWrap) { + _stylesWrap = stylesWrap; + _stylesWrap->setDuration(0); + _styles = stylesWrap->entity(); + _styles->setChangedCallback([=](int index) { + if (index >= 0 && index < int(_stylesData.size())) { + const auto wasNoSelection = (_styleIndex < 0); + _styleIndex = index; + updateTitles(); + if (_mode == ComposeAiMode::Style) { + request(); + if (wasNoSelection && _styleSelected) { + _styleSelected(); + } + } + } + }); + _styles->setActive(_styleIndex); + _stylesWrap->toggle(_mode == ComposeAiMode::Style, anim::type::instant); +} + +void ComposeAiContent::start() { + updatePinnedTabs(anim::type::instant); + updateTitles(); + request(); +} + +int ComposeAiContent::resizeGetHeight(int newWidth) { + _preview->resizeToWidth(newWidth); + _preview->moveToLeft(0, 0, newWidth); + return _preview->height(); +} + +void ComposeAiContent::refreshLayout() { + if (width() > 0) { + resizeToWidth(width()); + } +} + +void ComposeAiContent::chooseLanguage() { + if (_mode != ComposeAiMode::Translate) { + return; + } + const auto weak = QPointer(this); + const auto session = _session; + const auto styles = _translateStylesData; + const auto selectedStyle = std::make_shared(_translateStyleIndex); + _box->uiShow()->showBox(Box([=](not_null box) { + const auto apply = [=](LanguageId id, int styleIndex) { + if (!weak) { + return; + } + weak->_to = id; + if (const auto count = int(weak->_translateStylesData.size())) { + weak->_translateStyleIndex = std::clamp(styleIndex, 0, count - 1); + } + weak->updateTitles(); + weak->request(); + }; + Ui::ChooseLanguageBox( + box, + tr::lng_languages(), + [=](std::vector ids) { + if (ids.empty()) { + return; + } + apply(ids.front(), *selectedStyle); + }, + { _to }, + false, + nullptr); + const auto bottom = box->setPinnedToBottomContent( + object_ptr(box)); + const auto skip = st::defaultSubsectionTitlePadding.left(); + const auto tabs = bottom->add( + object_ptr( + bottom, + styles, + session->data().customEmojiManager().factory( + Data::CustomEmojiSizeTag::Large)), + QMargins( + (skip - st::aiComposeStyleTabsPadding.left()), + 0, + (skip - st::aiComposeStyleTabsPadding.right()), + 0)); + tabs->setPaintOuterCorners(false); + tabs->setChangedCallback([=](int index) { + if (index >= 0 && index < int(styles.size())) { + *selectedStyle = index; + apply(_to, index); + box->closeBox(); + } + }); + tabs->setActive(std::clamp(*selectedStyle, 0, int(styles.size()) - 1)); + tabs->scrollToActive(); + })); +} + +void ComposeAiContent::copyResult() { + if (_state != CardState::Ready) { + return; + } + TextUtilities::SetClipboardText( + TextForMimeData::WithExpandedLinks(_result)); +} + +void ComposeAiContent::setMode(ComposeAiMode mode) { + if (_mode == mode) { + return; + } + if (mode != ComposeAiMode::Style) { + _styleIndex = -1; + } + _mode = mode; + _state = CardState::Waiting; + _preview->setState(CardState::Waiting); + notifyLoadingChanged(); + if (_modeChanged) { + _modeChanged(_mode); + } + updatePinnedTabs(anim::type::normal); + updateTitles(); + refreshLayout(); + request(); +} + +void ComposeAiContent::updateTitles() { + const auto hasResult = (_state == CardState::Loading) + || (_state == CardState::Ready); + _preview->setOriginalVisible(hasResult); + _preview->setOriginalTitle( + (_mode == ComposeAiMode::Translate) + ? FromTitle(_detectedFrom) + : tr::lng_ai_compose_original(tr::now)); + _preview->setResultTitle( + hasResult + ? ((_mode == ComposeAiMode::Translate) + ? ToTitle(_to, currentTranslateStyleLabel()) + : tr::lng_ai_compose_result(tr::now, tr::marked)) + : tr::lng_ai_compose_original(tr::now, tr::marked)); + _preview->setEmojifyVisible( + hasResult && (_mode != ComposeAiMode::Fix)); + _preview->setEmojifyChecked(_emojify); +} + +void ComposeAiContent::updatePinnedTabs(anim::type animated) { + if (_tabs) { + _tabs->setActive(_mode); + } + if (_styles) { + _styles->setActive(_styleIndex); + } + if (_stylesWrap) { + _stylesWrap->toggle(_mode == ComposeAiMode::Style, animated); + } +} + +void ComposeAiContent::cancelRequest() { + ++_requestToken; + if (_requestId) { + _session->api().composeWithAi().cancel(_requestId); + _requestId = 0; + } +} + +void ComposeAiContent::request() { + cancelRequest(); + if (_mode == ComposeAiMode::Style && _styleIndex < 0) { + return; + } + _state = CardState::Loading; + _result = {}; + _preview->setState(CardState::Loading); + notifyLoadingChanged(); + updateTitles(); + notifyReadyChanged(); + + auto request = Api::ComposeWithAi::Request{ + .text = _original, + .emojify = (_mode != ComposeAiMode::Fix) && _emojify, + }; + switch (_mode) { + case ComposeAiMode::Translate: + request.translateToLang = _to.twoLetterCode(); + request.changeTone = currentTranslateStyle(); + break; + case ComposeAiMode::Style: + request.changeTone = _stylesData[_styleIndex].id; + break; + case ComposeAiMode::Fix: + request.proofread = true; + break; + } + + const auto token = ++_requestToken; + const auto weak = QPointer(this); + _requestId = _session->api().composeWithAi().request( + std::move(request), + [=](Api::ComposeWithAi::Result &&result) { + if (!weak || weak->_requestToken != token) { + return; + } + weak->_requestId = 0; + weak->applyResult(std::move(result)); + }, + [=](const MTP::Error &error) { + if (!weak || weak->_requestToken != token) { + return; + } + weak->_requestId = 0; + weak->showError(error.type()); + }); +} + +void ComposeAiContent::applyResult(Api::ComposeWithAi::Result &&result) { + _result = std::move(result.resultText); + if (_result.text.isEmpty()) { + showError({}); + return; + } + auto display = (_mode == ComposeAiMode::Fix && result.diffText) + ? BuildDiffDisplay(*result.diffText) + : _result; + _state = _result.text.isEmpty() ? CardState::Failed : CardState::Ready; + _preview->setState(_state); + notifyLoadingChanged(); + if (_state == CardState::Ready) { + _preview->setResultText(std::move(display)); + } + updateTitles(); + notifyReadyChanged(); + refreshLayout(); +} + +void ComposeAiContent::showError(const QString &error) { + _state = CardState::Failed; + _preview->setState(CardState::Failed); + notifyLoadingChanged(); + updateTitles(); + notifyReadyChanged(); + refreshLayout(); + if (error == u"AICOMPOSE_FLOOD_PREMIUM"_q) { + const auto show = Main::MakeSessionShow( + _box->uiShow(), + _session); + Settings::ShowPremiumPromoToast( + show, + ChatHelpers::ResolveWindowDefault(), + tr::lng_ai_compose_flood_text( + tr::now, + lt_link, + tr::link(tr::lng_ai_compose_flood_link(tr::now, tr::bold)), + tr::rich), + u"ai_compose"_q); + if (_premiumFlood) { + _premiumFlood(); + } + return; + } + _box->showToast(error.isEmpty() + ? tr::lng_ai_compose_error(tr::now) + : error); +} + +void ComposeAiContent::notifyLoadingChanged() { + if (_loadingChanged) { + _loadingChanged(_state == CardState::Loading); + } +} + +void ComposeAiContent::notifyReadyChanged() { + if (_readyChanged) { + _readyChanged(_state == CardState::Ready); + } +} + +void ComposeAiContent::setPremiumFloodCallback(Fn callback) { + _premiumFlood = std::move(callback); +} + +void ComposeAiContent::setModeChangedCallback( + Fn callback) { + _modeChanged = std::move(callback); +} + +void ComposeAiContent::setStyleSelectedCallback(Fn callback) { + _styleSelected = std::move(callback); +} + +QString ComposeAiContent::currentTranslateStyle() const { + return (_translateStyleIndex >= 0 + && _translateStyleIndex < int(_translateStylesData.size())) + ? _translateStylesData[_translateStyleIndex].id + : QString(); +} + +QString ComposeAiContent::currentTranslateStyleLabel() const { + if (const auto style = currentTranslateStyle(); !style.isEmpty()) { + return (_translateStyleIndex >= 0 + && _translateStyleIndex < int(_translateStylesData.size())) + ? _translateStylesData[_translateStyleIndex].label + : QString(); + } + return QString(); +} + +ComposeAiMode ComposeAiContent::mode() const { + return _mode; +} + +bool ComposeAiContent::hasStyleSelection() const { + return _styleIndex >= 0; +} + +[[nodiscard]] Fn SetupStyleTooltip( + not_null box, + not_null pinnedToTop, + not_null stylesWrap, + Fn currentMode) { + const auto tooltip = Ui::CreateChild( + box, + object_ptr>( + box, + Ui::MakeNiceTooltipLabel( + box, + tr::lng_ai_compose_style_tooltip(tr::rich), + st::historyMessagesTTLLabel.minWidth, + st::ttlMediaImportantTooltipLabel), + st::historyRecordTooltip.padding), + st::historyRecordTooltip); + tooltip->toggleFast(false); + + struct State { + bool shown = false; + bool shownOnce = false; + }; + const auto state = box->lifetime().make_state(); + + const auto updateGeometry = [=] { + const auto local = stylesWrap->geometry(); + if (local.isEmpty()) { + return; + } + const auto geometry = Ui::MapFrom(box, pinnedToTop, local); + const auto countPosition = [=](QSize size) { + const auto left = geometry.x() + + (geometry.width() - size.width()) / 2; + return QPoint( + std::max(std::min(left, box->width() - size.width()), 0), + (geometry.y() + + geometry.height() + - st::historyRecordTooltip.arrow + - (st::aiComposeBoxStyleTabsSkip / 2))); + }; + tooltip->pointAt(geometry, RectPart::Bottom, countPosition); + }; + + const auto updateVisibility = [=](bool visible) { + const auto show = visible + && !Core::App().settings().readPref( + kAiComposeStyleTooltipHiddenPref); + if (state->shown != show) { + state->shown = show; + if (show) { + updateGeometry(); + tooltip->raise(); + } + if (show && !state->shownOnce) { + state->shownOnce = true; + tooltip->toggleFast(true); + } else { + tooltip->toggleAnimated(show); + } + } + }; + + stylesWrap->geometryValue( + ) | rpl::on_next([=](const QRect &geometry) { + if (!geometry.isEmpty()) { + if (state->shown) { + updateGeometry(); + } else { + updateVisibility(currentMode() == ComposeAiMode::Style); + } + } + }, tooltip->lifetime()); + + return updateVisibility; +} + +} // namespace + +void ComposeAiBox(not_null box, ComposeAiBoxArgs &&args) { + const auto sendButtonHeight = st::aiComposeSendButton.inner.height; + const auto buttonHeight = st::aiComposeSendButton.inner.icon.height() + + 2 * st::aiComposeSendButton.sendIconFillPadding; + const auto boxStyle = [&](const style::Box &base) { + const auto result = box->lifetime().make_state(base); + result->button.height = buttonHeight; + result->buttonHeight = buttonHeight; + result->button.textTop = base.button.textTop + - (base.button.height - buttonHeight) / 2; + return result; + }; + const auto boxStyleNoSend = boxStyle(st::aiComposeBox); + const auto boxStyleWithSend = boxStyle(st::aiComposeBoxWithSend); + box->setStyle(*boxStyleNoSend); + box->setNoContentMargin(true); + box->setWidth(st::boxWideWidth); + const auto session = args.session; + box->addTopButton(st::boxTitleClose, [=] { + box->closeBox(); + }); + + const auto body = box->verticalLayout(); + const auto tabsSkip = QMargins(0, 0, 0, st::aiComposeBoxStyleTabsSkip); + const auto pinnedToTop = box->setPinnedToTopContent( + object_ptr(box)); + const auto tabs = pinnedToTop->add( + object_ptr(pinnedToTop), + st::aiComposeContentMargin + tabsSkip); + const auto content = body->add( + object_ptr(box, box, args), + st::aiComposeContentMargin); + auto emojiFactory = session->data().customEmojiManager().factory( + Data::CustomEmojiSizeTag::Large); + const auto stylesWrap = pinnedToTop->add( + object_ptr>( + pinnedToTop, + object_ptr( + pinnedToTop, + content->stylesData(), + std::move(emojiFactory)), + tabsSkip), + st::aiComposeContentMargin); + stylesWrap->hide(anim::type::instant); + content->setModeTabs(tabs); + content->setStyleTabs(stylesWrap); + + const auto updateStyleTooltipVisibility = SetupStyleTooltip( + box, + pinnedToTop, + stylesWrap, + [=] { return content->mode(); }); + + const auto sparkle = LoadingTitleSparkle(session); + const auto loading = box->lifetime().make_state< + rpl::variable>(); + + content->setLoadingChangedCallback([=](bool value) { + *loading = value; + }); + + box->setTitle(rpl::combine( + loading->value(), + tr::lng_ai_compose_title(tr::marked) + ) | rpl::map([=](bool loading, TextWithEntities title) { + return loading ? title.append(sparkle) : title; + }), Core::TextContext({ .session = session })); + + auto premiumFlooded = std::make_shared(false); + auto sendButton = std::make_shared>(); + + const auto applyAndClose = [=] { + if (!content->hasResult()) { + return; + } + args.apply(TextWithEntities(content->result())); + box->closeBox(); + }; + const auto sendResult = [=](Api::SendOptions options) { + if (!args.send || !content->hasResult()) { + return; + } + args.send( + TextWithEntities(content->result()), + options, + crl::guard(box, [=] { + box->closeBox(); + })); + }; + const auto addApplyButton = [=]( + const style::Box &style, + rpl::producer text, + Fn callback) { + box->setStyle(style); + const auto result = box->addButton(std::move(text), std::move(callback)); + result->setFullRadius(true); + return result; + }; + const auto disableButton = [=](not_null button) { + button->clearState(); + button->setDisabled(true); + button->setAttribute(Qt::WA_TransparentForMouseEvents); + button->setTextFgOverride( + anim::color(st::activeButtonBg, st::activeButtonFg, 0.5)); + button->setClickedCallback([] { + }); + }; + + const auto rebuildButtons = [=] { + if (*sendButton) { + delete sendButton->data(); + } + *sendButton = nullptr; + box->clearButtons(); + box->addTopButton(st::boxTitleClose, [=] { + box->closeBox(); + }); + + if (*premiumFlooded) { + auto helper = Ui::Text::CustomEmojiHelper(); + const auto badge = helper.paletteDependent( + Ui::Text::CustomEmojiTextBadge( + u"x50"_q, + st::aiComposeBadge, + st::aiComposeBadgeMargin)); + const auto btn = addApplyButton( + *boxStyleNoSend, + tr::lng_ai_compose_increase_limit(), nullptr); + btn->setContext(helper.context()); + btn->setText(rpl::single( + tr::lng_ai_compose_increase_limit(tr::now, tr::marked) + .append(' ') + .append(badge))); + const auto resolve = ChatHelpers::ResolveWindowDefault(); + const auto close = crl::guard(box, [=] { + box->closeBox(); + }); + btn->setClickedCallback([=] { + if (const auto controller = resolve(session)) { + ShowPremiumPreviewBox( + controller, + PremiumFeature::AiCompose); + } + close(); + }); + } else if (content->mode() == ComposeAiMode::Style + && !content->hasStyleSelection()) { + const auto btn = addApplyButton( + *boxStyleNoSend, + tr::lng_ai_compose_select_style(), nullptr); + disableButton(btn); + } else if (content->hasResult()) { + const auto isStyle = + (content->mode() == ComposeAiMode::Style); + const auto btn = addApplyButton( + args.send ? *boxStyleWithSend : *boxStyleNoSend, + isStyle + ? tr::lng_ai_compose_apply_style() + : tr::lng_ai_compose_apply(), + applyAndClose); + if (args.send) { + const auto send = Ui::CreateChild( + btn->parentWidget(), + st::aiComposeSendButton); + send->setState({ .type = Ui::SendButton::Type::Send }); + send->show(); + btn->geometryValue( + ) | rpl::on_next([=](QRect geometry) { + const auto size = sendButtonHeight; + send->resize(size, size); + send->moveToLeft( + geometry.x() + geometry.width() + + st::aiComposeSendButtonSkip, + geometry.y() + (geometry.height() - size) / 2); + }, send->lifetime()); + send->setClickedCallback([=] { + sendResult({}); + }); + if (args.setupMenu) { + args.setupMenu( + send, + [=](Api::SendOptions options) { + sendResult(options); + }); + } + *sendButton = send; + } + } else { + const auto isStyle = + (content->mode() == ComposeAiMode::Style); + const auto btn = addApplyButton( + *boxStyleNoSend, + isStyle + ? tr::lng_ai_compose_apply_style() + : tr::lng_ai_compose_apply(), + nullptr); + disableButton(btn); + } + }; + + content->setReadyChangedCallback([=](bool) { + rebuildButtons(); + }); + content->setPremiumFloodCallback([=] { + *premiumFlooded = true; + rebuildButtons(); + }); + content->setModeChangedCallback([=](ComposeAiMode mode) { + rebuildButtons(); + updateStyleTooltipVisibility(mode == ComposeAiMode::Style); + }); + content->setStyleSelectedCallback([=] { + rebuildButtons(); + if (!Core::App().settings().readPref(kAiComposeStyleTooltipHiddenPref)) { + Core::App().settings().writePref(kAiComposeStyleTooltipHiddenPref, true); + } + updateStyleTooltipVisibility(false); + }); + + rebuildButtons(); + content->start(); +} + +void ShowComposeAiBox( + std::shared_ptr show, + ComposeAiBoxArgs &&args) { + show->show(Box(ComposeAiBox, std::move(args))); +} + +} // namespace HistoryView::Controls diff --git a/Telegram/SourceFiles/boxes/compose_ai_box.h b/Telegram/SourceFiles/boxes/compose_ai_box.h new file mode 100644 index 0000000000..f7e23ac0b6 --- /dev/null +++ b/Telegram/SourceFiles/boxes/compose_ai_box.h @@ -0,0 +1,39 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "api/api_common.h" +#include "base/object_ptr.h" +#include "ui/text/text_entity.h" + +namespace Main { +class Session; +} // namespace Main + +namespace Ui { +class ChatStyle; +class GenericBox; +class RpWidget; +class Show; +} // namespace Ui + +namespace HistoryView::Controls { + +struct ComposeAiBoxArgs { + not_null session; + TextWithEntities text; + std::shared_ptr chatStyle; + Fn apply; + Fn)> send; + Fn, Fn)> setupMenu; +}; + +void ComposeAiBox(not_null box, ComposeAiBoxArgs &&args); +void ShowComposeAiBox(std::shared_ptr show, ComposeAiBoxArgs &&args); + +} // namespace HistoryView::Controls diff --git a/Telegram/SourceFiles/boxes/create_poll_box.cpp b/Telegram/SourceFiles/boxes/create_poll_box.cpp index 1b7751b70b..de621d2be1 100644 --- a/Telegram/SourceFiles/boxes/create_poll_box.cpp +++ b/Telegram/SourceFiles/boxes/create_poll_box.cpp @@ -7,7 +7,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "boxes/create_poll_box.h" +#include "poll/poll_media_upload.h" #include "base/call_delayed.h" +#include "base/qt/qt_key_modifiers.h" +#include "base/unixtime.h" +#include "boxes/premium_limits_box.h" #include "base/event_filter.h" #include "base/random.h" #include "base/unique_qptr.h" @@ -15,37 +19,85 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "chat_helpers/message_field.h" #include "chat_helpers/tabbed_panel.h" #include "chat_helpers/tabbed_selector.h" +#include "core/file_utilities.h" +#include "core/mime_type.h" #include "core/application.h" #include "core/core_settings.h" +#include "core/shortcuts.h" +#include "core/ui_integration.h" +#include "ui/power_saving.h" +#include "data/data_cloud_file.h" +#include "data/data_document.h" +#include "data/data_file_origin.h" +#include "data/data_location.h" #include "data/data_poll.h" +#include "data/data_peer.h" +#include "data/data_photo.h" +#include "data/data_session.h" #include "data/data_user.h" #include "data/stickers/data_custom_emoji.h" +#include "history/view/media/menu/history_view_poll_menu.h" #include "history/view/history_view_schedule_box.h" #include "lang/lang_keys.h" +#include "layout/layout_document_generic_preview.h" #include "main/main_app_config.h" +#include "mainwidget.h" +#include "mainwindow.h" +#include "platform/platform_file_utilities.h" #include "main/main_session.h" #include "menu/menu_send.h" +#include "settings/detailed_settings_button.h" +#include "settings/settings_common.h" +#include "storage/file_upload.h" +#include "storage/localimageloader.h" +#include "storage/storage_account.h" +#include "storage/storage_media_prepare.h" #include "ui/controls/emoji_button.h" #include "ui/controls/emoji_button_factory.h" +#include "ui/controls/location_picker.h" +#include "ui/chat/attach/attach_prepare.h" +#include "ui/dynamic_image.h" +#include "ui/dynamic_thumbnails.h" +#include "ui/effects/radial_animation.h" +#include "ui/effects/ripple_animation.h" +#include "ui/effects/ttl_icon.h" +#include "ui/painter.h" #include "ui/rect.h" #include "ui/text/text_utilities.h" #include "ui/toast/toast.h" #include "ui/vertical_list.h" #include "ui/widgets/buttons.h" +#include "ui/widgets/dropdown_menu.h" +#include "ui/widgets/menu/menu_action.h" #include "ui/widgets/checkbox.h" #include "ui/widgets/fields/input_field.h" #include "ui/widgets/labels.h" +#include "ui/boxes/choose_date_time.h" +#include "ui/text/format_values.h" +#include "ui/widgets/popup_menu.h" +#include "ui/widgets/scroll_area.h" #include "ui/widgets/shadow.h" #include "ui/wrap/fade_wrap.h" #include "ui/wrap/slide_wrap.h" #include "ui/wrap/vertical_layout.h" +#include "ui/wrap/vertical_layout_reorder.h" #include "ui/ui_utility.h" +#include "window/section_widget.h" #include "window/window_session_controller.h" +#include "apiwrap.h" #include "styles/style_boxes.h" +#include "styles/style_dialogs.h" +#include "styles/style_chat.h" #include "styles/style_chat_helpers.h" // defaultComposeFiles. #include "styles/style_layers.h" +#include "styles/style_menu_icons.h" +#include "styles/style_overview.h" +#include "styles/style_polls.h" #include "styles/style_settings.h" +#include +#include + namespace { constexpr auto kQuestionLimit = 255; @@ -56,24 +108,52 @@ constexpr auto kWarnOptionLimit = 30; constexpr auto kSolutionLimit = 200; constexpr auto kWarnSolutionLimit = 60; constexpr auto kErrorLimit = 99; +constexpr auto kMediaUploadMaxAge = 45 * 60 * crl::time(1000); + +using PollMediaState = PollMediaUpload::PollMediaState; +using PollMediaButton = PollMediaUpload::PollMediaButton; +using PollMediaUploader = PollMediaUpload::PollMediaUploader; + +using PollMediaUpload::FileListFromMimeData; +using PollMediaUpload::GenerateDocumentFilePreview; +using PollMediaUpload::LocalImageThumbnail; +using PollMediaUpload::PreparePollMediaTask; +using PollMediaUpload::UploadContext; +using PollMediaUpload::ValidateFileDragData; class Options { public: + using AttachCallback = Fn, + std::shared_ptr)>; + using FieldDropCallback = Fn, + std::shared_ptr)>; + using WidgetDropCallback = Fn, + std::shared_ptr)>; + Options( not_null box, not_null container, not_null controller, ChatHelpers::TabbedPanel *emojiPanel, - bool chooseCorrectEnabled); + bool chooseCorrectEnabled, + AttachCallback attachCallback, + FieldDropCallback fieldDropCallback, + WidgetDropCallback widgetDropCallback); [[nodiscard]] bool hasOptions() const; [[nodiscard]] bool isValid() const; [[nodiscard]] bool hasCorrect() const; + [[nodiscard]] bool hasUploadingMedia() const; + bool refreshStaleMedia(crl::time threshold); [[nodiscard]] std::vector toPollAnswers() const; void focusFirst(); - void enableChooseCorrect(bool enabled); + void enableChooseCorrect(bool enabled, bool multiCorrect = false); + [[nodiscard]] not_null layoutWidget() const; [[nodiscard]] rpl::producer usedCount() const; [[nodiscard]] rpl::producer> scrollToWidget() const; [[nodiscard]] rpl::producer<> backspaceInFront() const; @@ -87,14 +167,18 @@ private: not_null container, not_null session, int position, - std::shared_ptr group); + std::shared_ptr group, + AttachCallback attachCallback, + FieldDropCallback fieldDropCallback, + WidgetDropCallback widgetDropCallback); Option(const Option &other) = delete; Option &operator=(const Option &other) = delete; - void toggleRemoveAlways(bool toggled); void enableChooseCorrect( - std::shared_ptr group); + std::shared_ptr group, + bool multiCorrect = false, + Fn checkboxChanged = nullptr); void show(anim::type animated); void destroy(FnMut done); @@ -107,34 +191,40 @@ private: [[nodiscard]] bool isGood() const; [[nodiscard]] bool isTooLong() const; [[nodiscard]] bool isCorrect() const; + [[nodiscard]] bool uploadingMedia() const; + bool refreshMediaIfStale(crl::time threshold); [[nodiscard]] bool hasFocus() const; void setFocus() const; - void clearValue(); void setPlaceholder() const; void removePlaceholder() const; + void showAddIcon(bool show); [[nodiscard]] not_null field() const; [[nodiscard]] PollAnswer toPollAnswer(int index) const; - [[nodiscard]] rpl::producer removeClicks() const; + [[nodiscard]] Ui::RpWidget *handleWidget() const; private: - void createRemove(); + void createAttach(); void createWarning(); - void toggleCorrectSpace(bool visible); + void createHandle(); void updateFieldGeometry(); base::unique_qptr> _wrap; not_null _content; - base::unique_qptr> _correct; - Ui::Animations::Simple _correctShown; + base::unique_qptr> _correct; + base::unique_qptr> _handle; bool _hasCorrect = false; Ui::InputField *_field = nullptr; base::unique_qptr _shadow; - base::unique_qptr _remove; - rpl::variable *_removeAlways = nullptr; + base::unique_qptr _attach; + AttachCallback _attachCallback; + FieldDropCallback _fieldDropCallback; + WidgetDropCallback _widgetDropCallback; + std::shared_ptr _media; + Ui::FadeWrapScaled *_addIcon = nullptr; }; @@ -151,13 +241,22 @@ private: int findField(not_null field) const; [[nodiscard]] auto createChooseCorrectGroup() -> std::shared_ptr; + void setupReorder(); + void restartReorder(); not_null _box; not_null _container; const not_null _controller; ChatHelpers::TabbedPanel * const _emojiPanel; + const AttachCallback _attachCallback; + const FieldDropCallback _fieldDropCallback; + const WidgetDropCallback _widgetDropCallback; std::shared_ptr _chooseCorrectGroup; - int _position = 0; + bool _multiCorrect = false; + Fn _multiCorrectChanged; + Ui::VerticalLayout *_optionsLayout = nullptr; + std::unique_ptr _reorder; + int _reordering = 0; std::vector> _list; std::vector> _destroyed; rpl::variable _usedCount = 0; @@ -174,10 +273,15 @@ private: void InitField( not_null container, not_null field, - not_null session) { - field->setInstantReplaces(Ui::InstantReplaces::Default()); - field->setInstantReplacesEnabled( - Core::App().settings().replaceEmojiValue()); + not_null session, + std::shared_ptr show = nullptr, + base::flat_set markdownTags = {}) { + InitMessageFieldHandlers({ + .session = session, + .show = std::move(show), + .field = field, + .allowMarkdownTags = std::move(markdownTags), + }); auto options = Ui::Emoji::SuggestionsController::Options(); options.suggestExactFirstWord = false; Ui::Emoji::SuggestionsController::Init( @@ -223,12 +327,31 @@ void FocusAtEnd(not_null field) { field->ensureCursorVisible(); } +not_null AddPollToggleButton( + not_null container, + rpl::producer title, + rpl::producer description, + Settings::IconDescriptor icon, + rpl::producer toggled, + const style::DetailedSettingsButtonStyle &rowStyle) { + return AddDetailedSettingsButton( + container, + std::move(title), + std::move(description), + std::move(icon), + std::move(toggled), + rowStyle); +} + Options::Option::Option( not_null outer, not_null container, not_null session, int position, - std::shared_ptr group) + std::shared_ptr group, + AttachCallback attachCallback, + FieldDropCallback fieldDropCallback, + WidgetDropCallback widgetDropCallback) : _wrap(container->insert( position, object_ptr>( @@ -238,17 +361,28 @@ Options::Option::Option( , _field( Ui::CreateChild( _content.get(), - session->user()->isPremium() - ? st::createPollOptionFieldPremium - : st::createPollOptionField, + st::createPollOptionFieldPremium, Ui::InputField::Mode::NoNewlines, - tr::lng_polls_create_option_add())) { + tr::lng_polls_create_option_add())) +, _attachCallback(std::move(attachCallback)) +, _fieldDropCallback(std::move(fieldDropCallback)) +, _widgetDropCallback(std::move(widgetDropCallback)) +, _media(std::make_shared()) { InitField(outer, _field, session); _field->setMaxLength(kOptionLimit + kErrorLimit); _field->show(); + if (_fieldDropCallback) { + _fieldDropCallback(_field, _media); + } _wrap->hide(anim::type::instant); + _content->paintRequest( + ) | rpl::on_next([content = _content.get()] { + auto p = QPainter(content); + p.fillRect(content->rect(), st::boxBg); + }, _content->lifetime()); + _content->widthValue( ) | rpl::on_next([=] { updateFieldGeometry(); @@ -264,18 +398,23 @@ Options::Option::Option( Ui::PostponeCall(crl::guard(_field, [=] { if (_hasCorrect) { _correct->toggle(isGood(), anim::type::normal); + } else if (_handle) { + _handle->toggle(isGood(), anim::type::normal); } })); }, _field->lifetime()); createShadow(); - createRemove(); + createAttach(); createWarning(); + createHandle(); enableChooseCorrect(group); - _correctShown.stop(); if (_correct) { _correct->finishAnimating(); } + if (_handle) { + _handle->finishAnimating(); + } updateFieldGeometry(); } @@ -306,44 +445,33 @@ void Options::Option::destroyShadow() { _shadow = nullptr; } -void Options::Option::createRemove() { - using namespace rpl::mappers; - - const auto field = this->field(); - auto &lifetime = field->lifetime(); - - const auto remove = Ui::CreateChild( +void Options::Option::createAttach() { + const auto field = Option::field(); + const auto attach = Ui::CreateChild( field.get(), - st::createPollOptionRemove); - remove->show(anim::type::instant); - - const auto toggle = lifetime.make_state>(false); - _removeAlways = lifetime.make_state>(false); - - field->changes( - ) | rpl::on_next([field, toggle] { - // Don't capture 'this'! Because Option is a value type. - *toggle = !field->getLastText().isEmpty(); - }, field->lifetime()); -#if 0 - rpl::combine( - toggle->value(), - _removeAlways->value(), - _1 || _2 - ) | rpl::on_next([=](bool shown) { - remove->toggle(shown, anim::type::normal); - }, remove->lifetime()); -#endif - - field->widthValue( - ) | rpl::on_next([=](int width) { - remove->moveToRight( + st::pollAttach, + _media); + attach->show(); + field->sizeValue( + ) | rpl::on_next([=](QSize size) { + attach->moveToRight( st::createPollOptionRemovePosition.x(), - st::createPollOptionRemovePosition.y(), - width); - }, remove->lifetime()); - - _remove.reset(remove); + st::createPollOptionRemovePosition.y() - st::lineWidth * 2, + size.width()); + }, attach->lifetime()); + attach->clicks( + ) | rpl::on_next([=](Qt::MouseButton button) { + if (button != Qt::LeftButton) { + return; + } + if (_attachCallback) { + _attachCallback(not_null(attach), _media); + } + }, attach->lifetime()); + if (_widgetDropCallback) { + _widgetDropCallback(attach, _media); + } + _attach.reset(attach); } void Options::Option::createWarning() { @@ -370,6 +498,38 @@ void Options::Option::createWarning() { }, warning->lifetime()); } +void Options::Option::createHandle() { + auto widget = object_ptr(_content.get()); + const auto raw = widget.data(); + const auto &icon = st::pollBoxMenuPollOrderIcon; + raw->resize(icon.width(), icon.height()); + raw->setCursor(Qt::SizeVerCursor); + raw->paintRequest( + ) | rpl::on_next([=] { + auto p = QPainter(raw); + icon.paint(p, 0, 0, raw->width()); + }, raw->lifetime()); + + const auto wrap = Ui::CreateChild>( + _content.get(), + std::move(widget)); + wrap->hide(anim::type::instant); + + _content->sizeValue( + ) | rpl::on_next([=](QSize size) { + const auto left = st::createPollFieldPadding.left(); + wrap->moveToLeft( + left, + (size.height() - wrap->heightNoMargins()) / 2); + }, wrap->lifetime()); + + _handle.reset(wrap); +} + +Ui::RpWidget *Options::Option::handleWidget() const { + return _handle ? _handle->entity() : nullptr; +} + bool Options::Option::isEmpty() const { return field()->getLastText().trimmed().isEmpty(); } @@ -386,6 +546,22 @@ bool Options::Option::isCorrect() const { return isGood() && _correct && _correct->entity()->Checkbox::checked(); } +bool Options::Option::uploadingMedia() const { + return _media->uploading; +} + +bool Options::Option::refreshMediaIfStale(crl::time threshold) { + if (_media->media + && _media->uploadedAt > 0 + && (!threshold + || (crl::now() - _media->uploadedAt > threshold)) + && _media->reupload) { + _media->reupload(); + return true; + } + return false; +} + bool Options::Option::hasFocus() const { return field()->hasFocus(); } @@ -394,37 +570,41 @@ void Options::Option::setFocus() const { FocusAtEnd(field()); } -void Options::Option::clearValue() { - field()->setText(QString()); -} - void Options::Option::setPlaceholder() const { field()->setPlaceholder(tr::lng_polls_create_option_add()); } -void Options::Option::toggleRemoveAlways(bool toggled) { - *_removeAlways = toggled; -} - void Options::Option::enableChooseCorrect( - std::shared_ptr group) { - if (!group) { + std::shared_ptr group, + bool multiCorrect, + Fn checkboxChanged) { + if (!group && !multiCorrect) { + _hasCorrect = false; if (_correct) { - _hasCorrect = false; _correct->hide(anim::type::normal); - toggleCorrectSpace(false); + } + if (_handle) { + _handle->toggle(isGood(), anim::type::normal); } return; } static auto Index = 0; - const auto button = Ui::CreateChild>( - _content.get(), - object_ptr( + auto checkbox = multiCorrect + ? object_ptr( + _content.get(), + QString(), + false, + st::defaultCheckbox, + st::defaultCheck) + : object_ptr(object_ptr( _content.get(), group, ++Index, QString(), st::defaultCheckbox)); + const auto button = Ui::CreateChild>( + _content.get(), + std::move(checkbox)); button->entity()->resize( button->entity()->height(), button->entity()->height()); @@ -438,29 +618,27 @@ void Options::Option::enableChooseCorrect( }, button->lifetime()); _correct.reset(button); _hasCorrect = true; + if (multiCorrect && checkboxChanged) { + button->entity()->checkedChanges( + ) | rpl::on_next([=](bool) { + checkboxChanged(); + }, button->lifetime()); + } if (isGood()) { _correct->show(anim::type::normal); } else { _correct->hide(anim::type::instant); } - toggleCorrectSpace(true); -} - -void Options::Option::toggleCorrectSpace(bool visible) { - _correctShown.start( - [=] { updateFieldGeometry(); }, - visible ? 0. : 1., - visible ? 1. : 0., - st::fadeWrapDuration); + if (_handle) { + _handle->hide(anim::type::normal); + } } void Options::Option::updateFieldGeometry() { - const auto shown = _correctShown.value(_hasCorrect ? 1. : 0.); const auto skip = st::defaultRadio.diameter + st::defaultCheckbox.textPosition.x(); - const auto left = anim::interpolate(0, skip, shown); - _field->resizeToWidth(_content->width() - left); - _field->moveToLeft(left, 0); + _field->resizeToWidth(_content->width() - skip); + _field->moveToLeft(skip, 0); } not_null Options::Option::field() const { @@ -471,10 +649,54 @@ void Options::Option::removePlaceholder() const { field()->setPlaceholder(rpl::single(QString())); } +void Options::Option::showAddIcon(bool show) { + if (show && !_addIcon) { + auto icon = Settings::Icon(Settings::IconDescriptor{ + &st::settingsIconAdd, + Settings::IconType::Round, + &st::windowBgActive, + }); + const auto iconSize = icon.size(); + auto widget = object_ptr(_content.get()); + const auto raw = widget.data(); + raw->resize(iconSize); + const auto iconPtr = std::make_shared( + std::move(icon)); + raw->paintOn([=](QPainter &p) { + iconPtr->paint(p, 0, 0); + }); + + const auto wrap = + Ui::CreateChild>( + _content.get(), + std::move(widget)); + wrap->hide(anim::type::instant); + + _content->sizeValue( + ) | rpl::on_next([=](QSize size) { + const auto &handleIcon = st::pollBoxMenuPollOrderIcon; + const auto left = st::createPollFieldPadding.left() + + (handleIcon.width() - iconSize.width()) / 2; + wrap->moveToLeft( + left, + (size.height() - wrap->heightNoMargins()) / 2); + }, wrap->lifetime()); + + _addIcon = wrap; + } + if (_addIcon) { + if (show) { + _addIcon->show(anim::type::normal); + } else { + _addIcon->hide(anim::type::normal); + } + } +} + PollAnswer Options::Option::toPollAnswer(int index) const { Expects(index >= 0 && index < kMaxOptionsCount); - const auto text = field()->getTextWithTags(); + const auto text = field()->getTextWithAppliedMarkdown(); auto result = PollAnswer{ TextWithEntities{ @@ -483,29 +705,35 @@ PollAnswer Options::Option::toPollAnswer(int index) const { }, QByteArray(1, ('0' + index)), }; + result.media = _media->media; TextUtilities::Trim(result.text); result.correct = _correct ? _correct->entity()->Checkbox::checked() : false; return result; } -rpl::producer Options::Option::removeClicks() const { - return _remove->clicks(); -} - Options::Options( not_null box, not_null container, not_null controller, ChatHelpers::TabbedPanel *emojiPanel, - bool chooseCorrectEnabled) + bool chooseCorrectEnabled, + AttachCallback attachCallback, + FieldDropCallback fieldDropCallback, + WidgetDropCallback widgetDropCallback) : _box(box) , _container(container) , _controller(controller) , _emojiPanel(emojiPanel) +, _attachCallback(std::move(attachCallback)) +, _fieldDropCallback(std::move(fieldDropCallback)) +, _widgetDropCallback(std::move(widgetDropCallback)) , _chooseCorrectGroup(chooseCorrectEnabled ? createChooseCorrectGroup() - : nullptr) -, _position(_container->count()) { + : nullptr) { + auto optionsObj = object_ptr(container); + _optionsLayout = optionsObj.data(); + container->add(std::move(optionsObj)); + setupReorder(); checkLastOption(); } @@ -526,6 +754,24 @@ bool Options::hasCorrect() const { return _hasCorrect; } +bool Options::hasUploadingMedia() const { + return ranges::any_of(_list, &Option::uploadingMedia); +} + +bool Options::refreshStaleMedia(crl::time threshold) { + auto refreshed = false; + for (const auto &option : _list) { + if (option->refreshMediaIfStale(threshold)) { + refreshed = true; + } + } + return refreshed; +} + +not_null Options::layoutWidget() const { + return _optionsLayout; +} + rpl::producer Options::usedCount() const { return _usedCount.value(); } @@ -587,14 +833,28 @@ std::shared_ptr Options::createChooseCorrectGroup() { return result; } -void Options::enableChooseCorrect(bool enabled) { - _chooseCorrectGroup = enabled - ? createChooseCorrectGroup() - : nullptr; - for (auto &option : _list) { - option->enableChooseCorrect(_chooseCorrectGroup); +void Options::enableChooseCorrect(bool enabled, bool multiCorrect) { + _multiCorrect = enabled && multiCorrect; + if (_multiCorrect) { + _chooseCorrectGroup = nullptr; + _multiCorrectChanged = [=] { validateState(); }; + for (auto &option : _list) { + option->enableChooseCorrect( + nullptr, + true, + _multiCorrectChanged); + } + } else { + _multiCorrectChanged = nullptr; + _chooseCorrectGroup = enabled + ? createChooseCorrectGroup() + : nullptr; + for (auto &option : _list) { + option->enableChooseCorrect(_chooseCorrectGroup); + } } validateState(); + restartReorder(); } bool Options::correctShadows() const { @@ -643,6 +903,9 @@ void Options::removeEmptyTail() { } void Options::destroy(std::unique_ptr