diff --git a/.agents/skills/task-think/PROMPTS.md b/.agents/skills/task-think/PROMPTS.md new file mode 100644 index 0000000000..ad804219a4 --- /dev/null +++ b/.agents/skills/task-think/PROMPTS.md @@ -0,0 +1,454 @@ +# Phase Prompts + +Use these templates for `codex exec --json` child runs. Replace ``, ``, ``, and ``. + +## 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. + +Before running any phase prompts, the orchestrator must 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. + +**Do NOT proceed until you have run these checks and 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/`. +- Set `` = `a`. + +**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/`. + +Then proceed to Phase 1. Follow-up tasks do NOT skip context gathering — they go through a modified version of it. + +## Phase 1: Context (New Project, letter = `a`) + +```text +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. + +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. +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 +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: + +### 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. + +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 + +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. + +### 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 + +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. + +Do not implement code in this phase. +``` + +## Phase 1F: Context (Follow-up Task, letter = `b`, `c`, ...) + +```text +You are a context-gathering agent for a follow-up task on an existing project in a large C++ codebase (Telegram Desktop). + +NEW TASK: + +YOUR JOB: Read the existing project state, gather any additional context needed, and produce fresh documents for the new task. + +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. +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. +6. Read all newly relevant files thoroughly. + +Write TWO files: + +### 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. + +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 + +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) + +### 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. + +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. + +Do not implement code in this phase. +``` + +## Phase 2: Plan + +```text +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 +- Then read the specific source files referenced in context.md to understand the code deeply. + +Create a detailed plan in: .ai///plan.md + +The plan.md should contain: + +## Task + + +## Approach + + +## Files to Modify + + +## Files to Create + + +## 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.) + +Number every step. Group steps into phases if there are more than ~8 steps. + +### Phase 1: +1. +2. +... + +### Phase 2: (if needed) +... + +## Build Verification +- Build command to run +- Expected outcome + +## Status +- [ ] Phase 1: +- [ ] Phase 2: (if applicable) +- [ ] Build verification +- [ ] Code review + +Do not implement code in this phase. +``` + +## Phase 3: Plan Assessment + +```text +You are a plan assessment agent. Review and refine an implementation plan. + +Read these files: +- .ai///context.md +- .ai///plan.md +- Then read the actual source files referenced to verify the plan makes sense. + +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. + +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 + +If the plan is small enough for a single agent (roughly <=8 steps), 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: + +```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 + +Then read the source files you'll be modifying. + +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 +- When done, update plan.md Status section: change `- [ ] Phase : ...` to `- [x] Phase : ...` +- Do NOT work on other phases + +When finished, report what you did 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. + +## Phase 5: Build Verification + +Only run this phase if the task involved modifying project source code (not just docs or config). + +```text +You are a build verification agent. + +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. + +Steps: +1. Run (from repository root): cmake --build ./out --config Debug --target Telegram +2. If the build succeeds, update plan.md: change `- [ ] Build verification` to `- [x] Build verification` +3. If the build fails: + a. Read the error messages carefully + b. Read the relevant source files + c. Fix the errors in accordance with the plan and AGENTS.md conventions + d. Rebuild and repeat until the build passes + 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 + +When finished, report the build result. +``` + +## 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`. + +### Review Loop + +``` +LOOP: + 1. Run review agent (Step 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: + R = R + 1 + If R > 3 → go to FINISH (stop iterating, accept current state) + Otherwise → go to step 1 + +FINISH: + - Update plan.md: change `- [ ] Code review` to `- [x] Code review` + - Proceed to Completion +``` + +### Step 6a: Code Review Agent + +```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) + +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 read the modified source files in full to understand changes in context. + +Perform a thorough code review. + +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. 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. + +Write your review to: .ai///review.md + +The review document should contain: + +## Code Review - Iteration + +## Summary +<1-2 sentence overall assessment> + +## Verdict: + + + + + +## Changes Required + +### +- **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. + +When finished, report your verdict clearly as: APPROVED or NEEDS_CHANGES. +``` + +### Step 6b: Review Fix Agent + +```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 + +Then read the source files mentioned in the review. + +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. + +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. + +Rules: +- Implement exactly the changes from the review, nothing more. +- Follow AGENTS.md coding conventions. +- Do NOT modify .ai/ files. + +When finished, report what changes were made. +``` + +## Completion + +When all phases including build verification and code review are done: +1. Read the final `plan.md` and report the summary to the user. +2. Show which files were modified/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. + +## 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 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. + +## Reasoning Effort + +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. + +## Example Runner Commands + +```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 +``` diff --git a/.codex/skills/task-think/SKILL.md b/.agents/skills/task-think/SKILL.md similarity index 52% rename from .codex/skills/task-think/SKILL.md rename to .agents/skills/task-think/SKILL.md index 19f1260a33..3f332e7887 100644 --- a/.codex/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 optional fresh codex exec child runs per phase. Use when the user wants one prompt to drive context gathering, planning, implementation, 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/// 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. --- # Task Pipeline @@ -12,7 +12,7 @@ Run a full implementation workflow with repository artifacts and clear phase bou Collect: - task description - optional project name (if missing, derive a short kebab-case name) -- optional constraints (files, architecture, deadlines, risk tolerance) +- 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. @@ -28,11 +28,13 @@ Project structure: a/ # First task context.md # Gathered codebase context for this task plan.md # Implementation plan - review.md # Code review document + review1.md # Code review documents (up to 3 iterations) + review2.md + review3.md b/ # Follow-up task context.md plan.md - review.md + review1.md c/ # Another follow-up task ... ``` @@ -46,41 +48,55 @@ Create and maintain: - `.ai//about.md` - `.ai///context.md` - `.ai///plan.md` -- `.ai///implementation.md` -- `.ai///review.md` +- `.ai///review.md` (up to 3 review iterations) - `.ai///logs/phase-*.jsonl` (when running child `codex exec`) -## Execution Mode +## Phases -Run `codex exec --json` child sessions for each phase. +The workflow runs these phases sequentially via `codex exec --json` child sessions: -## Fresh-Run Mode Procedure - -1. Detect follow-up vs new project (check if first token of task description matches an existing project name with `about.md`). -2. For new projects: pick unique short name, create `.ai//` and `.ai//a/`. -3. For follow-up tasks: find latest task letter, create `.ai///`. -4. Run child phase sessions sequentially, waiting for each to finish. -5. After each phase, validate artifact file exists and has substantive content. -6. Summarize status in the parent session after each phase. -7. Stop immediately on blocking errors and report exact blocker. +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. 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. + +Phases that require elevated reasoning (Planning, Plan Assessment, Code Review) must use `-c model_reasoning_effort="xhigh"`. See example commands in `PROMPTS.md`. + ## Verification Rules -- If build or test commands fail due to file locks or access-denied outputs, stop and ask the user to close locking processes before retrying. +- 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. - Never claim completion without: - implemented code changes present - - build/test attempt results recorded + - build attempt results recorded - review pass documented with any follow-up fixes ## Completion Criteria Mark complete only when: -- plan phases are done -- verification results are recorded -- review issues are addressed or explicitly deferred with rationale -- Remind the user of the project name so they can request follow-up tasks within the same project. +- All plan phases are done +- Build verification is recorded +- Review issues are addressed or explicitly deferred with rationale +- 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 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. ## User Invocation diff --git a/.claude/commands/planner.md b/.claude/commands/planner.md index ec10090dc4..0a0e2e7c3b 100644 --- a/.claude/commands/planner.md +++ b/.claude/commands/planner.md @@ -37,16 +37,8 @@ ls .ai/ Suggest a name to the user or let them specify one directly via $ARGUMENTS. -### 3. Use /ultrathink for Planning -Before writing the prompt, use `/ultrathink` to carefully plan: -- The structure of the prompt -- What context the autonomous agent needs -- How tasks should be broken down -- What patterns/examples to include -- Edge cases and error handling - -### 4. Create the Folder and Files +### 3. Create the Folder and Files Create `.ai//`: @@ -91,7 +83,7 @@ IMPORTANT: Never try to commit files in .ai/ } ``` -### 5. Iterate with the User +### 4. Iterate with the User After creating initial files, the user may want to: - Add more tasks to tasks.json diff --git a/.claude/commands/task.md b/.claude/commands/task.md index 6d4b147f45..31988405bb 100644 --- a/.claude/commands/task.md +++ b/.claude/commands/task.md @@ -38,6 +38,8 @@ Project structure: ## Phase 0: Setup +**Record the current time now** (using `Get-Date` in PowerShell or equivalent) and store it as `$START_TIME`. You will use this at the end to display total elapsed time. + ⚠️ **CRITICAL: Follow-up detection MUST happen FIRST, before anything else.** ### Step 0a: Follow-up detection (MANDATORY — do this BEFORE understanding the task) @@ -80,10 +82,10 @@ You are a context-gathering agent for a large C++ codebase (Telegram Desktop). TASK: -YOUR JOB: Read CLAUDE.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 CLAUDE.md for project conventions and build instructions. +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. 4. For each relevant file, note: @@ -140,7 +142,7 @@ NEW TASK: YOUR JOB: Read the existing project state, gather any additional context needed, and produce fresh documents for the new task. Steps: -1. Read CLAUDE.md for project conventions and build instructions. +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. 4. Understand what has already been implemented by reading the actual source files referenced in about.md and the previous context. @@ -201,7 +203,7 @@ Read these files: - .ai///context.md - Contains all gathered context for this task - Then read the specific source files referenced in context.md to understand the code deeply. -Use /ultrathink to reason carefully about the implementation approach. +Think carefully about the implementation approach. Create a detailed plan in: .ai///plan.md @@ -262,11 +264,11 @@ Read these files: - .ai///plan.md - Then read the actual source files referenced to verify the plan makes sense. -Use /ultrathink to assess the plan: +Carefully 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 CLAUDE.md? +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. @@ -303,7 +305,7 @@ YOUR TASK: Implement ONLY Phase from the plan: Rules: - Follow the plan precisely -- Follow CLAUDE.md coding conventions (no comments except complex algorithms, use auto, empty line before closing brace, etc.) +- 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 - When done, update plan.md Status section: change `- [ ] Phase : ...` to `- [x] Phase : ...` - Do NOT work on other phases @@ -332,18 +334,18 @@ Read these files: The implementation is complete. Your job is to build the project and fix any build errors. Steps: -1. Run: cmake --build "c:\Telegram\tdesktop\out" --config Debug --target Telegram +1. Run (from repository root): cmake --build ./out --config Debug --target Telegram 2. If the build succeeds, update plan.md: change `- [ ] Build verification` to `- [x] Build verification` 3. If the build fails: a. Read the error messages carefully b. Read the relevant source files - c. Fix the errors in accordance with the plan and CLAUDE.md conventions + c. Fix the errors in accordance with the plan and AGENTS.md conventions d. Rebuild and repeat until the build passes e. Update plan.md status when done Rules: - Only fix build errors, do not refactor or improve code -- Follow CLAUDE.md conventions +- Follow AGENTS.md conventions - If build fails with file-locked errors (C1041, LNK1104), STOP and report - do not retry When finished, report the build result. @@ -391,7 +393,7 @@ Then run `git diff` to see all uncommitted changes made by the implementation. I Then read the modified source files in full to understand changes in context. -Use /ultrathink to perform a thorough code review. +Perform a thorough code review. REVIEW CRITERIA (in order of importance): @@ -409,7 +411,7 @@ REVIEW CRITERIA (in order of importance): 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 CLAUDE.md conventions (no unnecessary comments, `auto` usage, no hardcoded sizes — must use .style definitions), etc. +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. @@ -472,13 +474,13 @@ For each issue in the review: 3. Verify the change makes sense in context. After all changes are made: -1. Build: cmake --build "c:\Telegram\tdesktop\out" --config Debug --target Telegram +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. Rules: - Implement exactly the changes from the review, nothing more. -- Follow CLAUDE.md coding conventions. +- Follow AGENTS.md coding conventions. - Do NOT modify .ai/ files. When finished, report what changes were made. @@ -493,7 +495,8 @@ When all phases including build verification and code review are done: 2. Show which files were modified/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. Remind the user of the project name so they can use `/task ` for follow-up changes. +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 use `/task ` for follow-up changes. ## Error Handling diff --git a/.claude/commands/withtest.md b/.claude/commands/withtest.md index 739c583be8..d6f3c72f30 100644 --- a/.claude/commands/withtest.md +++ b/.claude/commands/withtest.md @@ -100,7 +100,7 @@ YOUR JOB: - [ ] Testing Assessed: yes -Use /ultrathink to reason carefully. The follow-up plan should be self-contained enough that an implementation agent can execute it by reading context.md and the updated plan.md. +Reason carefully. The follow-up plan should be self-contained enough that an implementation agent can execute it by reading context.md and the updated plan.md. ``` After this agent completes, read `plan.md` to verify the follow-up plan was written. Then proceed to Phase 4 (Implementation), using the follow-up phases (F1, F2, etc.) instead of the original phases. After implementation and build verification, proceed to Stage 2 (Testing Loop) as normal. @@ -163,7 +163,7 @@ Read these files: - .ai//context.md - Contains all gathered context - Then read the specific source files referenced in context.md to understand the code deeply. -Use /ultrathink to reason carefully about the implementation approach. +Think carefully about the implementation approach. Create a detailed plan in: .ai//plan.md @@ -224,7 +224,7 @@ Read these files: - .ai//plan.md - Then read the actual source files referenced to verify the plan makes sense. -Use /ultrathink to assess the plan: +Carefully 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? @@ -367,7 +367,7 @@ YOUR TASKS: - Decide: reuse/modify existing test code or start fresh. 3. **Plan the test code.** - Use /ultrathink to design test code that will verify the implementation works correctly. + Carefully design test code that will verify the implementation works correctly. The test code must: - Be wrapped in `#ifdef _DEBUG` blocks so it only runs in Debug builds @@ -530,7 +530,7 @@ Read these files: - .ai//result.md 1, also read previous test/result pairs for history> -Use /ultrathink to carefully analyze the test results. +Carefully analyze the test results. DECIDE one of three outcomes: diff --git a/.claude/iterate.ps1 b/.claude/iterate.ps1 index be038920b7..195c1be6ac 100644 --- a/.claude/iterate.ps1 +++ b/.claude/iterate.ps1 @@ -173,7 +173,7 @@ Do exactly ONE task per iteration. ## Steps 1. Read tasks.json and find the most suitable task to implement (it can be first uncompleted task or it can be some task in the middle, if it is better suited to be implemented right now, respecting dependencies) -2. Use /ultrathink to plan the implementation carefully +2. Plan the implementation carefully 3. Implement that ONE task only 4. After successful implementation: $AfterImplementation diff --git a/.codex/skills/task-think/PROMPTS.md b/.codex/skills/task-think/PROMPTS.md deleted file mode 100644 index f86d962849..0000000000 --- a/.codex/skills/task-think/PROMPTS.md +++ /dev/null @@ -1,227 +0,0 @@ -# Phase Prompts - -Use these templates for `codex exec --json` child runs. Replace ``, ``, ``, and ``. - -## Phase 0: Setup - -Before running any phase prompts, the orchestrator must determine whether this is a new project or a follow-up task. - -**Follow-up detection:** -1. Extract the first word/token from the task description. Call it `FIRST_TOKEN`. -2. Check if `.ai//about.md` exists. -3. If it exists: this is a **follow-up task**. The project name is `FIRST_TOKEN`. The task description is everything after `FIRST_TOKEN`. -4. If it does not exist: this is a **new project**. The full input is the task description. - -**For new projects:** -- List existing `.ai/` folders to pick a unique short name (1-2 lowercase words, hyphen-separated). -- Create `.ai//` and `.ai//a/` and `logs/`. -- Set `` = `a`. - -**For follow-up tasks:** -- Scan `.ai//` for existing task folders (`a/`, `b/`, ...). Find the latest one (highest letter). -- The new task letter = next letter in sequence. -- Create `.ai///` and `logs/`. - -Then proceed to Phase 1. Follow-up tasks do NOT skip context gathering — they go through a modified version of it. - -## Phase 1: Context (New Project, letter = `a`) - -```text -You are the context phase for task "" in repository . - -Read CLAUDE.md for the basic coding rules and guidelines. - -Read AGENTS.md and all relevant source files. Write TWO documents: - -### 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. - -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 - -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. - -### File 2: .ai//a/context.md - -This is the PRIMARY document — all downstream agents (planning, implementation, review) will read ONLY this file. It must be completely self-contained. Include: -1. Task description restated clearly -2. Relevant files with line ranges and why they matter -3. Existing patterns to follow (with code snippets) -4. Data structures, types, classes -5. API methods (from api.tl if applicable) -6. UI styles (from .style files if applicable) -7. Localization (from lang.strings if applicable) -8. Build info and verification hooks -9. Reference implementations of similar features -10. Risks and unknowns - -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. - -Do not implement code in this phase. -``` - -## Phase 1F: Context (Follow-up Task, letter = `b`, `c`, ...) - -```text -You are the context phase for a follow-up task on an existing project in repository . - -NEW TASK: - -Read CLAUDE.md for the basic coding rules and guidelines. - -Steps: -1. Read AGENTS.md for project conventions. -2. Read .ai//about.md — the project-level blueprint describing everything done so far. -3. Read .ai///context.md — 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. Search the codebase for any ADDITIONAL files, classes, functions, and patterns relevant to the new task but not already covered. -6. Read all newly relevant files thoroughly. - -Write TWO files: - -### 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. 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. - -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 - -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 - -### 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. - -It should contain: -- **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 (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. - -Do not implement code in this phase. -``` - -## Phase 2: Plan - -```text -You are the planning phase for task "" in repository . - -Read CLAUDE.md for the basic coding rules and guidelines. - -Read: -- .ai///context.md - -Then read the specific source files referenced in context.md to understand the code deeply. - -Create: -- .ai///plan.md - -Plan requirements: -1. Concrete file-level edits -2. Ordered phases (each phase implementable by a single agent, roughly <=8 steps) -3. Verification commands -4. Rollback/risk notes -5. Status section with checklist of phases, build verification, and code review -``` - -## Phase 3: Implement - -```text -You are the implementation phase for task "" in repository . - -Read CLAUDE.md for the basic coding rules and guidelines. - -Read: -- .ai///context.md -- .ai///plan.md - -Implement the plan in code. Then write: -- .ai///implementation.md - -Include: -1. Files changed -2. What was implemented -3. Any deviations from plan and why -4. Update plan.md Status section to mark completed phases -``` - -## Phase 4: Verify - -```text -You are the verification phase for task "" in repository . - -Read CLAUDE.md for the basic coding rules and guidelines. - -Read: -- .ai///plan.md -- .ai///implementation.md - -Run the relevant build/test commands from AGENTS.md and plan.md. -Append results to: -- .ai///implementation.md - -If blocked by locked files or access errors, stop and report exact blocker. -``` - -## Phase 5: Review - -```text -You are the review phase for task "" in repository . - -Read AGENTS.md for the basic coding rules and guidelines. -Read REVIEW.md for the style and formatting rules you must enforce. - -Read: -- .ai///context.md -- .ai///plan.md -- .ai///implementation.md - -Run `git diff` to see all uncommitted changes made by the implementation. Implementation phases do not commit, so `git diff` shows exactly the current feature's changes. Then read the modified source files in full. - -Perform a code review using these criteria (in order of importance): - -1. Correctness and safety: logic errors, null-check gaps at API boundaries, crashes, use-after-free, dangling references, race conditions. -2. Dead code: code added or left behind that is never called or used. Unused variables, unreachable branches, leftover scaffolding. -3. Redundant changes: changes in the diff with no functional effect — moving declarations or code blocks 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. -5. Wrong placement: code added to a module where it doesn't logically belong. -6. Function decomposition: for longer functions (~50+ lines), consider whether a sub-task could be cleanly extracted. Only suggest when there is a genuinely self-contained piece of logic. -7. Module structure: only flag if a large amount of new code (hundreds of lines) is logically distinct from its host module. -8. Style compliance: verify adherence to REVIEW.md rules and AGENTS.md conventions. - -Write: -- .ai///review.md - -If issues are found, implement fixes and update implementation.md/review.md with final status. -``` - -## Example Runner Commands - -```powershell -codex exec --json -C "" | Tee-Object .ai///logs/phase-1-context.jsonl -codex exec --json -C "" | Tee-Object .ai///logs/phase-2-plan.jsonl -codex exec --json -C "" | Tee-Object .ai///logs/phase-3-implement.jsonl -codex exec --json -C "" | Tee-Object .ai///logs/phase-4-verify.jsonl -codex exec --json -C "" | Tee-Object .ai///logs/phase-5-review.jsonl -``` diff --git a/.cursor/rules/api_usage.mdc b/.cursor/rules/api_usage.mdc deleted file mode 100644 index bf6e55bbdd..0000000000 --- a/.cursor/rules/api_usage.mdc +++ /dev/null @@ -1,105 +0,0 @@ ---- -description: For tasks requiring sending Telegram server API requests or working with generated API types. -globs: -alwaysApply: false ---- -# Telegram Desktop API Usage - -## API Schema - -The API definitions are described using [TL Language]\(https:/core.telegram.org/mtproto/TL) in two main schema files: - -1. **`Telegram/SourceFiles/mtproto/scheme/mtproto.tl`** - * Defines the core MTProto protocol types and methods used for basic communication, encryption, authorization, service messages, etc. - * Some fundamental types and methods from this schema (like basic types, RPC calls, containers) are often implemented directly in the C++ MTProto core (`SourceFiles/mtproto/`) and may be skipped during the C++ code generation phase. - * Other parts of `mtproto.tl` might still be processed by the code generator. - -2. **`Telegram/SourceFiles/mtproto/scheme/api.tl`** - * Defines the higher-level Telegram API layer, including all the methods and types related to chat functionality, user profiles, messages, channels, stickers, etc. - * This is the primary schema used when making functional API requests within the application. - -Both files use the same TL syntax to describe API methods (functions) and types (constructors). - -## Code Generation - -A custom code generation tool processes `api.tl` (and parts of `mtproto.tl`) to create corresponding C++ classes and types. These generated headers are typically included via the Precompiled Header (PCH) for the main `Telegram` project. - -Generated types often follow the pattern `MTP[Type]` (e.g., `MTPUser`, `MTPMessage`) and methods correspond to functions within the `MTP` namespace or related classes (e.g., `MTPmessages_SendMessage`). - -## Making API Requests - -API requests are made using a standard pattern involving the `api()` object (providing access to the `MTP::Instance`), the generated `MTP...` request object, callback handlers for success (`.done()`) and failure (`.fail()`), and the `.send()` method. - -Here's the general structure: - -```cpp -// Include necessary headers if not already in PCH - -// Obtain the API instance (usually via api() or MTP::Instance::Get()) -api().request(MTPnamespace_MethodName( - // Constructor arguments based on the api.tl definition for the method - MTP_flags(flags_value), // Use MTP_flags if the method has flags - MTP_inputPeer(peer), // Use MTP_... types for parameters - MTP_string(messageText), - MTP_long(randomId), - // ... other arguments matching the TL definition - MTP_vector() // Example for a vector argument -)).done([=]\(const MTPResponseType &result) { - // Handle the successful response (result). - // 'result' will be of the C++ type corresponding to the TL type - // specified after the '=' in the api.tl method definition. - // How to access data depends on whether the TL type has one or multiple constructors: - - // 1. Multiple Constructors (e.g., User = User | UserEmpty): - // Use .match() with lambdas for each constructor: - result.match([&]\(const MTPDuser &data) { - /* use data.vfirst_name().v, etc. */ - }, [&]\(const MTPDuserEmpty &data) { - /* handle empty user */ - }); - - // Alternatively, check the type explicitly and use the constructor getter: - if (result.type() == mtpc_user) { - const auto &data = result.c_user(); // Asserts if type is not mtpc_user! - // use data.vfirst_name().v - } else if (result.type() == mtpc_userEmpty) { - const auto &data = result.c_userEmpty(); - // handle empty user - } - - // 2. Single Constructor (e.g., Messages = messages { msgs: vector }): - // Use .match() with a single lambda: - result.match([&]\(const MTPDmessages &data) { /* use data.vmessages().v */ }); - - // Or check the type explicitly and use the constructor getter: - if (result.type() == mtpc_messages) { - const auto &data = result.c_messages(); // Asserts if type is not mtpc_messages! - // use data.vmessages().v - } - - // Or use the shortcut .data() for single-constructor types: - const auto &data = result.data(); // Only works for single-constructor types! - // use data.vmessages().v - -}).fail([=]\(const MTP::Error &error) { - // Handle the API error (error). - // 'error' is an MTP::Error object containing the error code (error.type()) - // and description (error.description()). Check for specific error strings. - if (error.type() == u"FLOOD_WAIT_X"_q) { - // Handle flood wait - } else { - Ui::show(Box(Lang::Hard::ServerError())); // Example generic error handling - } -}).handleFloodErrors().send(); // handleFloodErrors() is common, then send() -``` - -**Key Points:** - -* Always refer to `Telegram/SourceFiles/mtproto/scheme/api.tl` for the correct method names, parameters (names and types), and response types. -* Use the generated `MTP...` types/classes for request parameters (e.g., `MTP_int`, `MTP_string`, `MTP_bool`, `MTP_vector`, `MTPInputUser`, etc.) and response handling. -* The `.done()` lambda receives the specific C++ `MTP...` type corresponding to the TL return type. - * For types with **multiple constructors** (e.g., `User = User | UserEmpty`), use `result.match([&]\(const MTPDuser &d){ ... }, [&]\(const MTPDuserEmpty &d){ ... })` to handle each case, or check `result.type() == mtpc_user` / `mtpc_userEmpty` and call the specific `result.c_user()` / `result.c_userEmpty()` getter (which asserts on type mismatch). - * For types with a **single constructor** (e.g., `Messages = messages{...}`), you can use `result.match([&]\(const MTPDmessages &d){ ... })` with one lambda, or check `type()` and call `c_messages()`, or use the shortcut `result.data()` to access the fields directly. -* The `.fail()` lambda receives an `MTP::Error` object. Check `error.type()` against known error strings (often defined as constants or using `u"..."_q` literals). -* Directly construct the `MTPnamespace_MethodName(...)` object inside `request()`. -* Include `.handleFloodErrors()` before `.send()` for standard flood wait handling. diff --git a/.cursor/rules/localization.mdc b/.cursor/rules/localization.mdc deleted file mode 100644 index 85e7a32c7a..0000000000 --- a/.cursor/rules/localization.mdc +++ /dev/null @@ -1,164 +0,0 @@ ---- -description: For tasks requiring changing or adding user facing phrases and text parts. -globs: -alwaysApply: false ---- -# Telegram Desktop Localization - -## Coding Style Note - -**Use `auto`:** In the actual codebase, variable types are almost always deduced using `auto` (or `const auto`, `const auto &`) rather than being written out explicitly. Examples in this guide may use explicit types for clarity, but prefer `auto` in practice. - -```cpp -// Prefer this: -auto currentTitle = tr::lng_settings_title(tr::now); -auto nameProducer = GetNameProducer(); // Returns rpl::producer<...> - -// Instead of this: -QString currentTitle = tr::lng_settings_title(tr::now); -rpl::producer nameProducer = GetNameProducer(); -``` - -## String Resource File - -Base user-facing English strings are defined in the `lang.strings` file: - -`Telegram/Resources/langs/lang.strings` - -This file uses a key-value format with named placeholders: - -``` -"lng_settings_title" = "Settings"; -"lng_confirm_delete_item" = "Are you sure you want to delete {item_name}?"; -"lng_files_selected" = "{count} files selected"; // Simple count example (see Pluralization) -``` - -Placeholders are enclosed in curly braces, e.g., `{name}`, `{user}`. A special placeholder `{count}` is used for pluralization rules. - -### Pluralization - -For keys that depend on a number (using the `{count}` placeholder), English typically requires two forms: singular and plural. These are defined in `lang.strings` using `#one` and `#other` suffixes: - -``` -"lng_files_selected#one" = "{count} file selected"; -"lng_files_selected#other" = "{count} files selected"; -``` - -While only `#one` and `#other` are defined in the base `lang.strings`, the code generation process creates C++ accessors for all six CLDR plural categories (`#zero`, `#one`, `#two`, `#few`, `#many`, `#other`) to support languages with more complex pluralization rules. - -## Translation Process - -While `lang.strings` provides the base English text and the keys, the actual translations are managed via Telegram's translations platform (translations.telegram.org) and loaded dynamically at runtime from the API. The keys from `lang.strings` (including the `#one`/`#other` variants) are used on the platform. - -## Code Generation - -A code generation tool processes `lang.strings` to create C++ structures and accessors within the `tr` namespace. These allow type-safe access to strings and handling of placeholders and pluralization. Generated keys typically follow the pattern `tr::lng_key_name`. - -## String Usage in Code - -Strings are accessed in C++ code using the generated objects within the `tr::` namespace. There are two main ways to use them: reactively (returning an `rpl::producer`) or immediately (returning the current value). - -### 1. Reactive Usage (rpl::producer) - -Calling a generated string function directly returns a reactive producer, typically `rpl::producer`. This producer automatically updates its value whenever the application language changes. - -```cpp -// Key: "settings_title" = "Settings"; -auto titleProducer = tr::lng_settings_title(); // Type: rpl::producer - -// Key: "confirm_delete_item" = "Are you sure you want to delete {item_name}?"; -auto itemNameProducer = /* ... */; // Type: rpl::producer -auto confirmationProducer = tr::lng_confirm_delete_item( // Type: rpl::producer - tr::now, // NOTE: tr::now is NOT passed here for reactive result - lt_item_name, - std::move(itemNameProducer)); // Placeholder producers should be moved -``` - -### 2. Immediate Usage (Current Value) - -Passing `tr::now` as the first argument retrieves the string's current value in the active language (typically as a `QString`). - -```cpp -// Key: "settings_title" = "Settings"; -auto currentTitle = tr::lng_settings_title(tr::now); // Type: QString - -// Key: "confirm_delete_item" = "Are you sure you want to delete {item_name}?"; -const auto currentItemName = QString("My Document"); // Type: QString -auto currentConfirmation = tr::lng_confirm_delete_item( // Type: QString - tr::now, // Pass tr::now for immediate value - lt_item_name, currentItemName); // Placeholder value is a direct QString (or convertible) -``` - -### 3. Placeholders (`{tag}`) - -Placeholders like `{item_name}` are replaced by providing arguments after `tr::now` (for immediate) or as the initial arguments (for reactive). A corresponding `lt_tag_name` constant is passed before the value. - -* **Immediate:** Pass the direct value (e.g., `QString`, `int`). -* **Reactive:** Pass an `rpl::producer` of the corresponding type (e.g., `rpl::producer`). Remember to `std::move` the producer or use `rpl::duplicate` if you need to reuse the original producer afterwards. - -### 4. Pluralization (`{count}`) - -Keys using `{count}` require a numeric value for the `lt_count` placeholder. The correct plural form (`#zero`, `#one`, ..., `#other`) is automatically selected based on this value and the current language rules. - -* **Immediate (`tr::now`):** Pass a `float64` or `int` (which is auto-converted to `float64`). - ```cpp - int count = 1; - auto filesText = tr::lng_files_selected(tr::now, lt_count, count); // Type: QString - count = 5; - filesText = tr::lng_files_selected(tr::now, lt_count, count); // Uses "files_selected#other" - ``` - -* **Reactive:** Pass an `rpl::producer`. Use the `tr::to_count()` helper to convert an `rpl::producer` or wrap a single value. - ```cpp - // From an existing int producer: - auto countProducer = /* ... */; // Type: rpl::producer - auto filesTextProducer = tr::lng_files_selected( // Type: rpl::producer - lt_count, - countProducer | tr::to_count()); // Use tr::to_count() for conversion - - // From a single int value wrapped reactively: - int currentCount = 5; - auto filesTextProducerSingle = tr::lng_files_selected( // Type: rpl::producer - lt_count, - rpl::single(currentCount) | tr::to_count()); - // Alternative for single values (less common): rpl::single(currentCount * 1.) - ``` - -### 5. Custom Projectors - -An optional final argument can be a projector function (like `Ui::Text::Upper` or `Ui::Text::WithEntities`) to transform the output. - -* If the projector returns `OutputType`, the string function returns `OutputType` (immediate) or `rpl::producer` (reactive). -* Placeholder values must match the projector's *input* requirements. For `Ui::Text::WithEntities`, placeholders expect `TextWithEntities` (immediate) or `rpl::producer` (reactive). - -```cpp -// Immediate with Ui::Text::WithEntities projector -// Key: "user_posted_photo" = "{user} posted a photo"; -const auto userName = TextWithEntities{ /* ... */ }; // Type: TextWithEntities -auto message = tr::lng_user_posted_photo( // Type: TextWithEntities - tr::now, - lt_user, - userName, // Must be TextWithEntities - Ui::Text::WithEntities); // Projector - -// Reactive with Ui::Text::WithEntities projector -auto userNameProducer = /* ... */; // Type: rpl::producer -auto messageProducer = tr::lng_user_posted_photo( // Type: rpl::producer - lt_user, - std::move(userNameProducer), // Move placeholder producers - Ui::Text::WithEntities); // Projector -``` - -## Key Summary - -* Keys are defined in `Resources/langs/lang.strings` using `{tag}` placeholders. -* Plural keys use `{count}` and have `#one`/`#other` variants in `lang.strings`. -* Access keys via `tr::lng_key_name(...)` in C++. -* Call with `tr::now` as the first argument for the immediate `QString` (or projected type). -* Call without `tr::now` for the reactive `rpl::producer` (or projected type). -* Provide placeholder values (`lt_tag_name, value`) matching the usage (direct value for immediate, `rpl::producer` for reactive). Producers should typically be moved via `std::move`. -* For `{count}`: - * Immediate: Pass `int` or `float64`. - * Reactive: Pass `rpl::producer`, typically by converting an `int` producer using `| tr::to_count()`. -* Optional projector function as the last argument modifies the output type and required placeholder types. -* Actual translations are loaded at runtime from the API. diff --git a/.cursor/rules/rpl_guide.mdc b/.cursor/rules/rpl_guide.mdc deleted file mode 100644 index dea6beba47..0000000000 --- a/.cursor/rules/rpl_guide.mdc +++ /dev/null @@ -1,216 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# RPL (Reactive Programming Library) Guide - -## Coding Style Note - -**Use `auto`:** In the actual codebase, variable types are almost always deduced using `auto` (or `const auto`, `const auto &`) rather than being written out explicitly. Examples in this guide may use explicit types for clarity, but prefer `auto` in practice. - -```cpp -// Prefer this: -auto intProducer = rpl::single(123); -const auto &lifetime = existingLifetime; - -// Instead of this: -rpl::producer intProducer = rpl::single(123); -const rpl::lifetime &lifetime = existingLifetime; - -// Sometimes needed if deduction is ambiguous or needs help: -auto user = std::make_shared(); -auto data = QByteArray::fromHex("..."); -``` - -## Introduction - -RPL is the reactive programming library used in this project, residing in the `rpl::` namespace. It allows handling asynchronous streams of data over time. - -The core concept is the `rpl::producer`, which represents a stream of values that can be generated over a certain lifetime. - -## Producers: `rpl::producer` - -The fundamental building block is `rpl::producer`. It produces values of `Type` and can optionally signal an error of type `Error`. By default, `Error` is `rpl::no_error`, indicating that the producer does not explicitly handle error signaling through this mechanism. - -```cpp -// A producer that emits integers. -auto intProducer = /* ... */; // Type: rpl::producer - -// A producer that emits strings and can potentially emit a CustomError. -auto stringProducerWithError = /* ... */; // Type: rpl::producer -``` - -Producers are typically lazy; they don't start emitting values until someone subscribes to them. - -## Lifetime Management: `rpl::lifetime` - -Reactive pipelines have a limited duration, managed by `rpl::lifetime`. An `rpl::lifetime` object essentially holds a collection of cleanup callbacks. When the lifetime ends (either explicitly destroyed or goes out of scope), these callbacks are executed, tearing down the associated pipeline and freeing resources. - -```cpp -rpl::lifetime myLifetime; -// ... later ... -// myLifetime is destroyed, cleanup happens. - -// Or, pass a lifetime instance to manage a pipeline's duration. -rpl::lifetime &parentLifetime = /* ... get lifetime from context ... */; -``` - -## Starting a Pipeline: `rpl::start_...` - -To consume values from a producer, you start a pipeline using one of the `rpl::start_...` methods. These methods subscribe to the producer and execute callbacks for the events they handle. - -The most common method is `rpl::on_next`: - -```cpp -auto counter = /* ... */; // Type: rpl::producer -rpl::lifetime lifetime; - -// Counter is consumed here, use std::move if it's an l-value. -std::move( - counter -) | rpl::on_next([=]\(int nextValue) { - // Process the next integer value emitted by the producer. - qDebug() << "Received: " << nextValue; -}, lifetime); // Pass the lifetime to manage the subscription. -// Note: `counter` is now in a moved-from state and likely invalid. - -// If you need to start the same producer multiple times, duplicate it: -// rpl::duplicate(counter) | rpl::on_next(...); - -// If you DON'T pass a lifetime to a start_... method: -auto counter2 = /* ... */; // Type: rpl::producer -rpl::lifetime subscriptionLifetime = std::move( - counter2 -) | rpl::on_next([=]\(int nextValue) { /* ... */ }); -// The returned lifetime MUST be stored. If it's discarded immediately, -// the subscription stops instantly. -// `counter2` is also moved-from here. -``` - -Other variants allow handling errors (`_error`) and completion (`_done`): - -```cpp -auto dataStream = /* ... */; // Type: rpl::producer -rpl::lifetime lifetime; - -// Assuming dataStream might be used again, we duplicate it for the first start. -// If it's the only use, std::move(dataStream) would be preferred. -rpl::duplicate( - dataStream -) | rpl::on_error([=]\(Error &&error) { - // Handle the error signaled by the producer. - qDebug() << "Error: " << error.text(); -}, lifetime); - -// Using dataStream again, perhaps duplicated again or moved if last use. -rpl::duplicate( - dataStream -) | rpl::on_done([=] { - // Execute when the producer signals it's finished emitting values. - qDebug() << "Stream finished."; -}, lifetime); - -// Last use of dataStream, so we move it. -std::move( - dataStream -) | rpl::on_next_error_done( - [=]\(QString &&value) { /* handle next value */ }, - [=]\(Error &&error) { /* handle error */ }, - [=] { /* handle done */ }, - lifetime); -``` - -## Transforming Producers - -RPL provides functions to create new producers by transforming existing ones: - -* `rpl::map`: Transforms each value emitted by a producer. - ```cpp - auto ints = /* ... */; // Type: rpl::producer - // The pipe operator often handles the move implicitly for chained transformations. - auto strings = std::move( - ints // Explicit move is safer - ) | rpl::map([](int value) { - return QString::number(value * 2); - }); // Emits strings like "0", "2", "4", ... - ``` - -* `rpl::filter`: Emits only the values from a producer that satisfy a condition. - ```cpp - auto ints = /* ... */; // Type: rpl::producer - auto evenInts = std::move( - ints // Explicit move is safer - ) | rpl::filter([](int value) { - return (value % 2 == 0); - }); // Emits only even numbers. - ``` - -## Combining Producers - -You can combine multiple producers into one: - -* `rpl::combine`: Combines the latest values from multiple producers whenever *any* of them emits a new value. Requires all producers to have emitted at least one value initially. - While it produces a `std::tuple`, subsequent operators like `map`, `filter`, and `on_next` can automatically unpack this tuple into separate lambda arguments. - ```cpp - auto countProducer = rpl::single(1); // Type: rpl::producer - auto textProducer = rpl::single(u"hello"_q); // Type: rpl::producer - rpl::lifetime lifetime; - - // rpl::combine takes producers by const-ref internally and duplicates, - // so move/duplicate is usually not strictly needed here unless you - // want to signal intent or manage the lifetime of p1/p2 explicitly. - auto combined = rpl::combine( - countProducer, // or rpl::duplicate(countProducer) - textProducer // or rpl::duplicate(textProducer) - ); - - // Starting the combined producer consumes it. - // The lambda receives unpacked arguments, not the tuple itself. - std::move( - combined - ) | rpl::on_next([=]\(int count, const QString &text) { - // No need for std::get<0>(latest), etc. - qDebug() << "Combined: Count=" << count << ", Text=" << text; - }, lifetime); - - // This also works with map, filter, etc. - std::move( - combined - ) | rpl::filter([=]\(int count, const QString &text) { - return count > 0 && !text.isEmpty(); - }) | rpl::map([=]\(int count, const QString &text) { - return text.repeated(count); - }) | rpl::on_next([=]\(const QString &result) { - qDebug() << "Mapped & Filtered: " << result; - }, lifetime); - ``` - -* `rpl::merge`: Merges the output of multiple producers of the *same type* into a single producer. It emits a value whenever *any* of the source producers emits a value. - ```cpp - auto sourceA = /* ... */; // Type: rpl::producer - auto sourceB = /* ... */; // Type: rpl::producer - - // rpl::merge also duplicates internally. - auto merged = rpl::merge(sourceA, sourceB); - - // Starting the merged producer consumes it. - std::move( - merged - ) | rpl::on_next([=]\(QString &&value) { - // Receives values from either sourceA or sourceB as they arrive. - qDebug() << "Merged value: " << value; - }, lifetime); - ``` - -## Key Concepts Summary - -* Use `rpl::producer` to represent streams of values. -* Manage subscription duration using `rpl::lifetime`. -* Pass `rpl::lifetime` to `rpl::start_...` methods. -* If `rpl::lifetime` is not passed, **store the returned lifetime** to keep the subscription active. -* Use operators like `| rpl::map`, `| rpl::filter` to transform streams. -* Use `rpl::combine` or `rpl::merge` to combine streams. -* When starting a chain (`std::move(producer) | rpl::map(...)`), explicitly move the initial producer. -* These functions typically duplicate their input producers internally. -* Starting a pipeline consumes the producer; use ` diff --git a/.cursor/rules/styling.mdc b/.cursor/rules/styling.mdc deleted file mode 100644 index 42729f096c..0000000000 --- a/.cursor/rules/styling.mdc +++ /dev/null @@ -1,154 +0,0 @@ ---- -description: For tasks requiring working with user facing UI components. -globs: -alwaysApply: false ---- -# Telegram Desktop UI Styling - -## Style Definition Files - -UI element styles (colors, fonts, paddings, margins, icons, etc.) are defined in `.style` files using a custom syntax. These files are located alongside the C++ source files they correspond to within specific UI component directories (e.g., `Telegram/SourceFiles/ui/chat/chat.style`). - -Definitions from other `.style` files can be included using the `using` directive at the top of the file: -```style -using "ui/basic.style"; -using "ui/widgets/widgets.style"; -``` - -The central definition of named colors happens in `Telegram/SourceFiles/ui/colors.palette`. This file allows for theme generation and loading colors from various sources. - -### Syntax Overview - -1. **Built-in Types:** The syntax recognizes several base types inferred from the value assigned: - * `int`: Integer numbers (e.g., `lineHeight: 20;`) - * `bool`: Boolean values (e.g., `useShadow: true;`) - * `pixels`: Pixel values, ending with `px` (e.g., `borderWidth: 1px;`). Generated as `int` in C++. - * `color`: Named colors defined in `colors.palette` (e.g., `background: windowBg;`) - * `icon`: Defined inline using a specific syntax (see below). Generates `style::icon`. - * `margins`: Four pixel values for margins or padding. Requires `margins(top, right, bottom, left)` syntax (e.g., `margin: margins(10px, 5px, 10px, 5px);` or `padding: margins(8px, 8px, 8px, 8px);`). Generates `style::margins` (an alias for `QMargins`). - * `size`: Two pixel values for width and height (e.g., `iconSize: size(16px, 16px);`). Generates `style::size`. - * `point`: Two pixel values for x and y coordinates (e.g., `textPos: point(5px, 2px);`). Generates `style::point`. - * `align`: Alignment keywords (e.g., `textAlign: align(center);` or `iconAlign: align(left);`). Generates `style::align`. - * `font`: Font definitions (e.g., `font: font(14px semibold);`). Generates `style::font`. - * `double`: Floating point numbers (e.g., `disabledOpacity: 0.5;`) - - *Note on Borders:* Borders are typically defined using multiple fields like `border: pixels;` (for width) and `borderFg: color;` (for color), rather than a single CSS-like property. - -2. **Structure Definition:** You can define complex data structures directly within the `.style` file: - ```style - MyButtonStyle { // Defines a structure named 'MyButtonStyle' - textPadding: margins; // Field 'textPadding' expects margins type - icon: icon; // Field 'icon' of type icon - height: pixels; // Field 'height' of type pixels - } - ``` - This generates a `struct MyButtonStyle { ... };` inside the `namespace style`. Fields will have corresponding C++ types (`style::margins`, `style::icon`, `int`). - -3. **Variable Definition & Inheritance:** Variables are defined using `name: value;` or `groupName { ... }`. They can be of built-in types or custom structures. Structures can be initialized inline or inherit from existing variables. - - **Icon Definition Syntax:** Icons are defined inline using the `icon{...}` syntax. The generator probes for `.svg` files or `.png` files (including `@2x`, `@3x` variants) based on the provided path stem. - ```style - // Single-part icon definition: - myIconSearch: icon{{ "gui/icons/search", iconColor }}; - // Multi-part icon definition (layers drawn bottom-up): - myComplexIcon: icon{ - { "gui/icons/background", iconBgColor }, - { "gui/icons/foreground", iconFgColor } - }; - // Icon with path modifiers (PNG only for flips, SVG only for size): - myFlippedIcon: icon{{ "gui/icons/arrow-flip_horizontal", arrowColor }}; - myResizedIcon: icon{{ "gui/icons/logo-128x128", logoColor }}; // Forces 128x128 for SVG - ``` - - **Other Variable Examples:** - ```style - // Simple variables - buttonHeight: 30px; - activeButtonColor: buttonBgActive; // Named color from colors.palette - - // Variable of a custom structure type, initialized inline - defaultButton: MyButtonStyle { - textPadding: margins(10px, 15px, 10px, 15px); // Use margins(...) syntax - icon: myIconSearch; // Assign the previously defined icon variable - height: buttonHeight; // Reference another variable - } - - // Another variable inheriting from 'defaultButton' and overriding/adding fields - primaryButton: MyButtonStyle(defaultButton) { - icon: myComplexIcon; // Override icon with the multi-part one - backgroundColor: activeButtonColor; // Add a field not in MyButtonStyle definition - } - - // Style group (often used for specific UI elements) - chatInput { // Example using separate border properties and explicit padding - border: 1px; // Border width - borderFg: defaultInputFieldBorder; // Border color (named color) - padding: margins(5px, 10px, 5px, 10px); // Use margins(...) syntax for padding field - backgroundColor: defaultChatBg; // Background color - } - ``` - -## Code Generation - -A code generation tool processes these `.style` files and `colors.palette` to create C++ objects. -- The `using` directives resolve dependencies between `.style` files. -- Custom structure definitions (like `MyButtonStyle`) generate corresponding `struct MyButtonStyle { ... };` within the `namespace style`. -- Style variables/groups (like `defaultButton`, `primaryButton`, `chatInput`) are generated as objects/structs within the `st` namespace (e.g., `st::defaultButton`, `st::primaryButton`, `st::chatInput`). These generated structs contain members corresponding to the fields defined in the `.style` file. -- Color objects are generated into the `st` namespace as well, based on their names in `colors.palette` (e.g., `st::windowBg`, `st::buttonBgActive`). -- The generated header files for styles are placed in the `Telegram/SourceFiles/styles/` directory with a `style_` prefix (e.g., `styles/style_widgets.h` for `ui/widgets/widgets.style`). You include them like `#include "styles/style_widgets.h"`. - -Generated C++ types correspond to the `.style` types: `style::color`, `style::font`, `style::margins` (used for both `margin:` and `padding:` fields), `style::icon`, `style::size`, `style::point`, `style::align`, and `int` or `bool` for simple types. - -## Style Usage in Code - -Styles are applied in C++ code by referencing the generated `st::...` objects and their members. - -```cpp -// Example: Including the generated style header -#include "styles/style_widgets.h" // For styles defined in ui/widgets/widgets.style - -// ... inside some UI class code ... - -// Accessing members of a generated style struct -int height = st::primaryButton.height; // Accessing the 'height' field (pixels -> int) -const style::icon &icon = st::primaryButton.icon; // Accessing the 'icon' field (st::myComplexIcon) -style::margins padding = st::primaryButton.textPadding; // Accessing 'textPadding' -style::color bgColor = st::primaryButton.backgroundColor; // Accessing the color (st::activeButtonColor) - -// Applying styles (conceptual examples) -myButton->setIcon(st::primaryButton.icon); -myButton->setHeight(st::primaryButton.height); -myButton->setPadding(st::primaryButton.textPadding); -myButton->setBackgroundColor(st::primaryButton.backgroundColor); - -// Using styles directly in painting -void MyWidget::paintEvent(QPaintEvent *e) { - Painter p(this); - p.fillRect(rect(), st::chatInput.backgroundColor); // Use color from chatInput style - - // Border painting requires width and color - int borderWidth = st::chatInput.border; // Access border width (pixels -> int) - style::color borderColor = st::chatInput.borderFg; // Access border color - if (borderWidth > 0) { - p.setPen(QPen(borderColor, borderWidth)); - // Adjust rect for pen width if needed before drawing - p.drawRect(rect().adjusted(borderWidth / 2, borderWidth / 2, -borderWidth / 2, -borderWidth / 2)); - } - - // Access padding (style::margins) - style::margins inputPadding = st::chatInput.padding; - // ... use inputPadding.top(), inputPadding.left() etc. for content layout ... -} -``` - -**Key Points:** - -* Styles are defined in `.style` files next to their corresponding C++ source files. -* `using "path/to/other.style";` includes definitions from other style files. -* Named colors are defined centrally in `ui/colors.palette`. -* `.style` syntax supports built-in types (like `pixels`, `color`, `margins`, `point`, `size`, `align`, `font`, `double`), custom structure definitions (`Name { field: type; ... }`), variable definitions (`name: value;`), and inheritance (`child: Name(parent) { ... }`). -* Values must match the expected type (e.g., fields declared as `margins` type, like `margin:` or `padding:`, require `margins(...)` syntax). Borders are typically set via separate `border: pixels;` and `borderFg: color;` fields. -* Icons are defined inline using `name: icon{{ "path_stem", color }};` or `name: icon{ { "path1", c1 }, ... };` syntax, with optional path modifiers. -* Code generation creates `struct` definitions in the `style` namespace for custom types and objects/structs in the `st` namespace for defined variables/groups. -* Generated headers are in `styles/` with a `style_` prefix and must be included. -* Access style properties via the generated `st::` objects (e.g., `st::primaryButton.height`, `st::chatInput.backgroundColor`). diff --git a/.github/scripts/generate_changelog.py b/.github/scripts/generate_changelog.py new file mode 100644 index 0000000000..6cdd12652d --- /dev/null +++ b/.github/scripts/generate_changelog.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Convert changelog.txt to a static HTML page for GitHub Pages.""" + +import re +import shutil +import sys +import html +from pathlib import Path + +MONTHS = [ + "", "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +] + +VERSION_RE = re.compile( + r"^(\d+\.\d+(?:\.\d+)?)\s*" # version number + r"(?:(alpha|beta|dev|stable)\s*)?" # optional tag + r"\((\d{2})\.(\d{2})\.(\d{2,4})\)$" # date (DD.MM.YY or DD.MM.YYYY) +) + + +def parse_date(day: str, month: str, year: str) -> tuple[str, str, str]: + """Return (sort_key, raw_display, full_display) from DD, MM, YY strings.""" + y = int(year) + if y < 100: + y += 2000 + m = int(month) + d = int(day) + sort_key = f"{y:04d}-{m:02d}-{d:02d}" + raw_display = f"{day}.{month}.{year}" + full_display = f"{d} {MONTHS[m]} {y}" + return sort_key, raw_display, full_display + + +def parse_changelog(text: str) -> list[dict]: + entries = [] + current = None + + for raw_line in text.splitlines(): + line = raw_line.rstrip() + m = VERSION_RE.match(line) + if m: + if current: + entries.append(current) + version, tag, day, month, year = m.groups() + sort_key, raw_date, full_date = parse_date(day, month, year) + current = { + "version": version, + "tag": tag or "", + "date": raw_date, + "full_date": full_date, + "sort_key": sort_key, + "lines": [], + } + elif current is not None: + # Skip blank lines at the start + if not line and not current["lines"]: + continue + # Skip stray artifact lines + if line.strip() in ("),", "),"): + continue + current["lines"].append(line) + + if current: + entries.append(current) + + # Trim trailing blank lines from each entry + for entry in entries: + while entry["lines"] and not entry["lines"][-1]: + entry["lines"].pop() + + return entries + + +def render_entry(entry: dict) -> str: + version = html.escape(entry["version"]) + tag = entry["tag"] + date = html.escape(entry["date"]) + anchor = f"v{version}" + + tag_html = "" + if tag and tag not in ("stable",): + tag_html = f' {html.escape(tag)}' + + parts = [ + f'
', + f'

' + f'{version}{tag_html}' + f'

', + ] + + in_list = False + for line in entry["lines"]: + stripped = line.lstrip() + if stripped.startswith("- ") or stripped.startswith("\u2014 "): + # Bullet point (- or em dash) + if not in_list: + parts.append("
    ") + in_list = True + bullet_text = stripped[2:] + parts.append(f"
  • {html.escape(bullet_text)}
  • ") + else: + if in_list: + parts.append("
") + in_list = False + if stripped: + parts.append(f"

{html.escape(stripped)}

") + + if in_list: + parts.append(" ") + + parts.append("
") + return "\n".join(parts) + + +def build_html(entries: list[dict]) -> str: + count = len(entries) + first_date = entries[-1]["full_date"] if entries else "" + latest_version = entries[0]["version"] if entries else "" + + entries_html = "\n\n".join(render_entry(e) for e in entries) + + return f""" + + + + +Version history + + + + + + +
+

Version history

+

{count} releases since {first_date} · latest: {latest_version}

+
+ +
+ + +
+{entries_html} +
+
+ + + + + + +""" + + +def main(): + repo = Path(__file__).resolve().parent.parent.parent + src = repo / "changelog.txt" + if len(sys.argv) > 1: + src = Path(sys.argv[1]) + + out = repo / "docs" / "changelog" / "index.html" + if len(sys.argv) > 2: + out = Path(sys.argv[2]) + + text = src.read_text(encoding="utf-8") + entries = parse_changelog(text) + html_content = build_html(entries) + + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(html_content, encoding="utf-8") + + # Copy favicon files from resources + icons_src = repo / "Telegram" / "Resources" / "art" + for name in ("icon16.png", "icon32.png"): + icon = icons_src / name + if icon.exists(): + shutil.copy2(icon, out.parent / name) + + print(f"Generated {out} ({len(entries)} entries, {out.stat().st_size:,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000000..a94c13542b --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,47 @@ +name: Changelog + +on: + push: + branches: [dev] + paths: [changelog.txt] + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Generate HTML + run: python .github/scripts/generate_changelog.py + + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v4 + with: + path: docs + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 6f9ce84aa5..1425db2b2d 100644 --- a/.gitignore +++ b/.gitignore @@ -68,5 +68,8 @@ settings.local.json # AI work folder (session-specific, not for version control) .ai +# Generated changelog page (built by CI, deployed to GitHub Pages) +/docs/changelog/ + # Project specific Telegram/SourceFiles/_other/packer_private.h diff --git a/AGENTS.md b/AGENTS.md index 78c64f84a7..3a7e4039b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -336,15 +336,25 @@ auto filesTextProducer = tr::lng_files_selected( - Placeholders use `lt_tag_name, value` pattern - For `{count}`: immediate uses `int`, reactive uses `rpl::producer` with `| tr::to_count()` - Move producers with `std::move` when passing to placeholders -- Rich text projectors — pass as the last argument to produce `TextWithEntities` instead of `QString`. These are smart objects with multiple `operator()` overloads: +- Rich text projectors — these `tr::` helpers serve double duty: as the **last argument** (projector) they set the return type to `TextWithEntities`, and as **placeholder values** they wrap individual substitutions in formatting. Always prefer them over `Ui::Text::Bold()`, `Ui::Text::RichLangValue`, etc. — see REVIEW.md for the full mapping. - `tr::marked` — basic projection, converts `QString` to `TextWithEntities` - `tr::rich` — interprets `**bold**`/`__italic__` markup in the string - `tr::bold`, `tr::italic`, `tr::underline` — wrap text in that formatting - `tr::link` — wrap as a clickable link - `tr::url(u"https://..."_q)` — returns a projection that converts text to a link pointing to the given URL; can be passed to `rpl::map` or directly to a `tr::lng_...` call ```cpp + // As last argument (projector): auto title = tr::lng_export_progress_title(tr::now, tr::bold); auto text = tr::lng_proxy_incorrect_secret(tr::now, tr::rich); + // As placeholder value wrapper + projector: + auto desc = tr::lng_some_key( + tr::now, + lt_name, + tr::bold(userName), + lt_group, + tr::bold(groupName), + tr::rich); + // Nested tr::lng as placeholder: auto linked = tr::lng_settings_birthday_contacts( lt_link, tr::lng_settings_birthday_contacts_link(tr::url(link)), diff --git a/CMakeLists.txt b/CMakeLists.txt index bc55b3ca93..506baab549 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,10 @@ include(cmake/validate_special_target.cmake) include(cmake/version.cmake) desktop_app_parse_version(Telegram/build/version) +if (NOT DEFINED CMAKE_CONFIGURATION_TYPES) + set(configuration_types_init 1) +endif() + project(Telegram LANGUAGES C CXX VERSION ${desktop_app_version_cmake} @@ -23,6 +27,12 @@ if (APPLE) enable_language(OBJC OBJCXX) endif() +if (configuration_types_init + AND CMAKE_CONFIGURATION_TYPES + AND NOT MinSizeRel IN_LIST CMAKE_CONFIGURATION_TYPES) + set(CMAKE_CONFIGURATION_TYPES "${CMAKE_CONFIGURATION_TYPES};MinSizeRel" CACHE STRING "" FORCE) +endif() + set_property(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Telegram) get_filename_component(third_party_loc "Telegram/ThirdParty" REALPATH) diff --git a/REVIEW.md b/REVIEW.md index 33345a7cb2..092dcb124f 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -4,7 +4,7 @@ This file contains style and formatting rules that the review subagent must chec ## Empty line before closing brace -Always add an empty line before the closing brace of a class (after all private fields): +Always add an empty line before the closing brace of a **class** (which has one or more sections like `public:` / `private:`). Plain **structs** with just data members do NOT get a trailing empty line — they are compact: `struct Foo { data lines; };`. ```cpp // BAD: @@ -94,6 +94,82 @@ if (const auto peer = session().data().peerLoaded(peerId) if (const auto peer = session().data().peerLoaded(peerId)) { if (const auto user = peer->asUser()) { +## Always initialize variables of basic types + +Never leave variables of basic types (`int`, `float`, `bool`, pointers, etc.) uninitialized. Custom types with constructors are fine — they initialize themselves. But for any basic type, always provide a default value (`= 0`, `= false`, `= nullptr`, etc.). This applies especially to class fields, where uninitialized members are a persistent source of bugs. + +The only exception is performance-critical hot paths where you can prove no read-from-uninitialized-memory occurs. For class fields there is no such exception — always initialize. + +```cpp +// BAD: +int _bulletLeft; +int _bulletTop; +bool _expanded; +SomeType *_pointer; + +// GOOD: +int _bulletLeft = 0; +int _bulletTop = 0; +bool _expanded = false; +SomeType *_pointer = nullptr; +``` + +## Prefer tr:: projections over Ui::Text:: in localization calls + +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. + +| Instead of | Use | +|---|---| +| `Ui::Text::Bold(x)` | `tr::bold(x)` | +| `Ui::Text::Italic(x)` | `tr::italic(x)` | +| `Ui::Text::RichLangValue` | `tr::rich` | +| `Ui::Text::WithEntities` | `tr::marked` | + +```cpp +// BAD - verbose Ui::Text:: functions: +tr::lng_some_key( + tr::now, + lt_name, + Ui::Text::Bold(name), + lt_group, + Ui::Text::Bold(group), + Ui::Text::RichLangValue) + +// GOOD - concise tr:: helpers: +tr::lng_some_key( + tr::now, + lt_name, + tr::bold(name), + lt_group, + tr::bold(group), + tr::rich) +``` + +## 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. + +```cpp +// BAD - pairs of arguments sharing lines: +tr::lng_some_key( + tr::now, + lt_name, tr::bold(name), + lt_group, tr::bold(group), + tr::rich) + +// GOOD - one argument per line: +tr::lng_some_key( + tr::now, + lt_name, + tr::bold(name), + lt_group, + tr::bold(group), + tr::rich) + +// Single-line is fine when everything fits: +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. diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index a9331da489..9a1e866720 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -278,6 +278,8 @@ PRIVATE api/api_chat_participants.h api/api_cloud_password.cpp api/api_cloud_password.h + data/data_search_calendar.h + data/data_search_calendar.cpp api/api_common.cpp api/api_common.h api/api_confirm_phone.cpp @@ -385,6 +387,8 @@ PRIVATE boxes/peers/edit_members_visible.h boxes/peers/edit_participant_box.cpp boxes/peers/edit_participant_box.h + boxes/peers/edit_tag_control.cpp + boxes/peers/edit_tag_control.h boxes/peers/edit_participants_box.cpp boxes/peers/edit_participants_box.h boxes/peers/edit_peer_color_box.cpp @@ -412,6 +416,8 @@ PRIVATE boxes/peers/prepare_short_info_box.h boxes/peers/replace_boost_box.cpp boxes/peers/replace_boost_box.h + boxes/peers/tag_info_box.cpp + boxes/peers/tag_info_box.h boxes/peers/verify_peers_box.cpp boxes/peers/verify_peers_box.h boxes/about_box.cpp @@ -1015,6 +1021,8 @@ PRIVATE history/view/media/history_view_media_spoiler.h history/view/media/history_view_media_unwrapped.cpp history/view/media/history_view_media_unwrapped.h + history/view/media/history_view_no_forwards_request.cpp + history/view/media/history_view_no_forwards_request.h history/view/media/history_view_photo.cpp history/view/media/history_view_photo.h history/view/media/history_view_poll.cpp @@ -1110,6 +1118,8 @@ PRIVATE history/view/history_view_reaction_preview.h history/view/history_view_reply.cpp history/view/history_view_reply.h + history/view/history_view_reply_button.cpp + history/view/history_view_reply_button.h history/view/history_view_requests_bar.cpp history/view/history_view_requests_bar.h history/view/history_view_schedule_box.cpp @@ -1871,6 +1881,8 @@ 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 @@ -1969,10 +1981,23 @@ PRIVATE settings.cpp settings.h stdafx.h + tray_accounts_menu.h tray.cpp tray.h ) +if (APPLE) + nice_target_sources(Telegram ${src_loc} + PRIVATE + tray_accounts_menu.cpp + ) +else() + nice_target_sources(Telegram ${src_loc} + PRIVATE + tray_accounts_menu_dummy.cpp + ) +endif() + if (NOT build_winstore) remove_target_sources(Telegram ${src_loc} platform/win/windows_start_task.cpp @@ -2396,12 +2421,19 @@ if (LINUX AND DESKTOP_APP_USE_PACKAGED) generate_appstream_changelog(Telegram "${CMAKE_SOURCE_DIR}/changelog.txt" "${CMAKE_CURRENT_BINARY_DIR}/com.ayugram.desktop.metainfo.xml") install(TARGETS Telegram RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" BUNDLE DESTINATION "${CMAKE_INSTALL_BINDIR}") install(FILES "Resources/art/icon16.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/16x16/apps" RENAME "com.ayugram.desktop.png") - install(FILES "Resources/art/icon32.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/32x32/apps" RENAME "com.ayugram.desktop.png") - install(FILES "Resources/art/icon48.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/48x48/apps" RENAME "com.ayugram.desktop.png") + install(FILES "Resources/art/icon16@2x.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/16x16@2/apps" RENAME "org.telegram.desktop.png") + install(FILES "Resources/art/icon32.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/32x32/apps" RENAME "org.telegram.desktop.png") + install(FILES "Resources/art/icon32@2x.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/32x32@2/apps" RENAME "com.ayugram.desktop.png") + install(FILES "Resources/art/icon48.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/48x48/apps" RENAME "org.telegram.desktop.png") + install(FILES "Resources/art/icon48@2x.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/48x48@2/apps" RENAME "org.telegram.desktop.png") install(FILES "Resources/art/icon64.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/64x64/apps" RENAME "com.ayugram.desktop.png") - install(FILES "Resources/art/icon128.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/128x128/apps" RENAME "com.ayugram.desktop.png") + install(FILES "Resources/art/icon64@2x.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/64x64@2/apps" RENAME "com.ayugram.desktop.png") + install(FILES "Resources/art/icon128.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/128x128/apps" RENAME "org.telegram.desktop.png") + install(FILES "Resources/art/icon128@2x.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/128x128@2/apps" RENAME "org.telegram.desktop.png") install(FILES "Resources/art/icon256.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/256x256/apps" RENAME "com.ayugram.desktop.png") - install(FILES "Resources/art/icon512.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/512x512/apps" RENAME "com.ayugram.desktop.png") + install(FILES "Resources/art/icon256@2x.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/256x256@2/apps" RENAME "com.ayugram.desktop.png") + install(FILES "Resources/art/icon512.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/512x512/apps" RENAME "org.telegram.desktop.png") + install(FILES "Resources/art/icon512@2x.png" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/512x512@2/apps" RENAME "com.ayugram.desktop.png") install(FILES "Resources/icons/tray_monochrome.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/symbolic/apps" RENAME "com.ayugram.desktop-symbolic.svg") install(FILES "Resources/icons/tray_monochrome_attention.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/symbolic/apps" RENAME "com.ayugram.desktop-attention-symbolic.svg") install(FILES "Resources/icons/tray_monochrome_mute.svg" DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/symbolic/apps" RENAME "com.ayugram.desktop-mute-symbolic.svg") diff --git a/Telegram/Resources/animations/stop.tgs b/Telegram/Resources/animations/stop.tgs new file mode 100644 index 0000000000..70ace96393 Binary files /dev/null and b/Telegram/Resources/animations/stop.tgs differ diff --git a/Telegram/Resources/icons/chat/large_user_tag.svg b/Telegram/Resources/icons/chat/large_user_tag.svg new file mode 100644 index 0000000000..ed97783eba --- /dev/null +++ b/Telegram/Resources/icons/chat/large_user_tag.svg @@ -0,0 +1,7 @@ + + + Other / Large / large_user_tag + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/menu/download_off.svg b/Telegram/Resources/icons/menu/download_off.svg new file mode 100644 index 0000000000..bac54d5525 --- /dev/null +++ b/Telegram/Resources/icons/menu/download_off.svg @@ -0,0 +1,7 @@ + + + General / menu_download_off + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/menu/share_off.svg b/Telegram/Resources/icons/menu/share_off.svg new file mode 100644 index 0000000000..96d32e3bda --- /dev/null +++ b/Telegram/Resources/icons/menu/share_off.svg @@ -0,0 +1,9 @@ + + + General / menu_share_off + + + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/menu/share_on.svg b/Telegram/Resources/icons/menu/share_on.svg new file mode 100644 index 0000000000..bb8edfe992 --- /dev/null +++ b/Telegram/Resources/icons/menu/share_on.svg @@ -0,0 +1,7 @@ + + + General / menu_share_on + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/menu/tag_add.svg b/Telegram/Resources/icons/menu/tag_add.svg new file mode 100644 index 0000000000..8ad0a1cb90 --- /dev/null +++ b/Telegram/Resources/icons/menu/tag_add.svg @@ -0,0 +1,7 @@ + + + Icon / Menu / menu_tag_add + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/menu/tag_edit.svg b/Telegram/Resources/icons/menu/tag_edit.svg new file mode 100644 index 0000000000..fec18ee156 --- /dev/null +++ b/Telegram/Resources/icons/menu/tag_edit.svg @@ -0,0 +1,7 @@ + + + Icon / Menu / menu_tag_edit + + + + \ No newline at end of file diff --git a/Telegram/Resources/icons/mini_replace.svg b/Telegram/Resources/icons/mini_replace.svg new file mode 100644 index 0000000000..86d68953e6 --- /dev/null +++ b/Telegram/Resources/icons/mini_replace.svg @@ -0,0 +1,7 @@ + + + Mini / mini_replace2 + + + + \ No newline at end of file diff --git a/Telegram/Resources/iv_html/page.js b/Telegram/Resources/iv_html/page.js index 9bd67163a9..9d5ab683ef 100644 --- a/Telegram/Resources/iv_html/page.js +++ b/Telegram/Resources/iv_html/page.js @@ -159,13 +159,13 @@ var IV = { return false; }, initPreBlocks: function() { - if (!hljs) { + if (!window.hljs) { return; } var pres = document.getElementsByTagName('pre'); for (var i = 0; i < pres.length; i++) { if (pres[i].hasAttribute('data-language')) { - hljs.highlightBlock(pres[i]); + window.hljs.highlightBlock(pres[i]); } } }, diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index a7d54367aa..8496ef20c5 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -313,6 +313,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_error_noforwards_channel" = "Sorry, forwarding from this channel is disabled by admins."; "lng_error_nocopy_group" = "Sorry, copying from this group is disabled by admins."; "lng_error_nocopy_channel" = "Sorry, copying from this channel is disabled by admins."; +"lng_error_noforwards_user" = "Sorry, forwarding from this chat is restricted."; +"lng_error_nocopy_user" = "Sorry, copying from this chat is restricted."; "lng_error_nocopy_story" = "Sorry, the creator of this story disabled copying."; "lng_error_schedule_limit" = "Sorry, you can't schedule more than 100 messages."; "lng_sure_add_admin_invite" = "This user is not a member of this group. Add them to the group and promote them to admin?"; @@ -456,6 +458,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_contacts_loading" = "Loading..."; "lng_contacts_not_found" = "No contacts found"; "lng_topics_not_found" = "No topics found."; +"lng_forum_all_messages" = "All Messages"; +"lng_forum_create_new_topic" = "Create New Thread"; "lng_dlg_search_for_messages" = "Search for messages"; "lng_update_telegram" = "Update Telegram"; "lng_dlg_search_in" = "Search messages in"; @@ -666,6 +670,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_settings_send_cmdenter" = "Send with Cmd+Enter"; "lng_settings_chat_quick_action_reply" = "Reply with double click"; "lng_settings_chat_quick_action_react" = "Send reaction with double click"; +"lng_settings_chat_corner_reply" = "Reply button on messages"; "lng_settings_chat_corner_reaction" = "Reaction button on messages"; "lng_settings_shortcuts" = "Keyboard shortcuts"; @@ -1655,6 +1660,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_leave_next_owner_box_title_group" = "Leave Group?"; "lng_leave_next_owner_box_about" = "If you leave, **{user}** will become the new owner of **{chat}** in **1 week**."; "lng_leave_next_owner_box_about_admin" = "If you leave, **{user}** will become the new admin of **{chat}** in **1 week**."; +"lng_leave_next_owner_box_about_legacy" = "If you leave, **{user}** will immediately become the new owner of **{chat}**."; +"lng_leave_next_owner_box_about_admin_legacy" = "If you leave, **{user}** will immediately become the new admin of **{chat}**."; "lng_select_next_owner_box" = "Appoint Another Owner"; "lng_select_next_owner_box_admin" = "Appoint Another Admin"; "lng_select_next_owner_box_title" = "Appoint New Owner"; @@ -1892,6 +1899,35 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_manage_peer_no_forwards_about" = "Members won't be able to copy, save or forward content from this group."; "lng_manage_peer_no_forwards_about_channel" = "Subscribers won't be able to copy, save or forward content from this channel."; +"lng_disable_sharing" = "Disable Sharing"; +"lng_enable_sharing" = "Enable Sharing"; +"lng_disable_sharing_title" = "Disable Sharing"; +"lng_disable_sharing_no_forwarding" = "No Forwarding"; +"lng_disable_sharing_no_forwarding_about" = "Disable message forwarding to other chats."; +"lng_disable_sharing_no_saving" = "No Saving"; +"lng_disable_sharing_no_saving_about" = "Disable copying texts and saving photos and videos to gallery."; +"lng_disable_sharing_unlock" = "Unlock with Telegram Premium"; +"lng_disable_sharing_button" = "Disable Sharing"; +"lng_disable_sharing_toast" = "Sharing disabled for this chat."; +"lng_enable_sharing_toast" = "Sharing enabled for this chat."; +"lng_enable_sharing_request_title" = "Enable Sharing"; +"lng_enable_sharing_request_text" = "You need **{name}'s** approval to enable sharing. Send a request?"; +"lng_enable_sharing_request_button" = "Send Request"; + +"lng_action_no_forwards_you_disabled" = "You disabled sharing in this chat"; +"lng_action_no_forwards_you_enabled" = "You enabled sharing in this chat"; +"lng_action_no_forwards_disabled" = "{from} disabled sharing in this chat"; +"lng_action_no_forwards_enabled" = "{from} enabled sharing in this chat"; +"lng_action_no_forwards_still_disabled" = "Sharing in this chat is still disabled"; +"lng_action_no_forwards_request" = "{from} would like to enable sharing in this chat, which includes:"; +"lng_action_no_forwards_request_you" = "You requested to enable sharing in this chat, which includes:"; +"lng_action_no_forwards_feature_forwarding" = "Forwarding messages"; +"lng_action_no_forwards_feature_saving" = "Saving photos and videos"; +"lng_action_no_forwards_feature_copying" = "Copying messages"; +"lng_action_no_forwards_accept" = "Accept"; +"lng_action_no_forwards_reject" = "Reject"; +"lng_action_no_forwards_request_expired" = "Sharing enable request has expired"; + "lng_manage_peer_bot_public_link" = "Public Link"; "lng_manage_peer_bot_public_links" = "Public Links"; "lng_manage_peer_bot_balance" = "Balance"; @@ -2386,7 +2422,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_action_gift_premium_about" = "Subscription for exclusive Telegram features."; "lng_action_gift_refunded" = "This gift was downgraded because a request to refund the payment related to this gift was made, and the money was returned."; "lng_action_gift_got_ton" = "Use TON to suggest posts to channels."; -"lng_action_gift_offer" = "{user} offered you {cost} for {name}."; +"lng_action_gift_offer_incoming" = "An offer to buy this gift for {amount}."; "lng_action_gift_offer_you" = "You offered {cost} for {name}."; "lng_action_gift_offer_state_expires" = "This offer expires in {time}."; "lng_action_gift_offer_time_large" = "{hours} h"; @@ -2907,6 +2943,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_premium_summary_about_peer_colors" = "Choose a color and logo for your profile and replies to your messages."; "lng_premium_summary_subtitle_gifts" = "Telegram Gifts"; "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_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"; @@ -4170,6 +4208,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_gift_craft_another_button" = "Craft Another Gift"; "lng_gift_craft_unavailable" = "Crafting unavailable"; "lng_gift_craft_when" = "This gift will become available for crafting on {date} at {time}."; +"lng_gift_craft_address_error" = "This gift has been exported to the blockchain and can't be used as the primary gift for crafting."; "lng_auction_about_title" = "Auction"; "lng_auction_about_subtitle" = "Join the battle for exclusive gifts."; @@ -4699,6 +4738,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_url_auth_phone_toast_bad_title" = "Login failed"; "lng_url_auth_phone_toast_bad" = "Please try logging in to {domain} again."; "lng_url_auth_phone_toast_bad_expired" = "This link is expired."; +"lng_url_auth_match_code_title" = "Tap the emoji shown on your other device"; +"lng_url_auth_match_code_info" = "Login request from {domain}"; "lng_bot_start" = "Start"; "lng_bot_choose_group" = "Select a Group"; @@ -4864,6 +4905,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_context_edit_permissions" = "Edit admin rights"; "lng_context_restrict_user" = "Restrict user"; "lng_context_ban_user" = "Ban"; +"lng_context_delete_and_ban" = "Delete and ban"; "lng_context_remove_from_group" = "Remove from group"; "lng_context_add_to_group" = "Add to group"; @@ -4942,6 +4984,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_context_seen_watched_none" = "Nobody Listened"; "lng_context_seen_reacted#one" = "{count} Reacted"; "lng_context_seen_reacted#other" = "{count} Reacted"; +"lng_context_seen_reactions_count#one" = "{count} Reaction"; +"lng_context_seen_reactions_count#other" = "{count} Reactions"; "lng_context_seen_reacted_none" = "Nobody Reacted"; "lng_context_seen_reacted_all" = "Show All Reactions"; "lng_context_sent_by" = "Sent by {user}"; @@ -4989,6 +5033,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_context_animated_emoji_many#one" = "This message contains emoji from **{count} pack**."; "lng_context_animated_emoji_many#other" = "This message contains emoji from **{count} packs**."; "lng_context_animated_reaction" = "This reaction is from the **{name}** pack."; +"lng_context_animated_emoji_preview" = "This emoji is from the **{name}** pack."; "lng_context_animated_tag" = "This tag is from **{name} pack**."; "lng_context_animated_reactions" = "Reactions contain emoji from **{name} pack**."; "lng_context_animated_reactions_many#one" = "Reactions contain emoji from **{count} pack**."; @@ -4997,6 +5042,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_context_noforwards_info_channel" = "Copying and forwarding is not allowed in this channel."; "lng_context_noforwards_info_group" = "Copying and forwarding is not allowed in this group."; "lng_context_noforwards_info_bot" = "Copying and forwarding is not allowed from this bot."; +"lng_context_noforwards_info_his" = "{user} disabled copying and forwarding in this chat."; +"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"; @@ -5487,9 +5534,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_menu_formatting_blockquote" = "Quote"; "lng_menu_formatting_monospace" = "Monospace"; "lng_menu_formatting_spoiler" = "Spoiler"; +"lng_menu_formatting_date" = "Date"; "lng_menu_formatting_link_create" = "Create link"; "lng_menu_formatting_link_edit" = "Edit link"; "lng_menu_formatting_clear" = "Clear formatting"; +"lng_formatting_date_title" = "Choose date and time"; "lng_formatting_link_create_title" = "Create link"; "lng_formatting_link_edit_title" = "Edit link"; "lng_formatting_link_text" = "Text"; @@ -5501,6 +5550,40 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_text_copied" = "Text copied to clipboard."; "lng_code_copied" = "Code copied to clipboard."; +"lng_date_copied" = "Date copied to clipboard."; + +"lng_date_relative_now" = "now"; + +"lng_date_relative_seconds_ago#one" = "{count} second ago"; +"lng_date_relative_seconds_ago#other" = "{count} seconds ago"; +"lng_date_relative_minutes_ago#one" = "{count} minute ago"; +"lng_date_relative_minutes_ago#other" = "{count} minutes ago"; +"lng_date_relative_hours_ago#one" = "{count} hour ago"; +"lng_date_relative_hours_ago#other" = "{count} hours ago"; +"lng_date_relative_days_ago#one" = "{count} day ago"; +"lng_date_relative_days_ago#other" = "{count} days ago"; +"lng_date_relative_months_ago#one" = "{count} month ago"; +"lng_date_relative_months_ago#other" = "{count} months ago"; +"lng_date_relative_years_ago#one" = "{count} year ago"; +"lng_date_relative_years_ago#other" = "{count} years ago"; + +"lng_date_relative_in_seconds#one" = "in {count} second"; +"lng_date_relative_in_seconds#other" = "in {count} seconds"; +"lng_date_relative_in_minutes#one" = "in {count} minute"; +"lng_date_relative_in_minutes#other" = "in {count} minutes"; +"lng_date_relative_in_hours#one" = "in {count} hour"; +"lng_date_relative_in_hours#other" = "in {count} hours"; +"lng_date_relative_in_days#one" = "in {count} day"; +"lng_date_relative_in_days#other" = "in {count} days"; +"lng_date_relative_in_months#one" = "in {count} month"; +"lng_date_relative_in_months#other" = "in {count} months"; +"lng_date_relative_in_years#one" = "in {count} year"; +"lng_date_relative_in_years#other" = "in {count} years"; + +"lng_context_copy_date" = "Copy Date"; +"lng_context_add_to_calendar" = "Add to Calendar"; +"lng_context_set_reminder" = "Set a Reminder"; +"lng_reminder_scheduled_in" = "Reminder scheduled in {link}."; "lng_spellchecker_submenu" = "Spelling"; "lng_spellchecker_add" = "Add to Dictionary"; @@ -5959,7 +6042,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_confcall_e2e_about" = "These four emoji represent the call's encryption key. They must match for all participants and change when someone joins or leaves."; "lng_no_mic_permission" = "Telegram needs microphone access so that you can make calls and record voice messages."; - "lng_player_message_today" = "today at {time}"; "lng_player_message_yesterday" = "yesterday at {time}"; "lng_player_message_date" = "{date} at {time}"; @@ -5975,6 +6057,30 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_rights_edit_admin_header" = "What can this admin do?"; "lng_rights_edit_admin_rank_name" = "Custom title"; "lng_rights_edit_admin_rank_about" = "A title that members will see instead of '{title}'."; +"lng_context_add_my_tag" = "Add tag"; +"lng_context_edit_my_tag" = "Edit tag"; +"lng_context_add_member_tag" = "Add member tag"; +"lng_context_edit_member_tag" = "Edit member tag"; +"lng_rights_edit_tag_title" = "Edit tag"; +"lng_rights_tag_about" = "Add short tag next to {name}'s name."; +"lng_rights_tag_about_self" = "Share your role, title, or how you're known in this group. Your tag is visible to all members."; + +"lng_tag_info_title_user" = "Member Tags"; +"lng_tag_info_title_admin" = "Admin Tags"; +"lng_tag_info_title_owner" = "Owner Tags"; +"lng_tag_info_text_user" = "This grey tag {emoji} is {author}'s member tag in {group}."; +"lng_tag_info_text_admin" = "This green tag {emoji} is {author}'s admin tag in {group}."; +"lng_tag_info_text_owner" = "This purple tag {emoji} is {author}'s owner tag in {group}."; +"lng_tag_info_preview_member" = "Member Tag"; +"lng_tag_info_preview_admin" = "Admin Tag"; +"lng_tag_info_preview_owner" = "Owner Tag"; +"lng_tag_info_add_my_tag" = "Add My Tag"; +"lng_tag_info_edit_my_tag" = "Edit My Tag"; +"lng_tag_info_admins_only" = "Only admins can change tags in this group."; + +"lng_rights_promote_member" = "Promote to Admin"; +"lng_rights_remove_member" = "Remove from Group"; +"lng_rights_dismiss_admin" = "Dismiss Admin"; "lng_rights_about_add_admins_yes" = "This admin will be able to add new admins with equal or fewer rights."; "lng_rights_about_add_admins_no" = "This admin will not be able to add new admins."; "lng_rights_about_by" = "This admin promoted by {user} on {date}."; @@ -6067,9 +6173,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_rights_group_pin" = "Pin messages"; "lng_rights_group_topics" = "Manage topics"; "lng_rights_group_add_topics" = "Create topics"; +"lng_rights_group_edit_rank" = "Edit own tags"; +"lng_rights_group_edit_rank_single" = "Edit own tag"; "lng_rights_group_manage_calls" = "Manage video chats"; "lng_rights_group_delete" = "Delete messages"; "lng_rights_group_anonymous" = "Remain anonymous"; +"lng_rights_group_manage_ranks" = "Edit member tags"; "lng_rights_add_admins" = "Add new admins"; "lng_rights_chat_send_text" = "Send messages"; "lng_rights_chat_send_media" = "Send media"; @@ -6202,6 +6311,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_admin_log_filter_subscribers_removed" = "Subscribers leaving"; "lng_admin_log_filter_topics" = "Topics"; "lng_admin_log_filter_sub_extend" = "Subscription Renewals"; +"lng_admin_log_filter_edit_rank" = "Tag Changes"; "lng_admin_log_filter_all_admins" = "All users and admins"; "lng_admin_log_filter_actions_admins_subtitle" = "Filter actions by admins"; "lng_admin_log_filter_actions_admins_section" = "Show Actions by All Admins"; @@ -6380,14 +6490,24 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_admin_log_admin_invite_users" = "Add users"; "lng_admin_log_admin_invite_link" = "Invite users via link"; "lng_admin_log_admin_pin_messages" = "Pin messages"; +"lng_admin_log_banned_edit_rank" = "Edit own tags"; +"lng_admin_log_banned_edit_rank_single" = "Edit own tag"; "lng_admin_log_admin_manage_topics" = "Manage topics"; "lng_admin_log_admin_create_topics" = "Create topics"; "lng_admin_log_admin_manage_calls" = "Manage video chats"; "lng_admin_log_admin_manage_calls_channel" = "Manage live streams"; "lng_admin_log_admin_manage_direct" = "Manage direct messages"; +"lng_admin_log_admin_manage_ranks" = "Edit member tags"; "lng_admin_log_admin_add_admins" = "Add new admins"; "lng_admin_log_subscription_extend" = "{name} renewed subscription until {date}"; +"lng_admin_log_changed_rank_from" = "{from} changed tag for {user} from \"{previous}\" to \"{tag}\""; +"lng_admin_log_set_rank" = "{from} set tag for {user} to \"{tag}\""; +"lng_admin_log_removed_rank" = "{from} removed tag for {user} (was \"{previous}\")"; +"lng_admin_log_changed_own_rank_from" = "{from} changed own tag from \"{previous}\" to \"{tag}\""; +"lng_admin_log_set_own_rank" = "{from} set own tag to \"{tag}\""; +"lng_admin_log_removed_own_rank" = "{from} removed own tag (was \"{previous}\")"; + "lng_admin_log_antispam_menu_report" = "Report False Positive"; "lng_admin_log_antispam_menu_report_toast" = "You can manage anti-spam settings in {link}."; "lng_admin_log_antispam_menu_report_toast_link" = "Group Info > Administrators"; @@ -6715,6 +6835,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_polls_show_more#one" = "Show more ({count})"; "lng_polls_show_more#other" = "Show more ({count})"; "lng_polls_votes_collapse" = "Collapse"; +"lng_polls_vote_yesterday" = "yesterday"; "lng_todo_title" = "Checklist"; "lng_todo_title_group" = "Group Checklist"; @@ -7528,6 +7649,11 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_summarize_header_title" = "AI summary"; "lng_summarize_header_about" = "Click to see original text"; +"lng_calendar" = "Calendar"; + +"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."; + // Wnd specific "lng_wnd_choose_program_menu" = "Choose Default Program..."; @@ -7594,6 +7720,20 @@ 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 1ab7e76e83..989baedd1a 100644 --- a/Telegram/Resources/qrc/telegram/animations.qrc +++ b/Telegram/Resources/qrc/telegram/animations.qrc @@ -56,6 +56,7 @@ ../../animations/ban.tgs ../../animations/cocoon.tgs ../../animations/craft_failed.tgs + ../../animations/stop.tgs ../../animations/profile/profile_muting.tgs ../../animations/profile/profile_unmuting.tgs diff --git a/Telegram/Resources/uwp/AppX/AppxManifest.xml b/Telegram/Resources/uwp/AppX/AppxManifest.xml index 3623e370ee..c70b27b637 100644 --- a/Telegram/Resources/uwp/AppX/AppxManifest.xml +++ b/Telegram/Resources/uwp/AppX/AppxManifest.xml @@ -10,7 +10,7 @@ + Version="6.6.2.0" /> Telegram Desktop Telegram Messenger LLP diff --git a/Telegram/Resources/winrc/Telegram.rc b/Telegram/Resources/winrc/Telegram.rc index db9d1e7072..e196337b03 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,5,1,0 - PRODUCTVERSION 6,5,1,0 + FILEVERSION 6,6,2,0 + PRODUCTVERSION 6,6,2,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -62,10 +62,10 @@ BEGIN BEGIN VALUE "CompanyName", "Radolyn Labs" VALUE "FileDescription", "AyuGram Desktop" - VALUE "FileVersion", "6.5.1.0" + VALUE "FileVersion", "6.6.2.0" VALUE "LegalCopyright", "Copyright (C) 2014-2026" VALUE "ProductName", "AyuGram Desktop" - VALUE "ProductVersion", "6.5.1.0" + VALUE "ProductVersion", "6.6.2.0" END END BLOCK "VarFileInfo" diff --git a/Telegram/Resources/winrc/Updater.rc b/Telegram/Resources/winrc/Updater.rc index cf1ab333a0..d41b02a565 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,5,1,0 - PRODUCTVERSION 6,5,1,0 + FILEVERSION 6,6,2,0 + PRODUCTVERSION 6,6,2,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS 0x1L @@ -53,10 +53,10 @@ BEGIN BEGIN VALUE "CompanyName", "Radolyn Labs" VALUE "FileDescription", "AyuGram Desktop Updater" - VALUE "FileVersion", "6.5.1.0" + VALUE "FileVersion", "6.6.2.0" VALUE "LegalCopyright", "Copyright (C) 2014-2026" VALUE "ProductName", "AyuGram Desktop" - VALUE "ProductVersion", "6.5.1.0" + VALUE "ProductVersion", "6.6.2.0" END END BLOCK "VarFileInfo" diff --git a/Telegram/SourceFiles/api/api_chat_participants.cpp b/Telegram/SourceFiles/api/api_chat_participants.cpp index 495a4cbfe2..8900ca8dba 100644 --- a/Telegram/SourceFiles/api/api_chat_participants.cpp +++ b/Telegram/SourceFiles/api/api_chat_participants.cpp @@ -51,40 +51,47 @@ std::vector ParseList( void ApplyMegagroupAdmins(not_null channel, Members list) { Expects(channel->isMegagroup()); - const auto i = ranges::find_if(list, &Api::ChatParticipant::isCreator); - if (i != list.end()) { - i->tryApplyCreatorTo(channel); - } else { - channel->mgInfo->creator = nullptr; - channel->mgInfo->creatorRank = QString(); - } + const auto creatorIt = ranges::find_if( + list, + &Api::ChatParticipant::isCreator); - auto adding = base::flat_map(); + auto adding = base::flat_set(); + auto addingRanks = base::flat_map(); for (const auto &p : list) { if (p.isUser()) { - adding.emplace(p.userId(), p.rank()); + adding.emplace(p.userId()); + if (!p.rank().isEmpty()) { + addingRanks.emplace(p.userId(), p.rank()); + } } } - if (channel->mgInfo->creator) { - adding.emplace( - peerToUser(channel->mgInfo->creator->id), - channel->mgInfo->creatorRank); + if (creatorIt != list.end() && creatorIt->isUser()) { + adding.emplace(creatorIt->userId()); + if (!creatorIt->rank().isEmpty()) { + addingRanks.emplace(creatorIt->userId(), creatorIt->rank()); + } } auto removing = channel->mgInfo->admins; if (removing.empty() && adding.empty()) { - // Add some admin-placeholder so we don't DDOS - // server with admins list requests. LOG(("API Error: Got empty admins list from server.")); - adding.emplace(0, QString()); + adding.emplace(UserId(0)); } Data::ChannelAdminChanges changes(channel); - for (const auto &[addingId, rank] : adding) { - if (!removing.remove(addingId)) { - changes.add(addingId, rank); - } + if (creatorIt != list.end()) { + creatorIt->tryApplyCreatorTo(channel); + } else { + channel->mgInfo->creator = nullptr; } - for (const auto &[removingId, rank] : removing) { + for (const auto &addingId : adding) { + const auto r = addingRanks.find(addingId); + const auto rank = (r != end(addingRanks)) + ? r->second + : QString(); + removing.remove(addingId); + changes.add(addingId, rank); + } + for (const auto &removingId : removing) { changes.remove(removingId); } } @@ -112,6 +119,7 @@ void ApplyLastList( channel->mgInfo->lastAdmins.clear(); channel->mgInfo->lastRestricted.clear(); channel->mgInfo->lastParticipants.clear(); + channel->mgInfo->memberRanks.clear(); channel->mgInfo->lastParticipantsStatus = MegagroupInfo::LastParticipantsUpToDate | MegagroupInfo::LastParticipantsOnceReceived; @@ -148,6 +156,9 @@ void ApplyLastList( channel->mgInfo->botStatus = 2; } } + if (!p.rank().isEmpty()) { + channel->mgInfo->memberRanks[p.userId()] = p.rank(); + } } } // @@ -287,12 +298,14 @@ ChatParticipant::ChatParticipant( _type = Type::Member; _date = data.vdate().v; _by = peerToUser(peerFromUser(data.vinviter_id())); + _rank = qs(data.vrank().value_or_empty()); if (data.vsubscription_until_date()) { _subscriptionDate = data.vsubscription_until_date()->v; } }, [&](const MTPDchannelParticipant &data) { _type = Type::Member; _date = data.vdate().v; + _rank = qs(data.vrank().value_or_empty()); if (data.vsubscription_until_date()) { _subscriptionDate = data.vsubscription_until_date()->v; } @@ -300,6 +313,7 @@ ChatParticipant::ChatParticipant( _restrictions = ChatRestrictionsInfo(data.vbanned_rights()); _by = peerToUser(peerFromUser(data.vkicked_by())); _date = data.vdate().v; + _rank = qs(data.vrank().value_or_empty()); _type = (_restrictions.flags & ChatRestriction::ViewMessages) ? Type::Banned @@ -331,7 +345,11 @@ void ChatParticipant::tryApplyCreatorTo( if (isCreator() && isUser()) { if (const auto info = channel->mgInfo.get()) { info->creator = channel->owner().userLoaded(userId()); - info->creatorRank = rank(); + if (!rank().isEmpty()) { + info->memberRanks[userId()] = rank(); + } else { + info->memberRanks.remove(userId()); + } } } } @@ -651,7 +669,7 @@ void ChatParticipants::Restrict( ChatRestrictionsInfo oldRights, ChatRestrictionsInfo newRights, Fn onDone, - Fn onFail) { + Fn onFail) { channel->session().api().request(MTPchannels_EditBanned( channel->inputChannel(), participant->input(), @@ -662,9 +680,9 @@ void ChatParticipants::Restrict( if (onDone) { onDone(); } - }).fail([=] { + }).fail([=](const MTP::Error &error) { if (onFail) { - onFail(); + onFail(error.type()); } }).send(); } diff --git a/Telegram/SourceFiles/api/api_chat_participants.h b/Telegram/SourceFiles/api/api_chat_participants.h index 4f073eb8e6..4fedab7a75 100644 --- a/Telegram/SourceFiles/api/api_chat_participants.h +++ b/Telegram/SourceFiles/api/api_chat_participants.h @@ -113,7 +113,7 @@ public: ChatRestrictionsInfo oldRights, ChatRestrictionsInfo newRights, Fn onDone, - Fn onFail); + Fn onFail); void add( std::shared_ptr show, not_null peer, diff --git a/Telegram/SourceFiles/api/api_suggest_post.cpp b/Telegram/SourceFiles/api/api_suggest_post.cpp index 7c5a31b314..073d9dd9cc 100644 --- a/Telegram/SourceFiles/api/api_suggest_post.cpp +++ b/Telegram/SourceFiles/api/api_suggest_post.cpp @@ -520,6 +520,40 @@ void ConfirmGiftSaleDecline( ShowGiftSaleRejectBox(window, item, suggestion); } +void RespondToNoForwardsRequest( + not_null controller, + not_null item, + not_null request, + bool accept) { + if (request->requestId) { + return; + } + const auto id = item->fullId(); + const auto session = &item->history()->session(); + const auto peer = item->history()->peer; + const auto msgId = item->id; + const auto finish = [=] { + if (const auto item = session->data().message(id)) { + if (const auto r = item->Get()) { + r->requestId = 0; + } + } + }; + using Flag = MTPmessages_ToggleNoForwards::Flag; + request->requestId = session->api().request(MTPmessages_ToggleNoForwards( + MTP_flags(Flag::f_request_msg_id), + peer->input(), + MTP_bool(!accept), + MTP_int(msgId) + )).done([=](const MTPUpdates &result) { + session->api().applyUpdates(result); + finish(); + }).fail([=](const MTP::Error &error) { + controller->showToast(error.type()); + finish(); + }).send(); +} + } // namespace std::shared_ptr AcceptClickHandler( @@ -538,7 +572,11 @@ std::shared_ptr AcceptClickHandler( } const auto show = controller->uiShow(); const auto suggestion = item->Get(); - if (!suggestion) { + const auto nfRequest = item->Get(); + if (!suggestion && !nfRequest) { + return; + } else if (nfRequest) { + RespondToNoForwardsRequest(controller, item, nfRequest, true); return; } else if (suggestion->gift) { ConfirmGiftSaleAccept(controller, item, suggestion); @@ -564,6 +602,11 @@ std::shared_ptr DeclineClickHandler( if (!item) { return; } + const auto nfRequest = item->Get(); + if (nfRequest) { + RespondToNoForwardsRequest(controller, item, nfRequest, false); + return; + } const auto suggestion = item->Get(); if (suggestion && suggestion->gift) { ConfirmGiftSaleDecline(controller, item, suggestion); diff --git a/Telegram/SourceFiles/api/api_text_entities.cpp b/Telegram/SourceFiles/api/api_text_entities.cpp index f3d6db8b40..8db97edf0c 100644 --- a/Telegram/SourceFiles/api/api_text_entities.cpp +++ b/Telegram/SourceFiles/api/api_text_entities.cpp @@ -238,6 +238,32 @@ EntitiesInText EntitiesFromMTP( d.vlength().v, d.is_collapsed() ? u"1"_q : QString(), }); + }, [&](const MTPDmessageEntityFormattedDate &d) { + auto flags = FormattedDateFlags(); + if (d.is_relative()) { + flags |= FormattedDateFlag::Relative; + } + if (d.is_short_time()) { + flags |= FormattedDateFlag::ShortTime; + } + if (d.is_long_time()) { + flags |= FormattedDateFlag::LongTime; + } + if (d.is_short_date()) { + flags |= FormattedDateFlag::ShortDate; + } + if (d.is_long_date()) { + flags |= FormattedDateFlag::LongDate; + } + if (d.is_day_of_week()) { + flags |= FormattedDateFlag::DayOfWeek; + } + result.push_back({ + EntityType::FormattedDate, + d.voffset().v, + d.vlength().v, + SerializeFormattedDateData(d.vdate().v, flags), + }); }); } return result; @@ -265,7 +291,8 @@ MTPVector EntitiesToMTP( && entity.type() != EntityType::Spoiler && entity.type() != EntityType::MentionName && entity.type() != EntityType::CustomUrl - && entity.type() != EntityType::CustomEmoji) { + && entity.type() != EntityType::CustomEmoji + && entity.type() != EntityType::FormattedDate) { continue; } @@ -358,6 +385,37 @@ MTPVector EntitiesToMTP( v.push_back(*valid); } } break; + case EntityType::FormattedDate: { + const auto [date, dateFlags] = DeserializeFormattedDateData( + entity.data()); + if (date) { + using Flag = MTPDmessageEntityFormattedDate::Flag; + auto mtpFlags = MTPDmessageEntityFormattedDate::Flags(); + if (dateFlags & FormattedDateFlag::Relative) { + mtpFlags |= Flag::f_relative; + } + if (dateFlags & FormattedDateFlag::ShortTime) { + mtpFlags |= Flag::f_short_time; + } + if (dateFlags & FormattedDateFlag::LongTime) { + mtpFlags |= Flag::f_long_time; + } + if (dateFlags & FormattedDateFlag::ShortDate) { + mtpFlags |= Flag::f_short_date; + } + if (dateFlags & FormattedDateFlag::LongDate) { + mtpFlags |= Flag::f_long_date; + } + if (dateFlags & FormattedDateFlag::DayOfWeek) { + mtpFlags |= Flag::f_day_of_week; + } + v.push_back(MTP_messageEntityFormattedDate( + MTP_flags(mtpFlags), + offset, + length, + MTP_int(date))); + } + } break; } } return MTP_vector(std::move(v)); diff --git a/Telegram/SourceFiles/api/api_updates.cpp b/Telegram/SourceFiles/api/api_updates.cpp index db90cbd9a0..8e475141e9 100644 --- a/Telegram/SourceFiles/api/api_updates.cpp +++ b/Telegram/SourceFiles/api/api_updates.cpp @@ -1128,6 +1128,7 @@ void Updates::applyUpdatesNoPtsCheck(const MTPUpdates &updates) { ? peerToMTP(_session->userPeerId()) : MTP_peerUser(d.vuser_id())), MTPint(), // from_boosts_applied + MTPstring(), // from_rank MTP_peerUser(d.vuser_id()), MTPPeer(), // saved_peer_id d.vfwd_from() ? *d.vfwd_from() : MTPMessageFwdHeader(), @@ -1170,6 +1171,7 @@ void Updates::applyUpdatesNoPtsCheck(const MTPUpdates &updates) { d.vid(), MTP_peerUser(d.vfrom_id()), MTPint(), // from_boosts_applied + MTPstring(), // from_rank MTP_peerChat(d.vchat_id()), MTPPeer(), // saved_peer_id d.vfwd_from() ? *d.vfwd_from() : MTPMessageFwdHeader(), @@ -1901,6 +1903,10 @@ void Updates::feedUpdate(const MTPUpdate &update) { session().data().applyUpdate(update.c_updateChatParticipantAdmin()); } break; + case mtpc_updateChatParticipantRank: { + session().data().applyUpdate(update.c_updateChatParticipantRank()); + } break; + case mtpc_updateChatDefaultBannedRights: { session().data().applyUpdate(update.c_updateChatDefaultBannedRights()); } break; @@ -2695,6 +2701,7 @@ void Updates::feedUpdate(const MTPUpdate &update) { const auto &data = update.c_updateEmojiGameInfo(); _session->diceStickersPacks().apply(data); } break; + } } diff --git a/Telegram/SourceFiles/apiwrap.cpp b/Telegram/SourceFiles/apiwrap.cpp index b9550e7205..eaab91cc66 100644 --- a/Telegram/SourceFiles/apiwrap.cpp +++ b/Telegram/SourceFiles/apiwrap.cpp @@ -542,6 +542,8 @@ void ApiWrap::sendMessageFail( } else if (show && error == u"CHAT_FORWARDS_RESTRICTED"_q) { show->showToast(peer->isBroadcast() ? tr::lng_error_noforwards_channel(tr::now) + : peer->isUser() + ? tr::lng_error_noforwards_user(tr::now) : tr::lng_error_noforwards_group(tr::now), kJoinErrorDuration); } else if (error == u"PREMIUM_ACCOUNT_REQUIRED"_q) { Settings::ShowPremium(&session(), "premium_stickers"); @@ -1966,7 +1968,7 @@ void ApiWrap::updateNotifySettingsDelayed(Data::DefaultNotify type) { void ApiWrap::sendNotifySettingsUpdates() { _updateNotifyQueueLifetime.destroy(); - for (const auto topic : base::take(_updateNotifyTopics)) { + for (const auto &topic : base::take(_updateNotifyTopics)) { request(MTPaccount_UpdateNotifySettings( MTP_inputNotifyForumTopic( topic->peer()->input(), @@ -1974,7 +1976,7 @@ void ApiWrap::sendNotifySettingsUpdates() { topic->notify().serialize() )).afterDelay(kSmallDelayMs).send(); } - for (const auto peer : base::take(_updateNotifyPeers)) { + for (const auto &peer : base::take(_updateNotifyPeers)) { request(MTPaccount_UpdateNotifySettings( MTP_inputNotifyPeer(peer->input()), peer->notify().serialize() @@ -3582,7 +3584,6 @@ void ApiWrap::forwardMessages( if (shared) { ++shared->requestsLeft; } - const auto requestType = Data::Histories::RequestType::Send; const auto idsCopy = localIds; const auto scheduled = action.options.scheduled; const auto starsPaid = std::min( @@ -3593,64 +3594,87 @@ void ApiWrap::forwardMessages( action.options.starsApproved -= starsPaid; oneFlags |= SendFlag::f_allow_paid_stars; } - histories.sendRequest(history, requestType, [=](Fn finish) { - history->sendRequestId = request(MTPmessages_ForwardMessages( - MTP_flags(oneFlags), + auto buildMessage = [=]( + not_null history, + FullReplyTo replyTo) + -> Data::Histories::PreparedMessage { + const auto kGeneralId = Data::ForumTopic::kGeneralId; + const auto realTopMsgId = (replyTo.topicRootId == kGeneralId) + ? MsgId(0) + : replyTo.topicRootId; + auto flags = oneFlags; + if (realTopMsgId) { + flags |= SendFlag::f_top_msg_id; + } else { + flags &= ~SendFlag::f_top_msg_id; + } + return MTPmessages_ForwardMessages( + MTP_flags(flags), forwardFrom->input(), MTP_vector(ids), MTP_vector(randomIds), - peer->input(), - MTP_int(topMsgId), + history->peer->input(), + MTP_int(realTopMsgId), (action.options.suggest - ? ReplyToForMTP(history, action.replyTo) + ? ReplyToForMTP(history, replyTo) : monoforumPeer - ? MTP_inputReplyToMonoForum(monoforumPeer->input()) + ? MTP_inputReplyToMonoForum( + monoforumPeer->input()) : MTPInputReplyTo()), MTP_int(action.options.scheduled), MTP_int(action.options.scheduleRepeatPeriod), - (sendAs ? sendAs->input() : MTP_inputPeerEmpty()), - Data::ShortcutIdToMTP(_session, action.options.shortcutId), + (sendAs + ? sendAs->input() + : MTP_inputPeerEmpty()), + Data::ShortcutIdToMTP( + &history->session(), + action.options.shortcutId), MTP_long(action.options.effectId), - MTPint(), // video_timestamp + MTPint(), MTP_long(starsPaid), - Api::SuggestToMTP(action.options.suggest) - )).done([=](const MTPUpdates &result) { + Api::SuggestToMTP(action.options.suggest)); + }; + histories.sendPreparedMessage( + history, + FullReplyTo{ .topicRootId = topicRootId }, + uint64(0), + std::move(buildMessage), + [=](const MTPUpdates &result, const MTP::Response &) { if (!scheduled) { - this->updates().checkForSentToScheduled(result); + _session->api().updates().checkForSentToScheduled( + result); } - applyUpdates(result); if (shared && !--shared->requestsLeft) { shared->callback(); } - const auto &ghost = AyuSettings::ghost(&session()); + const auto &ghost = AyuSettings::ghost(_session); if (!ghost.sendReadMessages() && ghost.markReadAfterAction() && history->lastMessage()) { readHistory(history->lastMessage()); } - finish(); - if (peer->isSelf() && session().premium()) { + if (peer->isSelf() && _session->premium()) { ProcessRecentSelfForwards( _session, result, peer->id, forwardFrom->id); } - }).fail([=](const MTP::Error &error) { + }, + [=](const MTP::Error &error, const MTP::Response &) { if (idsCopy) { for (const auto &[randomId, itemId] : *idsCopy) { - sendMessageFail(error, peer, randomId, itemId); + _session->api().sendMessageFail( + error, + peer, + randomId, + itemId); } } else { - sendMessageFail(error, peer); + _session->api().sendMessageFail(error, peer); } - finish(); - }).afterRequest( - history->sendRequestId - ).send(); - return history->sendRequestId; - }); + }); ids.resize(0); randomIds.resize(0); @@ -3659,7 +3683,7 @@ void ApiWrap::forwardMessages( ids.reserve(count); randomIds.reserve(count); - for (const auto item : draft.items) { + for (const auto &item : draft.items) { const auto randomId = base::RandomValue(); if (genClientSideMessage) { const auto newId = FullMsgId( @@ -3805,14 +3829,16 @@ void ApiWrap::sendVoiceMessage( applyGhostScheduling(_session, scheduledAction.options, 17); const auto caption = TextWithTags(); const auto to = FileLoadTaskOptions(scheduledAction); - _fileLoader->addTask(std::make_unique( - &session(), - result, - duration, - waveform, - video, - to, - caption)); + _fileLoader->addTask( + std::make_unique(FileLoadTask::VoiceArgs{ + .session = &session(), + .voice = result, + .duration = duration, + .waveform = waveform, + .video = video, + .to = to, + .caption = caption, + })); } void ApiWrap::editMedia( @@ -3838,29 +3864,36 @@ void ApiWrap::editMedia( } const auto forceFile = (type == SendMediaType::File) && (file.type == Ui::PreparedFile::Type::Video); - _fileLoader->addTask(std::make_unique( - &session(), - file.path, - file.content, - std::move(file.information), - (file.videoCover - ? std::make_unique( - &session(), - file.videoCover->path, - file.videoCover->content, - std::move(file.videoCover->information), - nullptr, - SendMediaType::Photo, - to, - TextWithTags(), - false) + _fileLoader->addTask(std::make_unique(FileLoadTask::Args{ + .session = &session(), + .filepath = file.path, + .content = file.content, + .information = std::move(file.information), + .videoCover = (file.videoCover + ? std::make_unique(FileLoadTask::Args{ + .session = &session(), + .filepath = file.videoCover->path, + .content = file.videoCover->content, + .information = std::move(file.videoCover->information), + .videoCover = nullptr, + .type = SendMediaType::Photo, + .to = to, + .caption = TextWithTags(), + .spoiler = false, + .album = nullptr, + .forceFile = false, + .idOverride = 0, + }) : nullptr), - type, - to, - caption, - file.spoiler, - nullptr, - forceFile)); + .type = type, + .to = to, + .caption = caption, + .spoiler = file.spoiler, + .album = nullptr, + .forceFile = forceFile, + .idOverride = 0, + .displayName = file.displayName, + })); } void ApiWrap::sendFiles( @@ -3870,10 +3903,22 @@ void ApiWrap::sendFiles( std::shared_ptr album, const SendAction &action) { const auto haveCaption = !caption.text.isEmpty(); - if (haveCaption - && !list.canAddCaption( + const auto captionAttached = !haveCaption + ? false + : (list.files.size() == 1) + ? list.canAddCaption( album != nullptr, - type == SendMediaType::Photo)) { + 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; @@ -3895,30 +3940,36 @@ void ApiWrap::sendFiles( : SendMediaType::File; const auto forceFile = (type == SendMediaType::File) && (file.type == Ui::PreparedFile::Type::Video); - tasks.push_back(std::make_unique( - &session(), - file.path, - file.content, - std::move(file.information), - (file.videoCover - ? std::make_unique( - &session(), - file.videoCover->path, - file.videoCover->content, - std::move(file.videoCover->information), - nullptr, - SendMediaType::Photo, - to, - TextWithTags(), - false, - nullptr) + tasks.push_back(std::make_unique(FileLoadTask::Args{ + .session = &session(), + .filepath = file.path, + .content = file.content, + .information = std::move(file.information), + .videoCover = (file.videoCover + ? std::make_unique(FileLoadTask::Args{ + .session = &session(), + .filepath = file.videoCover->path, + .content = file.videoCover->content, + .information = std::move(file.videoCover->information), + .videoCover = nullptr, + .type = SendMediaType::Photo, + .to = to, + .caption = TextWithTags(), + .spoiler = false, + .album = nullptr, + .forceFile = false, + .idOverride = 0, + }) : nullptr), - uploadWithType, - to, - caption, - file.spoiler, - album, - forceFile)); + .type = uploadWithType, + .to = to, + .caption = caption, + .spoiler = file.spoiler, + .album = album, + .forceFile = forceFile, + .idOverride = 0, + .displayName = file.displayName, + })); caption = TextWithTags(); } if (album) { @@ -3938,18 +3989,20 @@ void ApiWrap::sendFile( const auto to = FileLoadTaskOptions(action); auto caption = TextWithTags(); const auto spoiler = false; - const auto information = nullptr; - const auto videoCover = nullptr; - _fileLoader->addTask(std::make_unique( - &session(), - QString(), - fileContent, - information, - videoCover, - type, - to, - caption, - spoiler)); + _fileLoader->addTask(std::make_unique(FileLoadTask::Args{ + .session = &session(), + .filepath = QString(), + .content = fileContent, + .information = nullptr, + .videoCover = nullptr, + .type = type, + .to = to, + .caption = caption, + .spoiler = spoiler, + .album = nullptr, + .forceFile = false, + .idOverride = 0 + })); } void ApiWrap::sendUploadedPhoto( diff --git a/Telegram/SourceFiles/boxes/boxes.style b/Telegram/SourceFiles/boxes/boxes.style index c9e49481dc..625ed9a4b8 100644 --- a/Telegram/SourceFiles/boxes/boxes.style +++ b/Telegram/SourceFiles/boxes/boxes.style @@ -806,14 +806,31 @@ backgroundConfirmCancel: RoundButton(backgroundConfirm) { color: shadowFg; } } +urlAuthBox: Box(defaultBox) { + buttonPadding: margins(0px, 0px, 0px, 12px); + buttonHeight: 0px; +} urlAuthCheckbox: Checkbox(defaultBoxCheckbox) { width: 240px; } +urlAuthCodesTitle: FlatLabel(defaultPeerListAbout) { + textFg: boxTitleFg; + style: TextStyle(defaultTextStyle) { + font: boxTitleFont; + } +} urlAuthCheckboxAbout: FlatLabel(defaultPeerListAbout) { style: TextStyle(boxTextStyle) { font: font(12px); } } +urlAuthCodesButton: RoundButton(defaultLightButton) { + height: 58px; + textTop: 19px; + style: TextStyle(semiboldTextStyle) { + font: font(20px semibold); + } +} urlAuthBoxRowTopLabel: FlatLabel(boxLabel) { maxHeight: 30px; } @@ -840,7 +857,7 @@ boostsUnrestrictLabel: FlatLabel(defaultFlatLabel) { } customBadgeField: InputField(defaultInputField) { - textMargins: margins(2px, 7px, 2px, 0px); + textMargins: margins(2px, 0px, 2px, 0px); placeholderFg: placeholderFg; placeholderFgActive: placeholderFgActive; @@ -849,9 +866,16 @@ customBadgeField: InputField(defaultInputField) { placeholderScale: 0.; placeholderFont: normalFont; + border: 0px; + borderActive: 0px; + heightMin: 32px; } +tagPreviewLineHeight: 8px; +tagPreviewLineSpacing: 4px; +tagPreviewInputSkip: 16px; + pollResultsQuestion: FlatLabel(defaultFlatLabel) { minWidth: 320px; textFg: windowBoldFg; @@ -876,6 +900,11 @@ pollResultsShowMore: SettingsButton(defaultSettingsButton) { ripple: defaultRippleAnimation; } +pollResultsVoteTimeFont: font(fsize); +pollResultsVoteTimeDateFont: font(fsize); +pollResultsVoteTimeRightSkip: 16px; +pollResultsVoteTimeLeftSkip: 8px; +pollResultsVoteTimeGap: 2px; inviteViaLinkButton: SettingsButton(defaultSettingsButton) { textFg: lightButtonFg; @@ -1200,3 +1229,9 @@ futureOwnerBox: Box(defaultBox) { buttonHeight: 0px; } futureOwnerBoxSelect: collectibleBox; + +disableSharingIconPadding: margins(12px, 12px, 12px, 8px); +disableSharingButtonLock: IconEmoji { + icon: icon {{ "emoji/premium_lock", activeButtonFg }}; + padding: margins(-2px, 1px, 0px, 0px); +} diff --git a/Telegram/SourceFiles/boxes/choose_filter_box.cpp b/Telegram/SourceFiles/boxes/choose_filter_box.cpp index e7842c0e69..304d6d5248 100644 --- a/Telegram/SourceFiles/boxes/choose_filter_box.cpp +++ b/Telegram/SourceFiles/boxes/choose_filter_box.cpp @@ -470,28 +470,43 @@ bool FillChooseFilterWithAdminedGroupsMenu( return added; } +History *HistoryFromMimeData( + const QMimeData *mime, + not_null session) { + const auto mimeFormat = u"application/x-telegram-dialog"_q; + if (mime->hasFormat(mimeFormat)) { + auto peerId = int64(-1); + auto isTestMode = false; + auto stream = QDataStream(mime->data(mimeFormat)); + stream >> peerId; + stream >> isTestMode; + if (isTestMode != session->isTestMode()) { + return nullptr; + } + return session->data().historyLoaded(PeerId(peerId)); + } + if (mime->hasText()) { + auto text = mime->text().trimmed(); + if (text.startsWith('@')) { + text = text.mid(1); + } else if (text.startsWith(u"https://t.me/"_q)) { + text = text.mid(13); + } else { + return nullptr; + } + if (const auto peer = session->data().peerByUsername(text)) { + return session->data().historyLoaded(peer->id); + } + } + return nullptr; +} + void SetupFilterDragAndDrop( not_null outer, not_null session, Fn(QPoint)> filterIdAtPosition, - Fn activeFilterId) { - const auto mimeFormat = u"application/x-telegram-dialog"_q; - const auto peerIdFromMime = [=](const QMimeData *mimeData) { - auto peerId = int64(-1); - auto isTestMode = false; - if (mimeData->hasFormat(mimeFormat)) { - auto stream = QDataStream(mimeData->data(mimeFormat)); - stream >> peerId; - stream >> isTestMode; - if (isTestMode != session->isTestMode()) { - return int64(-1); - } - } - return peerId; - }; - const auto historyFromMime = [=](const QMimeData *mime) { - return session->data().historyLoaded(PeerId(peerIdFromMime(mime))); - }; + Fn activeFilterId, + Fn selectByFilterId) { const auto hasAction = [=](not_null drop, bool perform) { const auto mimeData = drop->mimeData(); const auto filterId = filterIdAtPosition( @@ -500,7 +515,7 @@ void SetupFilterDragAndDrop( return false; } const auto id = *filterId; - if (const auto h = historyFromMime(mimeData)) { + if (const auto h = HistoryFromMimeData(mimeData, session)) { auto v = ChooseFilterValidator(h); if (id) { if (v.canAdd(id)) { @@ -508,6 +523,7 @@ void SetupFilterDragAndDrop( if (perform) { v.add(id); } + selectByFilterId(perform ? FilterId(-1) : id); return true; } } @@ -517,10 +533,12 @@ void SetupFilterDragAndDrop( if (perform) { v.remove(active); } + selectByFilterId(perform ? FilterId(-1) : active); return true; } } } + selectByFilterId(-1); return false; }; outer->setAcceptDrops(true); @@ -546,6 +564,7 @@ void SetupFilterDragAndDrop( dm->ignore(); } } else if (e->type() == QEvent::DragLeave) { + selectByFilterId(-1); } else if (e->type() == QEvent::Drop) { const auto drop = static_cast(e.get()); if (hasAction(drop, true)) { diff --git a/Telegram/SourceFiles/boxes/choose_filter_box.h b/Telegram/SourceFiles/boxes/choose_filter_box.h index 99492c6ba6..59cff96333 100644 --- a/Telegram/SourceFiles/boxes/choose_filter_box.h +++ b/Telegram/SourceFiles/boxes/choose_filter_box.h @@ -62,4 +62,9 @@ void SetupFilterDragAndDrop( not_null outer, not_null session, Fn(QPoint)> filterIdAtPosition, - Fn activeFilterId); + Fn activeFilterId, + Fn selectByFilterId); + +[[nodiscard]] History *HistoryFromMimeData( + const QMimeData *mime, + not_null session); diff --git a/Telegram/SourceFiles/boxes/connection_box.cpp b/Telegram/SourceFiles/boxes/connection_box.cpp index 8902fc2c26..b687c074e7 100644 --- a/Telegram/SourceFiles/boxes/connection_box.cpp +++ b/Telegram/SourceFiles/boxes/connection_box.cpp @@ -1116,6 +1116,30 @@ void ProxyBox::prepare() { } }, _port->lifetime()); + const auto submit = [=] { + if (_host->hasFocus() + && !_host->getLastText().trimmed().isEmpty()) { + _port->setFocus(); + } else if (_port->hasFocus() + && !_port->getLastText().trimmed().isEmpty()) { + if (_type->current() == Type::Mtproto) { + _secret->setFocus(); + } else { + _user->setFocus(); + } + } else if (_user->hasFocus()) { + _password->setFocus(); + } else { + save(); + } + }; + connect(_host.data(), &Ui::MaskedInputField::submitted, submit); + connect(_port.data(), &Ui::MaskedInputField::submitted, submit); + _user->submits( + ) | rpl::on_next(submit, _user->lifetime()); + connect(_password.data(), &Ui::MaskedInputField::submitted, submit); + connect(_secret.data(), &Ui::MaskedInputField::submitted, submit); + refreshButtons(); setDimensionsToContent(st::boxWideWidth, _content); } @@ -1421,7 +1445,7 @@ void ProxiesBoxController::ShowApplyConfirmation( } else if (type == Type::Mtproto) { add(proxy.password, tr::lng_proxy_box_secret); } - box->addButton(tr::lng_sure_enable(), [=] { + const auto enableButton = box->addButton(tr::lng_sure_enable(), [=] { auto &proxies = Core::App().settings().proxy().list(); if (!ranges::contains(proxies, proxy)) { proxies.push_back(proxy); @@ -1431,6 +1455,16 @@ void ProxiesBoxController::ShowApplyConfirmation( box->closeBox(); }); box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); + box->events( + ) | rpl::on_next([=](not_null e) { + if ((e->type() != QEvent::KeyPress) || !enableButton) { + return; + } + const auto k = static_cast(e.get()); + if (k->key() == Qt::Key_Enter || k->key() == Qt::Key_Return) { + enableButton->clicked(Qt::KeyboardModifiers(), Qt::LeftButton); + } + }, box->lifetime()); }; if (controller) { controller->uiShow()->showBox(Box(box)); diff --git a/Telegram/SourceFiles/boxes/edit_caption_box.cpp b/Telegram/SourceFiles/boxes/edit_caption_box.cpp index 32d01b297d..c1985e2dcc 100644 --- a/Telegram/SourceFiles/boxes/edit_caption_box.cpp +++ b/Telegram/SourceFiles/boxes/edit_caption_box.cpp @@ -114,7 +114,11 @@ constexpr auto kChangesDebounceTimeout = crl::time(1000); return Ui::AlbumType(); } -[[nodiscard]] bool CanBeCompressed(Ui::AlbumType type) { +[[nodiscard]] bool CanToggleCompressed(Ui::AlbumType type) { + return (type == Ui::AlbumType::None); +} + +[[nodiscard]] bool AlbumTypeCompressed(Ui::AlbumType type) { return (type == Ui::AlbumType::None) || (type == Ui::AlbumType::PhotoVideo); } @@ -258,6 +262,8 @@ EditCaptionBox::EditCaptionBox( Expects(!_initialList.files.empty()); Expects(item->allowsEditMedia()); + _asFile = !AlbumTypeCompressed(_albumType); + _mediaEditManager.start(item, spoilered, invertCaption); _controller->session().data().itemRemoved( @@ -457,7 +463,8 @@ void EditCaptionBox::rebuildPreview() { const auto photo = media->photo(); const auto document = media->document(); _isPhoto = (photo != nullptr); - if (photo || document->isVideoFile() || document->isAnimation()) { + _isVideo = (document != nullptr) && document->isVideoFile(); + if (_isPhoto || _isVideo || document->isAnimation()) { const auto media = Ui::CreateChild( this, st::defaultComposeControls, @@ -475,20 +482,18 @@ void EditCaptionBox::rebuildPreview() { } } else { const auto &file = _preparedList.files.front(); - const auto isVideoFile = file.isVideoFile(); + _isVideo = file.isVideoFile(); const auto media = Ui::SingleMediaPreview::Create( this, st::defaultComposeControls, gifPaused, file, [=](Ui::AttachActionType type) { - return (type != Ui::AttachActionType::EditCover) - || isVideoFile; + return (type != Ui::AttachActionType::EditCover) || _isVideo; }, Ui::AttachControls::Type::EditOnly); _isPhoto = (media && media->isPhoto()); - const auto withCheckbox = _isPhoto && CanBeCompressed(_albumType); - if (media && (!withCheckbox || !_asFile)) { + if (media && !_asFile) { media->spoileredChanges( ) | rpl::on_next([=](bool spoilered) { _mediaEditManager.apply({ .type = spoilered @@ -666,9 +671,9 @@ void EditCaptionBox::setupControls() { auto hintLabelToggleOn = _previewRebuilds.events_starting_with( {} ) | rpl::map([=] { - return _controller->session().settings().photoEditorHintShown() - ? (_isPhoto && !_asFile) - : false; + return _isPhoto + && !_asFile + && _controller->session().settings().photoEditorHintShown(); }); _controls->add(object_ptr>( @@ -684,21 +689,21 @@ void EditCaptionBox::setupControls() { this, object_ptr( this, - tr::lng_send_compressed_one(tr::now), - true, + tr::lng_send_as_documents_one(tr::now), + _asFile, st::defaultBoxCheckbox), st::editMediaCheckboxMargins) )->toggleOn( _previewRebuilds.events_starting_with({}) | rpl::map([=] { - return _isPhoto - && CanBeCompressed(_albumType) + return (_isPhoto || _isVideo) + && CanToggleCompressed(_albumType) && !_preparedList.files.empty(); }), anim::type::instant )->entity()->checkedChanges( ) | rpl::on_next([&](bool checked) { applyChanges(); - _asFile = !checked; + _asFile = checked; rebuildPreview(); }, _controls->lifetime()); @@ -1124,11 +1129,12 @@ void EditCaptionBox::save() { applyChanges(); } + const auto compressed = CanToggleCompressed(_albumType) + ? (!_asFile) + : AlbumTypeCompressed(_albumType); _controller->session().api().editMedia( std::move(_preparedList), - (_isPhoto && !_asFile && CanBeCompressed(_albumType)) - ? SendMediaType::Photo - : SendMediaType::File, + (compressed ? SendMediaType::Photo : SendMediaType::File), _field->getTextWithAppliedMarkdown(), action); closeAfterSave(); diff --git a/Telegram/SourceFiles/boxes/edit_caption_box.h b/Telegram/SourceFiles/boxes/edit_caption_box.h index 569039ad05..760df00183 100644 --- a/Telegram/SourceFiles/boxes/edit_caption_box.h +++ b/Telegram/SourceFiles/boxes/edit_caption_box.h @@ -145,6 +145,7 @@ private: base::Timer _checkChangedTimer; bool _isPhoto = false; + bool _isVideo = false; bool _asFile = false; QString _error; diff --git a/Telegram/SourceFiles/boxes/edit_privacy_box.cpp b/Telegram/SourceFiles/boxes/edit_privacy_box.cpp index 52ba4c7e2d..83ef8a3528 100644 --- a/Telegram/SourceFiles/boxes/edit_privacy_box.cpp +++ b/Telegram/SourceFiles/boxes/edit_privacy_box.cpp @@ -585,7 +585,7 @@ void EditNoPaidMessagesExceptions( setTo.premiums = false; setTo.miniapps = false; auto &removeFrom = copy.never; - for (const auto peer : setTo.peers) { + for (const auto &peer : setTo.peers) { removeFrom.peers.erase( ranges::remove(removeFrom.peers, peer), end(removeFrom.peers)); @@ -671,7 +671,7 @@ void EditPrivacyBox::editExceptions( Unexpected("Invalid exception value."); }(); auto &removeFrom = exceptions(type); - for (const auto peer : exceptions(exception).peers) { + for (const auto &peer : exceptions(exception).peers) { removeFrom.peers.erase( ranges::remove(removeFrom.peers, peer), end(removeFrom.peers)); diff --git a/Telegram/SourceFiles/boxes/moderate_messages_box.cpp b/Telegram/SourceFiles/boxes/moderate_messages_box.cpp index 59ab073528..2c6d7c510d 100644 --- a/Telegram/SourceFiles/boxes/moderate_messages_box.cpp +++ b/Telegram/SourceFiles/boxes/moderate_messages_box.cpp @@ -350,7 +350,8 @@ void ProccessCommonGroups( void CreateModerateMessagesBox( not_null box, const HistoryItemsList &items, - Fn confirmed) { + Fn confirmed, + ModerateMessagesBoxOptions options) { Expects(!items.empty()); using Controller = Ui::ExpandablePeerListController; @@ -381,6 +382,20 @@ void CreateModerateMessagesBox( const auto historyPeerId = history->peer->id; const auto ids = session->data().itemsToIds(items); + { + const auto remainingIds + = box->lifetime().make_state>( + ids.begin(), + ids.end()); + session->data().itemRemoved( + ) | rpl::on_next([=](not_null item) { + remainingIds->erase(item->fullId()); + if (remainingIds->empty()) { + box->closeBox(); + } + }, box->lifetime()); + } + if (ModerateCommonGroups.value() || session->supportMode()) { ProccessCommonGroups( items, @@ -568,7 +583,7 @@ void CreateModerateMessagesBox( object_ptr( box, tr::lng_report_spam(tr::now), - false, + options.reportSpam, st::defaultBoxCheckbox), st::boxRowPadding + buttonPadding); const auto controller = box->lifetime().make_state( @@ -599,7 +614,7 @@ void CreateModerateMessagesBox( lt_user, tr::bold(firstItem->from()->name()), tr::marked), - false, + options.deleteAll, st::defaultBoxCheckbox), st::boxRowPadding + buttonPadding); auto messagesCounts = MessagesCountValue(history, participants); @@ -715,7 +730,7 @@ void CreateModerateMessagesBox( rpl::single(isSingle), tr::lng_ban_user(), tr::lng_ban_users())), - false, + options.banUser, st::defaultBoxCheckbox), st::boxRowPadding + buttonPadding); const auto controller = box->lifetime().make_state( @@ -820,7 +835,7 @@ void CreateModerateMessagesBox( box, prepareFlags, disabledMessages, - { .isForum = peer->isForum() }); + { .isForum = peer->isForum(), .isUserSpecific = true }); computeRestrictions = getRestrictions; std::move(changes) | rpl::on_next([=] { ban->setChecked(true); @@ -1155,3 +1170,13 @@ void DeleteSublistBox( }, st::attentionBoxButton); box->addButton(tr::lng_cancel(), close); } + +ModerateMessagesBoxOptions DefaultModerateMessagesBoxOptions() { + return base::IsCtrlPressed() + ? ModerateMessagesBoxOptions{ + .reportSpam = true, + .deleteAll = true, + .banUser = true, + } + : ModerateMessagesBoxOptions{}; +} diff --git a/Telegram/SourceFiles/boxes/moderate_messages_box.h b/Telegram/SourceFiles/boxes/moderate_messages_box.h index 92307ce347..2d5f2cec1c 100644 --- a/Telegram/SourceFiles/boxes/moderate_messages_box.h +++ b/Telegram/SourceFiles/boxes/moderate_messages_box.h @@ -19,10 +19,19 @@ class GenericBox; extern const char kModerateCommonGroups[]; +struct ModerateMessagesBoxOptions final { + bool reportSpam = false; + bool deleteAll = false; + bool banUser = false; +}; + +[[nodiscard]] ModerateMessagesBoxOptions DefaultModerateMessagesBoxOptions(); + void CreateModerateMessagesBox( not_null box, const HistoryItemsList &items, - Fn confirmed); + Fn confirmed, + ModerateMessagesBoxOptions options); [[nodiscard]] bool CanCreateModerateMessagesBox(const HistoryItemsList &); diff --git a/Telegram/SourceFiles/boxes/peer_list_box.h b/Telegram/SourceFiles/boxes/peer_list_box.h index 6044bb70ed..3be7da350a 100644 --- a/Telegram/SourceFiles/boxes/peer_list_box.h +++ b/Telegram/SourceFiles/boxes/peer_list_box.h @@ -378,7 +378,7 @@ public: template void peerListAddSelectedPeers(PeerDataRange &&range) { - for (const auto peer : range) { + for (const auto &peer : range) { peerListAddSelectedPeerInBunch(peer); } peerListFinishSelectedRowsBunch(); diff --git a/Telegram/SourceFiles/boxes/peer_list_controllers.cpp b/Telegram/SourceFiles/boxes/peer_list_controllers.cpp index acca09e832..a83af75867 100644 --- a/Telegram/SourceFiles/boxes/peer_list_controllers.cpp +++ b/Telegram/SourceFiles/boxes/peer_list_controllers.cpp @@ -54,6 +54,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_profile.h" #include "styles/style_dialogs.h" #include "styles/style_chat_helpers.h" +#include "styles/style_menu_icons.h" namespace { @@ -852,7 +853,7 @@ void ChooseRecipientBoxController::rowClicked(not_null row) { const auto peer = row->peer(); if (const auto forum = peer->forum()) { const auto weak = std::make_shared>(); - auto callback = [=](not_null topic) { + auto callback = [=](not_null thread) { const auto exists = guard.get(); if (!exists) { if (*weak) { @@ -861,15 +862,15 @@ void ChooseRecipientBoxController::rowClicked(not_null row) { return; } auto onstack = std::move(_callback); - onstack(topic); + onstack(thread); if (guard) { _callback = std::move(onstack); } else if (*weak) { (*weak)->closeBox(); } }; - const auto filter = [=](not_null topic) { - return guard && (!_filter || _filter(topic)); + const auto filter = [=](not_null thread) { + return guard && (!_filter || _filter(thread)); }; auto owned = Box( std::make_unique( @@ -1095,10 +1096,62 @@ auto ChooseTopicBoxController::Row::generateNameWords() const return _topic->chatListNameWords(); } +QString ChooseTopicBoxController::AllMessagesRow::name() const { + return _userCreatesTopics + ? tr::lng_forum_create_new_topic(tr::now) + : tr::lng_forum_all_messages(tr::now); +} + +ChooseTopicBoxController::AllMessagesRow::AllMessagesRow(bool userCreatesTopics) +: PeerListRow(PeerListRowId(0)) +, _userCreatesTopics(userCreatesTopics) { + const auto words = TextUtilities::PrepareSearchWords(name()); + for (const auto &word : words) { + _nameWords.emplace(word); + _nameFirstLetters.emplace(word[0]); + } +} + +QString ChooseTopicBoxController::AllMessagesRow::generateName() { + return name(); +} + +QString ChooseTopicBoxController::AllMessagesRow::generateShortName() { + return name(); +} + +auto ChooseTopicBoxController::AllMessagesRow::generatePaintUserpicCallback( + bool forceRound) +-> PaintRoundImageCallback { + return [userCreatesTopics = _userCreatesTopics]( + Painter &p, + int x, + int y, + int outerWidth, + int size) { + const auto &icon = userCreatesTopics + ? st::menuIconDiscussion + : st::menuIconChats; + icon.paintInCenter( + p, + QRect(x, y - st::lineWidth, size, size)); + }; +} + +auto ChooseTopicBoxController::AllMessagesRow::generateNameFirstLetters() const +-> const base::flat_set & { + return _nameFirstLetters; +} + +auto ChooseTopicBoxController::AllMessagesRow::generateNameWords() const +-> const base::flat_set & { + return _nameWords; +} + ChooseTopicBoxController::ChooseTopicBoxController( not_null forum, - FnMut)> callback, - Fn)> filter) + FnMut)> callback, + Fn)> filter) : PeerListController(std::make_unique(forum)) , _forum(forum) , _callback(std::move(callback)) @@ -1127,7 +1180,11 @@ Main::Session &ChooseTopicBoxController::session() const { void ChooseTopicBoxController::rowClicked(not_null row) { const auto weak = base::make_weak(this); auto onstack = base::take(_callback); - onstack(static_cast(row.get())->topic()); + if (row->id() == PeerListRowId(0)) { + onstack(_forum->history()); + } else { + onstack(static_cast(row.get())->topic()); + } if (weak) { _callback = std::move(onstack); } @@ -1155,6 +1212,15 @@ void ChooseTopicBoxController::prepare() { void ChooseTopicBoxController::refreshRows(bool initial) { auto added = false; + if (_forum->bot() + && !delegate()->peerListFindRow(PeerListRowId(0)) + && (!_filter || _filter(_forum->history()))) { + const auto userCreatesTopics = Data::IsBotUserCreatesTopics( + _forum->peer()); + delegate()->peerListAppendRow( + std::make_unique(userCreatesTopics)); + added = true; + } for (const auto &row : _forum->topicsList()->indexed()->all()) { if (const auto topic = row->topic()) { const auto id = topic->rootId().bare; diff --git a/Telegram/SourceFiles/boxes/peer_list_controllers.h b/Telegram/SourceFiles/boxes/peer_list_controllers.h index fa02b17a58..6eb90a0009 100644 --- a/Telegram/SourceFiles/boxes/peer_list_controllers.h +++ b/Telegram/SourceFiles/boxes/peer_list_controllers.h @@ -354,8 +354,8 @@ class ChooseTopicBoxController final public: ChooseTopicBoxController( not_null forum, - FnMut)> callback, - Fn)> filter = nullptr); + FnMut)> callback, + Fn)> filter = nullptr); Main::Session &session() const override; void rowClicked(not_null row) override; @@ -391,13 +391,36 @@ private: }; + class AllMessagesRow final : public PeerListRow { + public: + explicit AllMessagesRow(bool userCreatesTopics); + + QString generateName() override; + QString generateShortName() override; + PaintRoundImageCallback generatePaintUserpicCallback( + bool forceRound) override; + + auto generateNameFirstLetters() const + -> const base::flat_set & override; + auto generateNameWords() const + -> const base::flat_set & override; + + private: + [[nodiscard]] QString name() const; + + base::flat_set _nameFirstLetters; + base::flat_set _nameWords; + bool _userCreatesTopics = false; + + }; + void refreshRows(bool initial = false); [[nodiscard]] std::unique_ptr createRow( not_null topic); const not_null _forum; - FnMut)> _callback; - Fn)> _filter; + FnMut)> _callback; + Fn)> _filter; }; diff --git a/Telegram/SourceFiles/boxes/peers/add_bot_to_chat_box.cpp b/Telegram/SourceFiles/boxes/peers/add_bot_to_chat_box.cpp index 956abf728c..de701a5215 100644 --- a/Telegram/SourceFiles/boxes/peers/add_bot_to_chat_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/add_bot_to_chat_box.cpp @@ -233,7 +233,7 @@ void AddBotToGroupBoxController::addBotToGroup(not_null chat) { const auto token = _token; const auto done = [=]( ChatAdminRightsInfo newRights, - const QString &rank) { + const std::optional &rank) { if (scope == Scope::GroupAdmin) { chat->session().api().sendBotStart(show, bot, chat, token); } diff --git a/Telegram/SourceFiles/boxes/peers/add_participants_box.cpp b/Telegram/SourceFiles/boxes/peers/add_participants_box.cpp index 5355138ac8..d87ebcb487 100644 --- a/Telegram/SourceFiles/boxes/peers/add_participants_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/add_participants_box.cpp @@ -1500,14 +1500,14 @@ void AddSpecialBoxController::showAdmin( _peer, user, currentRights, - _additional.adminRank(user), + _additional.memberRank(user), _additional.adminPromotedSince(user), _additional.adminPromotedBy(user)); const auto show = delegate()->peerListUiShow(); if (_additional.canAddOrEditAdmin(user)) { const auto done = crl::guard(this, [=]( ChatAdminRightsInfo newRights, - const QString &rank) { + const std::optional &rank) { editAdminDone(user, newRights, rank); }); const auto fail = crl::guard(this, [=] { @@ -1524,12 +1524,15 @@ void AddSpecialBoxController::showAdmin( void AddSpecialBoxController::editAdminDone( not_null user, ChatAdminRightsInfo rights, - const QString &rank) { + const std::optional &rank) { if (_editParticipantBox) { _editParticipantBox->closeBox(); } - _additional.applyAdminLocally(user, rights, rank); + _additional.applyAdminLocally( + user, + rights, + rank.value_or(_additional.memberRank(user))); // _adminDoneCallback should call changes().chatAdminUpdated. if (const auto callback = _adminDoneCallback) { callback(user, rights, rank); @@ -1582,6 +1585,7 @@ void AddSpecialBoxController::showRestricted( user, _additional.adminRights(user).has_value(), currentRights, + _additional.memberRank(user), _additional.restrictedBy(user), _additional.restrictedSince(user)); if (_additional.canRestrictParticipant(user)) { @@ -1594,8 +1598,9 @@ void AddSpecialBoxController::showRestricted( _editParticipantBox->closeBox(); } }); + const auto show = delegate()->peerListUiShow(); box->setSaveCallback( - SaveRestrictedCallback(_peer, user, done, fail)); + SaveRestrictedCallback(show, _peer, user, done, fail)); } _editParticipantBox = showBox(std::move(box)); } @@ -1668,7 +1673,9 @@ void AddSpecialBoxController::kickUser( const auto fail = crl::guard(this, [=] { _editBox = nullptr; }); + const auto show = delegate()->peerListUiShow(); const auto callback = SaveRestrictedCallback( + show, _peer, participant, done, diff --git a/Telegram/SourceFiles/boxes/peers/add_participants_box.h b/Telegram/SourceFiles/boxes/peers/add_participants_box.h index dc06e8040f..7a6aafa24e 100644 --- a/Telegram/SourceFiles/boxes/peers/add_participants_box.h +++ b/Telegram/SourceFiles/boxes/peers/add_participants_box.h @@ -101,7 +101,7 @@ public: using AdminDoneCallback = Fn user, ChatAdminRightsInfo adminRights, - const QString &rank)>; + const std::optional &rank)>; using BannedDoneCallback = Fn participant, ChatRestrictionsInfo bannedRights)>; @@ -133,7 +133,7 @@ private: void editAdminDone( not_null user, ChatAdminRightsInfo rights, - const QString &rank); + const std::optional &rank); void showRestricted(not_null user, bool sure = false); void editRestrictedDone( not_null participant, diff --git a/Telegram/SourceFiles/boxes/peers/channel_ownership_transfer.cpp b/Telegram/SourceFiles/boxes/peers/channel_ownership_transfer.cpp index 990636ed77..2153db1df7 100644 --- a/Telegram/SourceFiles/boxes/peers/channel_ownership_transfer.cpp +++ b/Telegram/SourceFiles/boxes/peers/channel_ownership_transfer.cpp @@ -11,7 +11,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "apiwrap.h" #include "boxes/passcode_box.h" #include "core/core_cloud_password.h" -#include "data/data_channel.h" #include "data/data_user.h" #include "lang/lang_keys.h" #include "main/main_session.h" @@ -47,21 +46,10 @@ bool ChannelOwnershipTransfer::handleTransferPasswordError( } void ChannelOwnershipTransfer::start() { - if (const auto chat = _peer->asChatNotMigrated()) { - _peer->session().api().migrateChat(chat, [=]( - not_null channel) { - startTransfer(channel); - }); - } else if (const auto channel = _peer->asChannelOrMigrated()) { - startTransfer(channel); - } -} - -void ChannelOwnershipTransfer::startTransfer(not_null channel) { - const auto api = &channel->session().api(); + const auto api = &_peer->session().api(); api->cloudPassword().reload(); - api->request(MTPchannels_EditCreator( - channel->inputChannel(), + api->request(MTPmessages_EditChatCreator( + _peer->input(), MTP_inputUserEmpty(), MTP_inputCheckPasswordEmpty() )).fail([=](const MTP::Error &error) { @@ -78,7 +66,7 @@ void ChannelOwnershipTransfer::startTransfer(not_null channel) { .text = tr::lng_rights_transfer_about( tr::now, lt_group, - tr::bold(channel->name()), + tr::bold(_peer->name()), lt_user, tr::bold(_selectedUser->shortName()), tr::rich), @@ -110,20 +98,16 @@ void ChannelOwnershipTransfer::requestPassword() { void ChannelOwnershipTransfer::sendRequest( base::weak_qptr box, const Core::CloudPasswordResult &result) { - const auto channel = _peer->asChannelOrMigrated(); - if (!channel) { - return; - } - const auto api = &channel->session().api(); - api->request(MTPchannels_EditCreator( - channel->inputChannel(), + const auto api = &_peer->session().api(); + api->request(MTPmessages_EditChatCreator( + _peer->input(), _selectedUser->inputUser(), result.result )).done([=](const MTPUpdates &result) { api->applyUpdates(result); const auto currentShow = box ? box->uiShow() : _show; currentShow->showToast( - (channel->isBroadcast() + (_peer->isBroadcast() ? tr::lng_rights_transfer_done_channel : tr::lng_rights_transfer_done_group)( tr::now, @@ -143,11 +127,13 @@ void ChannelOwnershipTransfer::sendRequest( } else if (type == u"CHANNELS_ADMIN_LOCATED_TOO_MUCH"_q) { return tr::lng_channels_too_much_located_other(tr::now); } else if (type == u"ADMINS_TOO_MUCH"_q) { - return (channel->isBroadcast() + return (_peer->isBroadcast() ? tr::lng_error_admin_limit_channel : tr::lng_error_admin_limit)(tr::now); - } else if (type == u"CHANNEL_INVALID"_q) { - return (channel->isBroadcast() + } else if (type == u"CHANNEL_INVALID"_q + || type == u"CHAT_CREATOR_REQUIRED"_q + || type == u"PARTICIPANT_MISSING"_q) { + return (_peer->isBroadcast() ? tr::lng_channel_not_accessible : tr::lng_group_not_accessible)(tr::now); } diff --git a/Telegram/SourceFiles/boxes/peers/channel_ownership_transfer.h b/Telegram/SourceFiles/boxes/peers/channel_ownership_transfer.h index 73c7709cc9..2fca0750fd 100644 --- a/Telegram/SourceFiles/boxes/peers/channel_ownership_transfer.h +++ b/Telegram/SourceFiles/boxes/peers/channel_ownership_transfer.h @@ -7,7 +7,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once -class ChannelData; class UserData; namespace Ui { @@ -36,7 +35,6 @@ private: void sendRequest( base::weak_qptr box, const Core::CloudPasswordResult &result); - void startTransfer(not_null channel); const not_null _peer; const not_null _selectedUser; diff --git a/Telegram/SourceFiles/boxes/peers/edit_discussion_link_box.cpp b/Telegram/SourceFiles/boxes/peers/edit_discussion_link_box.cpp index 8f5154c955..01c838b82c 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_discussion_link_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_discussion_link_box.cpp @@ -111,7 +111,7 @@ void Controller::prepare() { if (_chat) { appendRow(_chat); } else { - for (const auto chat : _chats) { + for (const auto &chat : _chats) { appendRow(chat); } if (_chats.size() >= kEnableSearchRowsCount) { diff --git a/Telegram/SourceFiles/boxes/peers/edit_members_visible.cpp b/Telegram/SourceFiles/boxes/peers/edit_members_visible.cpp index 0fd9fd007b..8d042eccfa 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_members_visible.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_members_visible.cpp @@ -34,44 +34,50 @@ namespace { [[nodiscard]] object_ptr CreateMembersVisibleButton( not_null megagroup) { - auto result = object_ptr((QWidget*)nullptr); - const auto container = result.data(); - const auto min = EnableHideMembersMin(megagroup); - if (!megagroup->canBanMembers() || megagroup->membersCount() < min) { + const auto showHideMembers = megagroup->canBanMembers() + && megagroup->membersCount() >= min; + if (!showHideMembers) { return { nullptr }; } - struct State { - rpl::event_stream toggled; - }; - Ui::AddSkip(container); - const auto state = container->lifetime().make_state(); - const auto button = container->add( - EditPeerInfoBox::CreateButton( - container, - tr::lng_profile_hide_participants(), - rpl::single(QString()), - [] {}, - st::manageGroupNoIconButton, - {} - ))->toggleOn(rpl::single( - (megagroup->flags() & ChannelDataFlag::ParticipantsHidden) != 0 - ) | rpl::then(state->toggled.events())); - Ui::AddSkip(container); - Ui::AddDividerText(container, tr::lng_profile_hide_participants_about()); + auto result = object_ptr((QWidget*)nullptr); + const auto container = result.data(); - button->toggledValue( - ) | rpl::on_next([=](bool toggled) { - megagroup->session().api().request( - MTPchannels_ToggleParticipantsHidden( - megagroup->inputChannel(), - MTP_bool(toggled) - ) - ).done([=](const MTPUpdates &result) { - megagroup->session().api().applyUpdates(result); - }).send(); - }, button->lifetime()); + if (showHideMembers) { + struct State { + rpl::event_stream toggled; + }; + Ui::AddSkip(container); + const auto state = container->lifetime().make_state(); + const auto button = container->add( + EditPeerInfoBox::CreateButton( + container, + tr::lng_profile_hide_participants(), + rpl::single(QString()), + [] {}, + st::manageGroupNoIconButton, + {} + ))->toggleOn(rpl::single( + (megagroup->flags() & ChannelDataFlag::ParticipantsHidden) != 0 + ) | rpl::then(state->toggled.events())); + Ui::AddSkip(container); + Ui::AddDividerText( + container, + tr::lng_profile_hide_participants_about()); + + button->toggledValue( + ) | rpl::on_next([=](bool toggled) { + megagroup->session().api().request( + MTPchannels_ToggleParticipantsHidden( + megagroup->inputChannel(), + MTP_bool(toggled) + ) + ).done([=](const MTPUpdates &result) { + megagroup->session().api().applyUpdates(result); + }).send(); + }, button->lifetime()); + } return result; } diff --git a/Telegram/SourceFiles/boxes/peers/edit_participant_box.cpp b/Telegram/SourceFiles/boxes/peers/edit_participant_box.cpp index 1840710f69..72d88cacfe 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_participant_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_participant_box.cpp @@ -7,6 +7,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "boxes/peers/edit_participant_box.h" +#include "boxes/peers/edit_tag_control.h" +#include "boxes/peers/edit_participants_box.h" +#include "history/view/history_view_message.h" #include "lang/lang_keys.h" #include "ui/controls/userpic_button.h" #include "ui/vertical_list.h" @@ -31,8 +34,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/peers/add_bot_to_chat_box.h" #include "boxes/peers/edit_peer_permissions_box.h" #include "boxes/peers/edit_peer_info_box.h" +#include "settings/settings_common.h" #include "data/data_peer_values.h" #include "data/data_channel.h" +#include "data/data_changes.h" #include "data/data_chat.h" #include "data/data_user.h" #include "base/unixtime.h" @@ -41,13 +46,71 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_layers.h" #include "styles/style_boxes.h" #include "styles/style_info.h" +#include "styles/style_settings.h" namespace { constexpr auto kMaxRestrictDelayDays = 366; constexpr auto kSecondsInDay = 24 * 60 * 60; constexpr auto kSecondsInWeek = 7 * kSecondsInDay; -constexpr auto kAdminRoleLimit = 16; + +class Cover final : public Ui::FixedHeightWidget { +public: + Cover(QWidget *parent, not_null user, bool hasAdminRights); + +private: + void setupChildGeometry(); + + const style::InfoProfileCover &_st; + const not_null _user; + object_ptr _userpic; + object_ptr _name = { nullptr }; + object_ptr _status = { nullptr }; + +}; + +Cover::Cover( + QWidget *parent, + not_null user, + bool hasAdminRights) +: FixedHeightWidget(parent, st::infoEditContactCover.height) +, _st(st::infoEditContactCover) +, _user(user) +, _userpic(this, _user, _st.photo) { + _userpic->setAttribute(Qt::WA_TransparentForMouseEvents); + + _name = object_ptr(this, _st.name); + _name->setText(_user->name()); + + const auto statusText = [&] { + if (_user->isBot()) { + const auto seesAllMessages = _user->botInfo->readsAllHistory + || hasAdminRights; + return (seesAllMessages + ? tr::lng_status_bot_reads_all + : tr::lng_status_bot_not_reads_all)(tr::now); + } + return Data::OnlineText(_user->lastseen(), base::unixtime::now()); + }(); + _status = object_ptr(this, _st.status); + _status->setText(statusText); + _status->setAttribute(Qt::WA_TransparentForMouseEvents); + + setupChildGeometry(); +} + +void Cover::setupChildGeometry() { + widthValue( + ) | rpl::on_next([this](int newWidth) { + _userpic->moveToLeft(_st.photoLeft, _st.photoTop, newWidth); + auto nameWidth = newWidth - _st.nameLeft - _st.rightSkip; + _name->resizeToNaturalWidth(nameWidth); + _name->moveToLeft(_st.nameLeft, _st.nameTop, newWidth); + auto statusWidth = newWidth - _st.statusLeft - _st.rightSkip; + _status->resizeToNaturalWidth(statusWidth); + _status->moveToLeft(_st.statusLeft, _st.statusTop, newWidth); + }, lifetime()); +} } // namespace @@ -66,6 +129,8 @@ public: return _rows; } + void setCoverMode(bool enabled); + protected: int resizeGetHeight(int newWidth) override; void paintEvent(QPaintEvent *e) override; @@ -76,6 +141,7 @@ private: object_ptr _userPhoto; Ui::Text::String _userName; bool _hasAdminRights = false; + bool _coverMode = false; object_ptr _rows; }; @@ -110,7 +176,20 @@ Widget *EditParticipantBox::Inner::addControl( return _rows->add(std::move(widget), margin); } +void EditParticipantBox::Inner::setCoverMode(bool enabled) { + _coverMode = enabled; + if (enabled) { + _userPhoto->hide(); + } + resizeToWidth(width()); +} + int EditParticipantBox::Inner::resizeGetHeight(int newWidth) { + if (_coverMode) { + _rows->resizeToWidth(newWidth); + _rows->moveToLeft(0, 0, newWidth); + return _rows->heightNoMargins(); + } _userPhoto->moveToLeft( st::rightsPhotoMargin.left(), st::rightsPhotoMargin.top()); @@ -127,6 +206,10 @@ void EditParticipantBox::Inner::paintEvent(QPaintEvent *e) { p.fillRect(e->rect(), st::boxBg); + if (_coverMode) { + return; + } + p.setPen(st::contactsNameFg); auto namex = st::rightsPhotoMargin.left() + st::rightsPhotoButton.size .width() @@ -198,6 +281,12 @@ bool EditParticipantBox::amCreator() const { Unexpected("Peer type in EditParticipantBox::Inner::amCreator."); } +void EditParticipantBox::setCoverMode(bool enabled) { + Expects(_inner != nullptr); + + _inner->setCoverMode(enabled); +} + EditAdminBox::EditAdminBox( QWidget*, not_null peer, @@ -234,7 +323,8 @@ ChatAdminRightsInfo EditAdminBox::defaultRights() const { | Flag::InviteByLinkOrAdd | Flag::ManageTopics | Flag::PinMessages - | Flag::ManageCall) } + | Flag::ManageCall + | Flag::ManageRanks) } : ChatAdminRightsInfo{ (Flag::ChangeInfo | Flag::PostMessages | Flag::EditMessages @@ -255,6 +345,11 @@ void EditAdminBox::prepare() { EditParticipantBox::prepare(); + setCoverMode(true); + addControl( + object_ptr(this, user(), hasAdminRights()), + style::margins()); + setTitle(_addingBot ? (_addingBot->existing ? tr::lng_rights_edit_admin() @@ -395,7 +490,6 @@ void EditAdminBox::prepare() { Ui::AddSkip(emptyAboutAddAdminsInner->entity()); if (hasRank) { Ui::AddDivider(emptyAboutAddAdminsInner->entity()); - Ui::AddSkip(emptyAboutAddAdminsInner->entity()); } Ui::AddSkip(aboutAddAdminsInner->entity()); Ui::AddDividerText( @@ -433,7 +527,65 @@ void EditAdminBox::prepare() { } if (canSave()) { - _rank = hasRank ? addRankInput(inner).get() : nullptr; + if (hasRank) { + const auto role = _oldRights.flags + ? LookupBadgeRole(peer(), user()) + : HistoryView::BadgeRole::Admin; + _tagControl = inner->add( + object_ptr( + inner, + &peer()->session(), + user(), + _oldRank, + role), + style::margins()); + } + if (_tagControl) { + Ui::AddSkip(inner); + Ui::AddDividerText( + inner, + user()->isSelf() + ? tr::lng_rights_tag_about_self() + : tr::lng_rights_tag_about( + lt_name, + rpl::single(user()->shortName()))); + } + if (_oldRights.flags && !_addingBot) { + const auto isTargetCreator = (LookupBadgeRole(peer(), user()) + == HistoryView::BadgeRole::Creator); + if (!isTargetCreator) { + if (!_tagControl) { + Ui::AddSkip(inner); + inner->add( + object_ptr(inner), + { 0, st::infoProfileSkip, 0, st::infoProfileSkip }); + } + Ui::AddSkip(inner); + const auto dismissButton = Settings::AddButtonWithIcon( + inner, + tr::lng_rights_dismiss_admin(), + st::settingsAttentionButton, + { nullptr }); + dismissButton->setClickedCallback([=] { + if (const auto callback = _saveCallback) { + const auto old = _oldRights; + const auto confirmed = [=](Fn close) { + callback(old, {}, {}); + close(); + }; + uiShow()->showBox( + Ui::MakeConfirmBox({ + .text = tr::lng_profile_sure_remove_admin( + tr::now, + lt_user, + user()->firstName), + .confirmed = confirmed, + .confirmText = tr::lng_box_remove(), + })); + } + }); + } + } _finishSave = [=, value = getChecked] { const auto newFlags = (value() | ChatAdminRight::Other) & ((!channel || channel->amCreator()) @@ -442,7 +594,9 @@ void EditAdminBox::prepare() { _saveCallback( _oldRights, ChatAdminRightsInfo(newFlags), - _rank ? _rank->getLastText().trimmed() : QString()); + _tagControl + ? std::optional(_tagControl->currentRank()) + : std::nullopt); }; _save = [=] { const auto show = uiShow(); @@ -497,56 +651,6 @@ void EditAdminBox::refreshButtons() { } } -not_null EditAdminBox::addRankInput( - not_null container) { - // Ui::AddDivider(container); - - container->add( - object_ptr( - container, - tr::lng_rights_edit_admin_rank_name(), - st::rightsHeaderLabel), - st::rightsHeaderMargin); - - const auto isOwner = [&] { - if (user()->isSelf() && amCreator()) { - return true; - } else if (const auto chat = peer()->asChat()) { - return chat->creator == peerToUser(user()->id); - } else if (const auto channel = peer()->asChannel()) { - return channel->mgInfo && channel->mgInfo->creator == user(); - } - Unexpected("Peer type in EditAdminBox::addRankInput."); - }(); - const auto result = container->add( - object_ptr( - container, - st::customBadgeField, - (isOwner ? tr::lng_owner_badge : tr::lng_admin_badge)(), - TextUtilities::RemoveEmoji(_oldRank)), - st::rightsAboutMargin); - result->setMaxLength(kAdminRoleLimit); - result->setInstantReplaces(Ui::InstantReplaces::TextOnly()); - result->changes( - ) | rpl::on_next([=] { - const auto text = result->getLastText(); - const auto removed = TextUtilities::RemoveEmoji(text); - if (removed != text) { - result->setText(removed); - } - }, result->lifetime()); - - Ui::AddSkip(container); - Ui::AddDividerText( - container, - tr::lng_rights_edit_admin_rank_about( - lt_title, - (isOwner ? tr::lng_owner_badge : tr::lng_admin_badge)())); - Ui::AddSkip(container); - - return result; -} - bool EditAdminBox::canTransferOwnership() const { if (user()->isInaccessible() || user()->isBot() || user()->isSelf()) { return false; @@ -599,10 +703,12 @@ EditRestrictedBox::EditRestrictedBox( not_null user, bool hasAdminRights, ChatRestrictionsInfo rights, + const QString &rank, UserData *by, TimeId since) : EditParticipantBox(nullptr, peer, user, hasAdminRights) , _oldRights(rights) +, _oldRank(rank) , _by(by) , _since(since) { } @@ -613,6 +719,11 @@ void EditRestrictedBox::prepare() { EditParticipantBox::prepare(); + setCoverMode(true); + addControl( + object_ptr(this, user(), hasAdminRights()), + style::margins()); + setTitle(tr::lng_rights_user_restrictions()); Ui::AddDivider(verticalLayout()); @@ -658,13 +769,36 @@ void EditRestrictedBox::prepare() { this, prepareFlags, disabledMessages, - { .isForum = peer()->isForum() }); + { .isForum = peer()->isForum(), .isUserSpecific = true }); addControl(std::move(checkboxes), QMargins()); + if (canSave() && peer()->canManageRanks()) { + Ui::AddSkip(verticalLayout()); + Ui::AddDivider(verticalLayout()); + _tagControl = addControl( + object_ptr( + this, + &peer()->session(), + user(), + _oldRank, + HistoryView::BadgeRole::User), + style::margins()); + Ui::AddSkip(verticalLayout()); + Ui::AddDividerText( + verticalLayout(), + user()->isSelf() + ? tr::lng_rights_tag_about_self() + : tr::lng_rights_tag_about( + lt_name, + rpl::single(user()->shortName()))); + } + _until = prepareRights.until; - addControl( - object_ptr(this, st::defaultVerticalListSkip)); - Ui::AddDivider(verticalLayout()); + if (!_tagControl) { + addControl( + object_ptr(this, st::defaultVerticalListSkip)); + Ui::AddDivider(verticalLayout()); + } addControl( object_ptr( this, @@ -703,6 +837,107 @@ void EditRestrictedBox::prepare() { } if (canSave()) { + Ui::AddSkip(verticalLayout()); + Ui::AddDivider(verticalLayout()); + Ui::AddSkip(verticalLayout()); + + const auto canAddAdmins = (chat && chat->canAddAdmins()) + || (channel && channel->canAddAdmins()); + if (canAddAdmins) { + const auto promoteButton = Settings::AddButtonWithIcon( + verticalLayout(), + tr::lng_rights_promote_member(), + st::settingsButtonNoIcon, + { nullptr }); + promoteButton->setClickedCallback([=] { + const auto rank = _tagControl + ? _tagControl->currentRank() + : _oldRank; + auto adminBox = Box( + peer(), + user(), + ChatAdminRightsInfo(), + rank, + TimeId(0), + nullptr); + const auto adminBoxWeak = QPointer( + adminBox.data()); + const auto show = uiShow(); + const auto restrictWeak = QPointer( + this); + const auto closeBoth = [=] { + if (adminBoxWeak) { + adminBoxWeak->closeBox(); + } + if (restrictWeak) { + restrictWeak->closeBox(); + } + }; + const auto savedUser = user(); + const auto savedPeer = peer(); + const auto done = [=]( + ChatAdminRightsInfo newRights, + const std::optional &rank) { + closeBoth(); + const auto effectiveRank = rank.value_or([&] { + const auto ch = savedPeer->asChannel(); + if (!ch) { + return QString(); + } + const auto info = ch->mgInfo.get(); + if (!info) { + return QString(); + } + const auto i = info->memberRanks.find( + peerToUser(savedUser->id)); + return (i != end(info->memberRanks)) + ? i->second + : QString(); + }()); + savedUser->session().changes().chatAdminChanged( + savedPeer, + savedUser, + newRights.flags, + effectiveRank); + }; + const auto fail = closeBoth; + adminBox->setSaveCallback( + SaveAdminCallback( + show, + peer(), + user(), + done, + fail)); + show->showBox(std::move(adminBox)); + }); + } + + const auto removeButton = Settings::AddButtonWithIcon( + verticalLayout(), + tr::lng_rights_remove_member(), + st::settingsAttentionButton, + { nullptr }); + removeButton->setClickedCallback([=] { + if (const auto callback = _saveCallback) { + const auto old = _oldRights; + const auto confirmed = [=](Fn close) { + callback( + old, + ChannelData::KickedRestrictedRights(user())); + close(); + }; + uiShow()->show( + Ui::MakeConfirmBox({ + .text = tr::lng_profile_sure_kick( + tr::now, + lt_user, + user()->firstName), + .confirmed = confirmed, + .confirmText = tr::lng_box_remove(), + })); + } + }); + const auto save = [=, value = getRestrictions] { if (!_saveCallback) { return; @@ -710,6 +945,18 @@ void EditRestrictedBox::prepare() { _saveCallback( _oldRights, ChatRestrictionsInfo{ value(), getRealUntilValue() }); + if (_tagControl) { + const auto rank = _tagControl->currentRank(); + if (rank != _oldRank) { + SaveMemberRank( + uiShow(), + peer(), + user(), + rank, + nullptr, + nullptr); + } + } }; addButton(tr::lng_settings_save(), save); addButton(tr::lng_cancel(), [=] { closeBox(); }); diff --git a/Telegram/SourceFiles/boxes/peers/edit_participant_box.h b/Telegram/SourceFiles/boxes/peers/edit_participant_box.h index 79f9f6294b..b3c1df3cfb 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_participant_box.h +++ b/Telegram/SourceFiles/boxes/peers/edit_participant_box.h @@ -23,6 +23,7 @@ class SlideWrap; } // namespace Ui class ChannelOwnershipTransfer; +class EditTagControl; class EditParticipantBox : public Ui::BoxContent { public: @@ -48,6 +49,8 @@ protected: template Widget *addControl(object_ptr widget, QMargins margin = {}); + void setCoverMode(bool enabled); + bool hasAdminRights() const { return _hasAdminRights; } @@ -83,7 +86,7 @@ public: Fn callback) { + const std::optional &rank)> callback) { _saveCallback = std::move(callback); } @@ -93,15 +96,13 @@ protected: private: [[nodiscard]] ChatAdminRightsInfo defaultRights() const; - not_null addRankInput( - not_null container); void transferOwnership(); - bool canSave() const { + [[nodiscard]] bool canSave() const { return _saveCallback != nullptr; } void finishAddAdmin(); void refreshButtons(); - bool canTransferOwnership() const; + [[nodiscard]] bool canTransferOwnership() const; not_null*> setupTransferButton( not_null container, bool isGroup); @@ -111,12 +112,12 @@ private: Fn _saveCallback; + const std::optional &rank)> _saveCallback; base::weak_qptr _confirmBox; Ui::Checkbox *_addAsAdmin = nullptr; Ui::SlideWrap *_adminControlsWrap = nullptr; - Ui::InputField *_rank = nullptr; + EditTagControl *_tagControl = nullptr; Fn _save, _finishSave; @@ -139,6 +140,7 @@ public: not_null user, bool hasAdminRights, ChatRestrictionsInfo rights, + const QString &rank, UserData *by, TimeId since); @@ -164,6 +166,8 @@ private: TimeId getRealUntilValue() const; const ChatRestrictionsInfo _oldRights; + const QString _oldRank; + EditTagControl *_tagControl = nullptr; UserData *_by = nullptr; TimeId _since = 0; TimeId _until = 0; diff --git a/Telegram/SourceFiles/boxes/peers/edit_participants_box.cpp b/Telegram/SourceFiles/boxes/peers/edit_participants_box.cpp index 66de189874..e3adb3f6df 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_participants_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_participants_box.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_chat_participants.h" #include "boxes/peers/edit_participant_box.h" +#include "boxes/peers/edit_tag_control.h" #include "boxes/peers/add_participants_box.h" #include "boxes/peers/prepare_short_info_box.h" // PrepareShortInfoBox #include "boxes/peers/edit_members_visible.h" @@ -29,13 +30,19 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_user.h" #include "data/data_changes.h" #include "base/unixtime.h" +#include "ui/chat/chat_style.h" #include "ui/effects/outline_segments.h" +#include "ui/layers/generic_box.h" +#include "ui/vertical_list.h" +#include "ui/widgets/fields/input_field.h" #include "ui/widgets/menu/menu_multiline_action.h" #include "ui/widgets/popup_menu.h" #include "ui/text/text_utilities.h" #include "info/profile/info_profile_values.h" #include "window/window_session_controller.h" #include "history/history.h" +#include "history/view/history_view_message.h" +#include "styles/style_boxes.h" #include "styles/style_chat.h" #include "styles/style_menu_icons.h" @@ -50,13 +57,16 @@ constexpr auto kParticipantsPerPage = 200; constexpr auto kSortByOnlineDelay = crl::time(1000); void RemoveAdmin( + std::shared_ptr show, not_null channel, not_null user, ChatAdminRightsInfo oldRights, Fn onDone, Fn onFail) { const auto newRights = MTP_chatAdminRights(MTP_flags(0)); + using Flag = MTPchannels_editAdmin::Flag; channel->session().api().request(MTPchannels_EditAdmin( + MTP_flags(Flag::f_rank), channel->inputChannel(), user->inputUser(), newRights, @@ -67,7 +77,10 @@ void RemoveAdmin( if (onDone) { onDone(); } - }).fail([=] { + }).fail([=](const MTP::Error &error) { + if (show) { + show->showToast(error.type()); + } if (onFail) { onFail(); } @@ -134,8 +147,13 @@ void SaveChatAdmin( onFail, false); }, onFail); - } else if (onFail) { - onFail(); + } else { + if (show) { + show->showToast(error.type()); + } + if (onFail) { + onFail(); + } } }).send(); } @@ -146,17 +164,31 @@ void SaveChannelAdmin( not_null user, ChatAdminRightsInfo oldRights, ChatAdminRightsInfo newRights, - const QString &rank, + const std::optional &rank, Fn onDone, Fn onFail) { + using Flag = MTPchannels_editAdmin::Flag; + const auto flags = Flag(0) + | (rank.has_value() ? Flag::f_rank : Flag(0)); channel->session().api().request(MTPchannels_EditAdmin( + MTP_flags(flags), channel->inputChannel(), user->inputUser(), AdminRightsToMTP(newRights), - MTP_string(rank) + rank ? MTP_string(*rank) : MTPstring() )).done([=](const MTPUpdates &result) { channel->session().api().applyUpdates(result); - channel->applyEditAdmin(user, oldRights, newRights, rank); + const auto effectiveRank = rank.value_or([&] { + if (const auto info = channel->mgInfo.get()) { + const auto i = info->memberRanks.find( + peerToUser(user->id)); + if (i != end(info->memberRanks)) { + return i->second; + } + } + return QString(); + }()); + channel->applyEditAdmin(user, oldRights, newRights, effectiveRank); if (onDone) { onDone(); } @@ -169,6 +201,7 @@ void SaveChannelAdmin( } void SaveChatParticipantKick( + std::shared_ptr show, not_null chat, not_null user, Fn onDone, @@ -182,7 +215,10 @@ void SaveChatParticipantKick( if (onDone) { onDone(); } - }).fail([=] { + }).fail([=](const MTP::Error &error) { + if (show) { + show->showToast(error.type()); + } if (onFail) { onFail(); } @@ -191,21 +227,71 @@ void SaveChatParticipantKick( } // namespace +void SaveMemberRank( + std::shared_ptr show, + not_null peer, + not_null user, + const QString &rank, + Fn onDone, + Fn onFail) { + peer->session().api().request(MTPmessages_EditChatParticipantRank( + peer->input(), + user->input(), + MTP_string(rank) + )).done([=](const MTPUpdates &result) { + peer->session().api().applyUpdates(result); + if (const auto channel = peer->asChannel()) { + channel->applyEditMemberRank(user, rank); + } else if (const auto chat = peer->asChat()) { + const auto userId = peerToUser(user->id); + if (rank.isEmpty()) { + chat->memberRanks.remove(userId); + } else { + chat->memberRanks[userId] = rank; + } + if (userId != chat->session().userId()) { + if (const auto history = chat->owner().historyLoaded(chat)) { + auto changes = base::flat_set(); + changes.emplace(userId); + history->applyGroupAdminChanges(changes); + } + } + chat->session().changes().peerUpdated( + chat, + Data::PeerUpdate::Flag::Members); + } + peer->session().changes().chatMemberRankChanged( + peer, + user, + rank); + if (onDone) { + onDone(); + } + }).fail([=](const MTP::Error &error) { + if (show) { + show->showToast(error.type()); + } + if (onFail) { + onFail(); + } + }).send(); +} + Fn SaveAdminCallback( + const std::optional &rank)> SaveAdminCallback( std::shared_ptr show, not_null peer, not_null user, Fn onDone, + const std::optional &rank)> onDone, Fn onFail) { return [=]( ChatAdminRightsInfo oldRights, ChatAdminRightsInfo newRights, - const QString &rank) { + const std::optional &rank) { const auto done = [=] { if (onDone) onDone(newRights, rank); }; const auto saveForChannel = [=](not_null channel) { SaveChannelAdmin( @@ -221,9 +307,11 @@ FnasChatNotMigrated()) { const auto saveChatAdmin = [&](bool isAdmin) { SaveChatAdmin(show, chat, user, isAdmin, done, onFail); + if (rank) { + SaveMemberRank(show, chat, user, *rank, [] {}, [] {}); + } }; - if (newRights.flags == chat->defaultAdminRights(user).flags - && rank.isEmpty()) { + if (newRights.flags == chat->defaultAdminRights(user).flags) { saveChatAdmin(true); } else if (!newRights.flags) { saveChatAdmin(false); @@ -241,6 +329,7 @@ Fn SaveRestrictedCallback( + std::shared_ptr show, not_null peer, not_null participant, Fn onDone, @@ -256,12 +345,20 @@ FnshowToast(errorType); + } + if (onFail) { + onFail(); + } + }); }; if (const auto chat = peer->asChatNotMigrated()) { if (participant->isUser() && (newRights.flags & ChatRestriction::ViewMessages)) { SaveChatParticipantKick( + show, chat, participant->asUser(), done, @@ -383,10 +480,10 @@ auto ParticipantsAdditionalData::adminRights( : std::nullopt; } -QString ParticipantsAdditionalData::adminRank( +QString ParticipantsAdditionalData::memberRank( not_null user) const { - const auto i = _adminRanks.find(user); - return (i != end(_adminRanks)) ? i->second : QString(); + const auto i = _memberRanks.find(user); + return (i != end(_memberRanks)) ? i->second : QString(); } TimeId ParticipantsAdditionalData::adminPromotedSince( @@ -460,7 +557,6 @@ void ParticipantsAdditionalData::setExternal( _adminRights.erase(user); _adminCanEdit.erase(user); _adminPromotedBy.erase(user); - _adminRanks.erase(user); _admins.erase(user); } _restrictedRights.erase(participant); @@ -504,6 +600,9 @@ void ParticipantsAdditionalData::fillFromChat(not_null chat) { } _members = chat->participants; _admins = chat->admins; + for (const auto &[uid, rank] : chat->memberRanks) { + _memberRanks[chat->owner().user(uid)] = rank; + } } void ParticipantsAdditionalData::fillFromChannel( @@ -514,11 +613,10 @@ void ParticipantsAdditionalData::fillFromChannel( } if (information->creator) { _creator = information->creator; - _adminRanks[information->creator] = information->creatorRank; } - for (const auto user : information->lastParticipants) { + for (const auto &user : information->lastParticipants) { const auto admin = information->lastAdmins.find(user); - const auto rank = information->admins.find(peerToUser(user->id)); + const auto rank = information->memberRanks.find(peerToUser(user->id)); const auto restricted = information->lastRestricted.find(user); if (admin != information->lastAdmins.cend()) { _restrictedRights.erase(user); @@ -530,15 +628,14 @@ void ParticipantsAdditionalData::fillFromChannel( _adminCanEdit.erase(user); } _adminRights.emplace(user, admin->second.rights); - if (rank != end(information->admins) + if (rank != end(information->memberRanks) && !rank->second.isEmpty()) { - _adminRanks[user] = rank->second; + _memberRanks[user] = rank->second; } } else if (restricted != information->lastRestricted.cend()) { _adminRights.erase(user); _adminCanEdit.erase(user); _adminPromotedBy.erase(user); - _adminRanks.erase(user); _restrictedRights.emplace(user, restricted->second.rights); } } @@ -610,6 +707,16 @@ void ParticipantsAdditionalData::applyBannedLocally( } } +void ParticipantsAdditionalData::applyMemberRankLocally( + not_null user, + const QString &rank) { + if (rank.isEmpty()) { + _memberRanks.remove(user); + } else { + _memberRanks[user] = rank; + } +} + PeerData *ParticipantsAdditionalData::applyParticipant( const Api::ChatParticipant &data) { return applyParticipant(data, _role); @@ -626,43 +733,53 @@ PeerData *ParticipantsAdditionalData::applyParticipant( return nullptr; }; - switch (data.type()) { - case Api::ChatParticipant::Type::Creator: { - if (overrideRole != Role::Profile - && overrideRole != Role::Members - && overrideRole != Role::Admins) { - return logBad(); + const auto result = [&]() -> PeerData* { + switch (data.type()) { + case Api::ChatParticipant::Type::Creator: { + if (overrideRole != Role::Profile + && overrideRole != Role::Members + && overrideRole != Role::Admins) { + return logBad(); + } + return applyCreator(data); + } + case Api::ChatParticipant::Type::Admin: { + if (overrideRole != Role::Profile + && overrideRole != Role::Members + && overrideRole != Role::Admins) { + return logBad(); + } + return applyAdmin(data); + } + case Api::ChatParticipant::Type::Member: { + if (overrideRole != Role::Profile + && overrideRole != Role::Members) { + return logBad(); + } + return applyRegular(data.userId()); + } + case Api::ChatParticipant::Type::Restricted: + case Api::ChatParticipant::Type::Banned: + if (overrideRole != Role::Profile + && overrideRole != Role::Members + && overrideRole != Role::Restricted + && overrideRole != Role::Kicked) { + return logBad(); + } + return applyBanned(data); + case Api::ChatParticipant::Type::Left: + return logBad(); + }; + Unexpected("Api::ChatParticipant::type in applyParticipant."); + }(); + if (const auto user = result ? result->asUser() : nullptr) { + if (!data.rank().isEmpty()) { + _memberRanks[user] = data.rank(); + } else { + _memberRanks.remove(user); } - return applyCreator(data); } - case Api::ChatParticipant::Type::Admin: { - if (overrideRole != Role::Profile - && overrideRole != Role::Members - && overrideRole != Role::Admins) { - return logBad(); - } - return applyAdmin(data); - } - case Api::ChatParticipant::Type::Member: { - if (overrideRole != Role::Profile - && overrideRole != Role::Members) { - return logBad(); - } - return applyRegular(data.userId()); - } - case Api::ChatParticipant::Type::Restricted: - case Api::ChatParticipant::Type::Banned: - if (overrideRole != Role::Profile - && overrideRole != Role::Members - && overrideRole != Role::Restricted - && overrideRole != Role::Kicked) { - return logBad(); - } - return applyBanned(data); - case Api::ChatParticipant::Type::Left: - return logBad(); - }; - Unexpected("Api::ChatParticipant::type in applyParticipant."); + return result; } UserData *ParticipantsAdditionalData::applyCreator( @@ -675,11 +792,6 @@ UserData *ParticipantsAdditionalData::applyCreator( } else { _adminCanEdit.erase(user); } - if (!data.rank().isEmpty()) { - _adminRanks[user] = data.rank(); - } else { - _adminRanks.remove(user); - } return user; } return nullptr; @@ -706,11 +818,6 @@ UserData *ParticipantsAdditionalData::applyAdmin( } else { _adminCanEdit.erase(user); } - if (!data.rank().isEmpty()) { - _adminRanks[user] = data.rank(); - } else { - _adminRanks.remove(user); - } if (data.promotedSince()) { _adminPromotedSince[user] = data.promotedSince(); } else { @@ -744,7 +851,6 @@ UserData *ParticipantsAdditionalData::applyRegular(UserId userId) { _adminRights.erase(user); _adminCanEdit.erase(user); _adminPromotedBy.erase(user); - _adminRanks.erase(user); _restrictedRights.erase(user); _kicked.erase(user); _restrictedBy.erase(user); @@ -763,7 +869,6 @@ PeerData *ParticipantsAdditionalData::applyBanned( _adminRights.erase(user); _adminCanEdit.erase(user); _adminPromotedBy.erase(user); - _adminRanks.erase(user); } if (data.isKicked()) { _kicked.emplace(participant); @@ -878,12 +983,16 @@ ParticipantsBoxController::ParticipantsBoxController( : ParticipantsBoxController(CreateTag(), navigation, peer, role) { } +ParticipantsBoxController::~ParticipantsBoxController() = default; + ParticipantsBoxController::ParticipantsBoxController( CreateTag, Window::SessionNavigation *navigation, not_null peer, Role role) : PeerListController(CreateSearchController(peer, role, &_additional)) +, _chatStyle( + std::make_unique(peer->session().colorIndicesValue())) , _navigation(navigation) , _peer(peer) , _api(&_peer->session().mtp()) @@ -1033,7 +1142,7 @@ void ParticipantsBoxController::addNewItem() { const auto adminDone = crl::guard(this, [=]( not_null user, ChatAdminRightsInfo rights, - const QString &rank) { + const std::optional &rank) { editAdminDone(user, rights, rank); }); const auto restrictedDone = crl::guard(this, [=]( @@ -1094,18 +1203,20 @@ void ParticipantsBoxController::peerListSearchAddRow( std::unique_ptr ParticipantsBoxController::createSearchRow( not_null peer) { - if (const auto user = peer->asUser()) { - return createRow(user); + if (_role == Role::Profile + || _role == Role::Members + || _role == Role::Admins) { + if (const auto user = peer->asUser()) { + return createRow(user); + } + return nullptr; } - return nullptr; + return createRow(peer); } std::unique_ptr ParticipantsBoxController::createRestoredRow( not_null peer) { - if (const auto user = peer->asUser()) { - return createRow(user); - } - return nullptr; + return createSearchRow(peer); } auto ParticipantsBoxController::saveState() const @@ -1300,6 +1411,20 @@ void ParticipantsBoxController::prepare() { recomputeTypeFor(user); refreshRows(); }, lifetime()); + + _peer->session().changes().chatMemberRankChanges( + ) | rpl::on_next([=](const Data::ChatMemberRankChange &update) { + if (update.peer != _peer) { + return; + } + _additional.applyMemberRankLocally(update.user, update.rank); + recomputeTypeFor(update.user); + refreshRows(); + }, lifetime()); + + style::PaletteChanged() | rpl::on_next([=] { + _pillCircleCache.clear(); + }, lifetime()); } void ParticipantsBoxController::unload() { @@ -1450,7 +1575,7 @@ void ParticipantsBoxController::rebuildChatAdmins( delegate()->peerListRemoveRow( delegate()->peerListRowAt(0)); } - for (const auto user : list) { + for (const auto &user : list) { if (auto row = createRow(user)) { const auto raw = row.get(); delegate()->peerListAppendRow(std::move(row)); @@ -1473,14 +1598,11 @@ void ParticipantsBoxController::chatListReady() { } void ParticipantsBoxController::rebuildRowTypes() { - if (_role != Role::Profile) { - return; - } const auto count = delegate()->peerListFullRowsCount(); for (auto i = 0; i != count; ++i) { const auto row = static_cast( delegate()->peerListRowAt(i).get()); - row->setType(computeType(row->user())); + row->setType(computeType(row->peer())); } refreshRows(); } @@ -1610,7 +1732,7 @@ bool ParticipantsBoxController::feedMegagroupLastParticipants() { auto added = false; _additional.fillFromPeer(); - for (const auto user : info->lastParticipants) { + for (const auto &user : info->lastParticipants) { if (appendRow(user)) { added = true; } @@ -1643,6 +1765,16 @@ void ParticipantsBoxController::rowClicked(not_null row) { && (_peer->isChat() || _peer->isMegagroup()) && user) { showRestricted(user); + } else if (_role == Role::Members + && user + && (_additional.adminRights(user).has_value() + || _additional.isCreator(user)) + && _additional.canAddOrEditAdmin(user)) { + showAdmin(user); + } else if (_role == Role::Members + && user + && _additional.canRestrictParticipant(participant)) { + showRestricted(user); } else { Assert(_navigation != nullptr); if (_role != Role::Profile) { @@ -1657,15 +1789,46 @@ void ParticipantsBoxController::rowClicked(not_null row) { void ParticipantsBoxController::rowRightActionClicked( not_null row) { + rowElementClicked(row, Row::kRemoveElement); +} + +void ParticipantsBoxController::rowElementClicked( + not_null row, + int element) { const auto participant = row->peer(); const auto user = participant->asUser(); - if (_role == Role::Members || _role == Role::Profile) { - kickParticipant(participant); - } else if (_role == Role::Admins) { - Assert(user != nullptr); - removeAdmin(user); - } else { - removeKicked(row, participant); + const auto memberRow = static_cast(row.get()); + if (element == Row::kTagElement) { + if (!user) { + return; + } + if (memberRow->type().canAddTag || memberRow->type().canEditTag) { + const auto show = delegate()->peerListUiShow(); + const auto peer = _peer; + const auto currentRank = _additional.memberRank(user); + const auto isSelf = user->isSelf(); + show->show(Box( + EditCustomRankBox, + show, + peer, + user, + currentRank, + isSelf, + crl::guard(this, [=](const QString &rank) { + _additional.applyMemberRankLocally(user, rank); + recomputeTypeFor(user); + refreshRows(); + }))); + } + } else if (element == Row::kRemoveElement) { + if (_role == Role::Members || _role == Role::Profile) { + kickParticipant(participant); + } else if (_role == Role::Admins) { + Assert(user != nullptr); + removeAdmin(user); + } else { + removeKicked(row, participant); + } } } @@ -1738,6 +1901,48 @@ base::unique_qptr ParticipantsBoxController::rowContextMenu( ? &st::menuIconProfile : &st::menuIconInfo)); } + if (user) { + const auto isSelf = user->isSelf(); + const auto canEditSelf = isSelf + && !_peer->amRestricted(ChatRestriction::EditRank); + const auto targetIsAdmin = _additional.adminRights(user).has_value() + || _additional.isCreator(user); + const auto canEditTarget = !isSelf + && _peer->canManageRanks() + && (!targetIsAdmin || _additional.canEditAdmin(user)); + if (canEditSelf || canEditTarget) { + const auto currentRank = _additional.memberRank(user); + const auto show = delegate()->peerListUiShow(); + const auto peer = _peer; + const auto actionText = canEditSelf + ? (currentRank.isEmpty() + ? tr::lng_context_add_my_tag(tr::now) + : tr::lng_context_edit_my_tag(tr::now)) + : (currentRank.isEmpty() + ? tr::lng_context_add_member_tag(tr::now) + : tr::lng_context_edit_member_tag(tr::now)); + const auto weak = base::make_weak(this); + result->addAction( + actionText, + [=] { + show->show(Box( + EditCustomRankBox, + show, + peer, + user, + currentRank, + canEditSelf, + crl::guard(weak, [=](const QString &rank) { + _additional.applyMemberRankLocally(user, rank); + recomputeTypeFor(user); + refreshRows(); + }))); + }, + (currentRank.isEmpty() + ? &st::menuIconTagAdd + : &st::menuIconTagEdit)); + } + } if (_role == Role::Kicked) { if (_peer->isMegagroup() && _additional.canRestrictParticipant(participant)) { @@ -1801,13 +2006,13 @@ void ParticipantsBoxController::showAdmin(not_null user) { _peer, user, currentRights, - _additional.adminRank(user), + _additional.memberRank(user), _additional.adminPromotedSince(user), _additional.adminPromotedBy(user)); if (_additional.canAddOrEditAdmin(user)) { const auto done = crl::guard(this, [=]( ChatAdminRightsInfo newRights, - const QString &rank) { + const std::optional &rank) { editAdminDone(user, newRights, rank); }); const auto fail = crl::guard(this, [=] { @@ -1825,13 +2030,20 @@ void ParticipantsBoxController::showAdmin(not_null user) { void ParticipantsBoxController::editAdminDone( not_null user, ChatAdminRightsInfo rights, - const QString &rank) { + const std::optional &rank) { _addBox = nullptr; if (_editParticipantBox) { _editParticipantBox->closeBox(); } + + const auto effectiveRank = rank.value_or( + _additional.memberRank(user)); + _additional.applyAdminLocally(user, rights, effectiveRank); + recomputeTypeFor(user); + refreshRows(); + const auto flags = rights.flags; - user->session().changes().chatAdminChanged(_peer, user, flags, rank); + user->session().changes().chatAdminChanged(_peer, user, flags, effectiveRank); } void ParticipantsBoxController::showRestricted(not_null user) { @@ -1845,6 +2057,7 @@ void ParticipantsBoxController::showRestricted(not_null user) { user, hasAdminRights, currentRights, + _additional.memberRank(user), _additional.restrictedBy(user), _additional.restrictedSince(user)); if (_additional.canRestrictParticipant(user)) { @@ -1857,8 +2070,9 @@ void ParticipantsBoxController::showRestricted(not_null user) { _editParticipantBox->closeBox(); } }); + const auto show = delegate()->peerListUiShow(); box->setSaveCallback( - SaveRestrictedCallback(_peer, user, done, fail)); + SaveRestrictedCallback(show, _peer, user, done, fail)); } _editParticipantBox = showBox(std::move(box)); } @@ -1890,8 +2104,7 @@ void ParticipantsBoxController::editRestrictedDone( if (_role == Role::Restricted) { prependRow(participant); } else if (_role == Role::Kicked - || _role == Role::Admins - || _role == Role::Members) { + || _role == Role::Admins) { removeRow(participant); } } @@ -1902,8 +2115,33 @@ void ParticipantsBoxController::editRestrictedDone( void ParticipantsBoxController::kickParticipant(not_null participant) { const auto user = participant->asUser(); + const auto kickFrom = _peer; + const auto restrictedRights = _additional.restrictedRights(participant); + const auto removeLocal = crl::guard(this, [=] { + const auto id = participant->id; + if (const auto row = delegate()->peerListFindRow(id.value)) { + delegate()->peerListRemoveRow(row); + refreshRows(); + } + }); + const auto kick = [=] { + const auto currentRights = restrictedRights + ? *restrictedRights + : ChatRestrictionsInfo(); + removeLocal(); + auto &session = kickFrom->session(); + if (const auto chat = kickFrom->asChat()) { + session.api().chatParticipants().kick(chat, participant); + } else if (const auto channel = kickFrom->asChannel()) { + session.api().chatParticipants().kick( + channel, + participant, + currentRights); + } + }; + if (user && user->isInaccessible()) { - return kickParticipantSure(participant); + return kick(); } const auto text = ((_peer->isChat() || _peer->isMegagroup()) ? tr::lng_profile_sure_kick @@ -1911,12 +2149,10 @@ void ParticipantsBoxController::kickParticipant(not_null participant) tr::now, lt_user, user ? user->firstName : participant->name()); - _editBox = showBox( + showBox( Ui::MakeConfirmBox({ .text = text, - .confirmed = crl::guard(this, [=] { - kickParticipantSure(participant); - }), + .confirmed = [=](Fn close) { kick(); close(); }, .confirmText = tr::lng_box_remove(), })); } @@ -1931,30 +2167,6 @@ void ParticipantsBoxController::unkickParticipant(not_null user) { _peer->session().api().chatParticipants().add(show, _peer, { 1, user }); } -void ParticipantsBoxController::kickParticipantSure( - not_null participant) { - _editBox = nullptr; - - const auto restrictedRights = _additional.restrictedRights(participant); - const auto currentRights = restrictedRights - ? *restrictedRights - : ChatRestrictionsInfo(); - - if (const auto row = delegate()->peerListFindRow(participant->id.value)) { - delegate()->peerListRemoveRow(row); - refreshRows(); - } - auto &session = _peer->session(); - if (const auto chat = _peer->asChat()) { - session.api().chatParticipants().kick(chat, participant); - } else if (const auto channel = _peer->asChannel()) { - session.api().chatParticipants().kick( - channel, - participant, - currentRights); - } -} - void ParticipantsBoxController::removeAdmin(not_null user) { _editBox = showBox( Ui::MakeConfirmBox({ @@ -1973,21 +2185,16 @@ void ParticipantsBoxController::removeAdminSure(not_null user) { if (const auto chat = _peer->asChat()) { const auto show = delegate()->peerListUiShow(); SaveChatAdmin(show, chat, user, false, crl::guard(this, [=] { - editAdminDone( - user, - ChatAdminRightsInfo(), - QString()); + editAdminDone(user, {}, {}); }), nullptr); } else if (const auto channel = _peer->asChannel()) { const auto adminRights = _additional.adminRights(user); if (!adminRights) { return; } - RemoveAdmin(channel, user, *adminRights, crl::guard(this, [=] { - editAdminDone( - user, - ChatAdminRightsInfo(), - QString()); + const auto show = delegate()->peerListUiShow(); + RemoveAdmin(show, channel, user, *adminRights, crl::guard(this, [=] { + editAdminDone(user, {}, {}); }), nullptr); } } @@ -2085,69 +2292,129 @@ bool ParticipantsBoxController::removeRow(not_null participant) { std::unique_ptr ParticipantsBoxController::createRow( not_null participant) const { - const auto user = participant->asUser(); if (_role == Role::Profile) { - Assert(user != nullptr); - return std::make_unique(user, computeType(user)); + Assert(participant->asUser() != nullptr); } - const auto chat = _peer->asChat(); - const auto channel = _peer->asChannel(); - auto row = std::make_unique(participant); + auto row = std::make_unique(participant, computeType(participant)); refreshCustomStatus(row.get()); - if (_role == Role::Admins - && user - && !_additional.isCreator(user) - && _additional.adminRights(user).has_value() - && _additional.canEditAdmin(user)) { - row->setActionLink(tr::lng_profile_kick(tr::now)); - } else if (_role == Role::Kicked || _role == Role::Restricted) { - if (_additional.canRestrictParticipant(participant)) { - row->setActionLink(tr::lng_profile_delete_removed(tr::now)); - } - } else if (_role == Role::Members) { - Assert(user != nullptr); - if ((chat ? chat->canBanMembers() : channel->canBanMembers()) - && !_additional.isCreator(user) - && (!_additional.adminRights(user) - || _additional.canEditAdmin(user))) { - row->setActionLink(tr::lng_profile_kick(tr::now)); - } - if (_role == Role::Members && user->isBot()) { - auto seesAllMessages = (user->botInfo->readsAllHistory || _additional.adminRights(user).has_value()); + if (const auto user = participant->asUser()) { + if ((_role == Role::Members || _role == Role::Profile) + && user->isBot()) { + const auto seesAllMessages = + (user->botInfo->readsAllHistory + || _additional.adminRights(user).has_value()); row->setCustomStatus(seesAllMessages ? tr::lng_status_bot_reads_all(tr::now) : tr::lng_status_bot_not_reads_all(tr::now)); } } + const auto raw = row.get(); + row->setRefreshCallback(crl::guard(this, [=] { + delegate()->peerListUpdateRow(raw); + })); return row; } auto ParticipantsBoxController::computeType( not_null participant) const -> Type { const auto user = participant->asUser(); - auto result = Type(); + auto result = Type{ + .chatStyle = _chatStyle.get(), + .circleCache = &_pillCircleCache, + }; result.rights = (user && _additional.isCreator(user)) ? Rights::Creator : (user && _additional.adminRights(user).has_value()) ? Rights::Admin : Rights::Normal; - result.canRemove = _additional.canRemoveParticipant(participant) - && user - && user->isInaccessible(); - if (!result.canRemove) { - result.adminRank = user ? _additional.adminRank(user) : QString(); + + if (user) { + result.rank = _additional.memberRank(user); } + + const auto chat = _peer->asChat(); + const auto channel = _peer->asChannel(); + + switch (_role) { + case Role::Profile: { + if (user + && (chat + ? chat->canBanMembers() + : (channel && channel->canBanMembers())) + && !_additional.isCreator(user) + && (!_additional.adminRights(user) + || _additional.canEditAdmin(user))) { + result.canRemove = true; + result.removeText = tr::lng_profile_kick(tr::now); + } else if (_additional.canRemoveParticipant(participant) + && user + && user->isInaccessible()) { + result.canRemove = true; + result.rank = QString(); + result.removeText = tr::lng_profile_delete_removed(tr::now); + } + } break; + case Role::Members: { + if (user + && (chat + ? chat->canBanMembers() + : channel->canBanMembers()) + && !_additional.isCreator(user) + && (!_additional.adminRights(user) + || _additional.canEditAdmin(user))) { + result.canRemove = true; + result.removeText = tr::lng_profile_kick(tr::now); + } + } break; + case Role::Admins: { + if (user + && !_additional.isCreator(user) + && _additional.adminRights(user).has_value() + && _additional.canEditAdmin(user)) { + result.canRemove = true; + result.removeText = tr::lng_profile_kick(tr::now); + } + } break; + case Role::Restricted: + case Role::Kicked: { + if (_additional.canRestrictParticipant(participant)) { + result.canRemove = true; + result.removeText = tr::lng_profile_delete_removed(tr::now); + } + } break; + } + + if (user && !_peer->isBroadcast()) { + const auto isSelf = user->isSelf(); + const auto canEditSelf = isSelf + && !_peer->amRestricted(ChatRestriction::EditRank); + const auto targetIsAdmin = + _additional.adminRights(user).has_value() + || _additional.isCreator(user); + const auto canEditTarget = !isSelf + && _peer->canManageRanks() + && (!targetIsAdmin || _additional.canEditAdmin(user)); + if (canEditSelf || canEditTarget) { + result.canEditTag = true; + } + if (isSelf + && result.rank.isEmpty() + && !result.canRemove + && result.rights == Rights::Normal + && canEditSelf) { + result.canAddTag = true; + } + } + return result; } void ParticipantsBoxController::recomputeTypeFor( not_null participant) { - if (_role != Role::Profile) { - return; - } const auto row = delegate()->peerListFindRow(participant->id.value); if (row) { static_cast(row)->setType(computeType(participant)); + delegate()->peerListUpdateRow(row); } } @@ -2445,3 +2712,65 @@ void ParticipantsBoxSearchController::searchDone( delegate()->peerListSearchRefreshRows(); } + +void EditCustomRankBox( + not_null box, + std::shared_ptr show, + not_null peer, + not_null user, + const QString ¤tRank, + bool isSelf, + Fn onSaved) { + struct State { + bool saving = false; + }; + const auto state = box->lifetime().make_state(); + + box->setTitle(tr::lng_rights_edit_tag_title()); + + const auto role = LookupBadgeRole(peer, user); + const auto control = box->addRow( + object_ptr( + box, + &peer->session(), + user, + currentRank, + role), + style::margins()); + Ui::AddSkip(box->verticalLayout()); + Ui::AddDividerText( + box->verticalLayout(), + (isSelf + ? tr::lng_rights_tag_about_self() + : tr::lng_rights_tag_about( + lt_name, + rpl::single(user->shortName())))); + const auto field = control->field(); + + box->setFocusCallback([=] { field->setFocusFast(); }); + + const auto close = crl::guard(box, [=] { box->closeBox(); }); + const auto save = [=] { + if (state->saving) { + return; + } + state->saving = true; + const auto rank = control->currentRank(); + SaveMemberRank( + show, + peer, + user, + rank, + [=] { + if (onSaved) { + onSaved(rank); + } + close(); + }, + [=] { state->saving = false; }); + }; + field->submits( + ) | rpl::on_next([=] { save(); }, field->lifetime()); + box->addButton(tr::lng_settings_save(), save); + box->addButton(tr::lng_cancel(), close); +} diff --git a/Telegram/SourceFiles/boxes/peers/edit_participants_box.h b/Telegram/SourceFiles/boxes/peers/edit_participants_box.h index 6e14120302..6d40bec4c2 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_participants_box.h +++ b/Telegram/SourceFiles/boxes/peers/edit_participants_box.h @@ -32,23 +32,41 @@ class ChatParticipant; Fn SaveAdminCallback( + const std::optional &rank)> SaveAdminCallback( std::shared_ptr show, not_null peer, not_null user, Fn onDone, + const std::optional &rank)> onDone, Fn onFail); Fn SaveRestrictedCallback( + std::shared_ptr show, not_null peer, not_null participant, Fn onDone, Fn onFail); +void EditCustomRankBox( + not_null box, + std::shared_ptr show, + not_null peer, + not_null user, + const QString ¤tRank, + bool isSelf, + Fn onSaved); + +void SaveMemberRank( + std::shared_ptr show, + not_null peer, + not_null user, + const QString &rank, + Fn onDone, + Fn onFail); + void SubscribeToMigration( not_null peer, rpl::lifetime &lifetime, @@ -106,7 +124,7 @@ public: not_null participant) const; [[nodiscard]] std::optional adminRights( not_null user) const; - [[nodiscard]] QString adminRank(not_null user) const; + [[nodiscard]] QString memberRank(not_null user) const; [[nodiscard]] std::optional restrictedRights( not_null participant) const; [[nodiscard]] bool isCreator(not_null user) const; @@ -129,6 +147,9 @@ public: void applyBannedLocally( not_null participant, ChatRestrictionsInfo rights); + void applyMemberRankLocally( + not_null user, + const QString &rank); private: UserData *applyCreator(const Api::ChatParticipant &data); @@ -148,7 +169,7 @@ private: // Data for channels. base::flat_map, ChatAdminRightsInfo> _adminRights; - base::flat_map, QString> _adminRanks; + base::flat_map, QString> _memberRanks; base::flat_map, TimeId> _adminPromotedSince; base::flat_map, TimeId> _restrictedSince; base::flat_map, TimeId> _memberSince; @@ -178,11 +199,15 @@ public: not_null navigation, not_null peer, Role role); + ~ParticipantsBoxController(); Main::Session &session() const override; void prepare() override; void rowClicked(not_null row) override; void rowRightActionClicked(not_null row) override; + void rowElementClicked( + not_null row, + int element) override; base::unique_qptr rowContextMenu( QWidget *parent, not_null row) override; @@ -206,8 +231,10 @@ public: void setStoriesShown(bool shown); protected: - // Allow child controllers not providing navigation. - // This is their responsibility to override all methods that use it. + using Row = Info::Profile::MemberListRow; + using Type = Row::Type; + using Rights = Row::Rights; + struct CreateTag { }; ParticipantsBoxController( @@ -219,10 +246,10 @@ protected: virtual std::unique_ptr createRow( not_null participant) const; + std::unique_ptr _chatStyle; + mutable base::flat_map _pillCircleCache; + private: - using Row = Info::Profile::MemberListRow; - using Type = Row::Type; - using Rights = Row::Rights; struct SavedState : SavedStateBase { explicit SavedState(const ParticipantsAdditionalData &additional); @@ -260,7 +287,7 @@ private: void editAdminDone( not_null user, ChatAdminRightsInfo rights, - const QString &rank); + const std::optional &rank); void showRestricted(not_null user); void editRestrictedDone( not_null participant, @@ -271,7 +298,6 @@ private: void removeKickedWithRow(not_null participant); void removeKicked(not_null participant); void kickParticipant(not_null participant); - void kickParticipantSure(not_null participant); void unkickParticipant(not_null user); void removeAdmin(not_null user); void removeAdminSure(not_null user); diff --git a/Telegram/SourceFiles/boxes/peers/edit_peer_color_box.cpp b/Telegram/SourceFiles/boxes/peers/edit_peer_color_box.cpp index e41de5d728..06afcfee6c 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_peer_color_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_peer_color_box.cpp @@ -2637,7 +2637,10 @@ void SetupPeerColorSample( ) | rpl::map([=] { return peer->emojiStatusId(); }); - const auto name = peer->shortName(); + auto name = peer->session().changes().peerFlagsValue( + peer, + Data::PeerUpdate::Flag::Name + ) | rpl::map([=] { return peer->shortName(); }); const auto sampleSize = st::settingsColorSampleSize; @@ -2648,7 +2651,7 @@ void SetupPeerColorSample( style, rpl::duplicate(colorIndexValue), rpl::duplicate(colorCollectibleValue), - name); + rpl::duplicate(name)); sample->show(); struct ProfileSampleState { @@ -2681,13 +2684,15 @@ void SetupPeerColorSample( rpl::duplicate(label), rpl::duplicate(colorIndexValue), rpl::duplicate(colorProfileIndexValue), - rpl::duplicate(emojiStatusIdValue) + rpl::duplicate(emojiStatusIdValue), + rpl::duplicate(name) ) | rpl::on_next([=]( int width, const QString &buttonText, int colorIndex, std::optional profileIndex, - EmojiStatusId emojiStatusId) { + EmojiStatusId emojiStatusId, + const QString &name) { const auto available = width - st::settingsButton.padding.left() - (st::settingsColorButton.padding.right() - sampleSize) diff --git a/Telegram/SourceFiles/boxes/peers/edit_peer_info_box.cpp b/Telegram/SourceFiles/boxes/peers/edit_peer_info_box.cpp index c075dcdf90..0a698cd9a5 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_peer_info_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_peer_info_box.cpp @@ -2280,11 +2280,13 @@ void Controller::saveUsernamesOrder() { _api.request(MTPchannels_DeactivateAllUsernames( channel->inputChannel() )).done([=] { - channel->setUsernames(channel->editableUsername().isEmpty() - ? Data::Usernames() - : Data::Usernames{ + if (channel->editableUsername().isEmpty()) { + channel->setUsernames({}); + } else { + channel->setUsernames({ { channel->editableUsername(), true, true } }); + } continueSave(); }).send(); } else { @@ -2740,9 +2742,12 @@ void Controller::saveForwards() { || *_savingData.noForwards == _peer->isAyuNoForwards()) { return continueSave(); } + using Flag = MTPmessages_ToggleNoForwards::Flag; _api.request(MTPmessages_ToggleNoForwards( + MTP_flags(Flag()), _peer->input(), - MTP_bool(*_savingData.noForwards) + MTP_bool(*_savingData.noForwards), + MTPint() )).done([=](const MTPUpdates &result) { _peer->session().api().applyUpdates(result); continueSave(); diff --git a/Telegram/SourceFiles/boxes/peers/edit_peer_invite_link.cpp b/Telegram/SourceFiles/boxes/peers/edit_peer_invite_link.cpp index d1179e27eb..ac5b632317 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_peer_invite_link.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_peer_invite_link.cpp @@ -1522,7 +1522,7 @@ object_ptr ShareInviteLinkBox( comment.text = link; } auto &api = session->api(); - for (const auto thread : result) { + for (const auto &thread : result) { auto message = Api::MessageToSend( Api::SendAction(thread, options)); message.textWithTags = comment; diff --git a/Telegram/SourceFiles/boxes/peers/edit_peer_permissions_box.cpp b/Telegram/SourceFiles/boxes/peers/edit_peer_permissions_box.cpp index f88a81b59a..b81161b0ed 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_peer_permissions_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_peer_permissions_box.cpp @@ -96,6 +96,9 @@ constexpr auto kDefaultChargeStars = 10; { Flag::AddParticipants, tr::lng_rights_chat_add_members(tr::now) }, { Flag::CreateTopics, tr::lng_rights_group_add_topics(tr::now) }, { Flag::PinMessages, tr::lng_rights_group_pin(tr::now) }, + { Flag::EditRank, (options.isUserSpecific + ? tr::lng_rights_group_edit_rank_single + : tr::lng_rights_group_edit_rank)(tr::now) }, { Flag::ChangeInfo, tr::lng_rights_group_info(tr::now) }, }; if (!options.isForum) { @@ -136,6 +139,7 @@ constexpr auto kDefaultChargeStars = 10; }; auto second = std::vector{ { Flag::ManageCall, tr::lng_rights_group_manage_calls(tr::now) }, + { Flag::ManageRanks, tr::lng_rights_group_manage_ranks(tr::now) }, { Flag::Anonymous, tr::lng_rights_group_anonymous(tr::now) }, { Flag::AddAdmins, tr::lng_rights_add_admins(tr::now) }, }; @@ -315,7 +319,8 @@ ChatRestrictions NegateRestrictions(ChatRestrictions value) { | Flag::SendMusic | Flag::SendVoiceMessages | Flag::SendFiles - | Flag::SendOther); + | Flag::SendOther + | Flag::EditRank); } auto Dependencies(ChatAdminRights) diff --git a/Telegram/SourceFiles/boxes/peers/edit_peer_type_box.cpp b/Telegram/SourceFiles/boxes/peers/edit_peer_type_box.cpp index 4c2a53fc11..0373cc6a16 100644 --- a/Telegram/SourceFiles/boxes/peers/edit_peer_type_box.cpp +++ b/Telegram/SourceFiles/boxes/peers/edit_peer_type_box.cpp @@ -296,6 +296,7 @@ void Controller::createContent() { (_isGroup ? tr::lng_manage_peer_no_forwards_about : tr::lng_manage_peer_no_forwards_about_channel)()); + } if (_linkOnly) { _controls.inviteLinkWrap->show(anim::type::instant); diff --git a/Telegram/SourceFiles/boxes/peers/edit_tag_control.cpp b/Telegram/SourceFiles/boxes/peers/edit_tag_control.cpp new file mode 100644 index 0000000000..0a9afa0237 --- /dev/null +++ b/Telegram/SourceFiles/boxes/peers/edit_tag_control.cpp @@ -0,0 +1,417 @@ +/* +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/peers/edit_tag_control.h" + +#include "base/unixtime.h" +#include "data/data_channel.h" +#include "data/data_chat.h" +#include "data/data_session.h" +#include "data/data_user.h" +#include "history/admin_log/history_admin_log_item.h" +#include "history/history.h" +#include "history/history_item.h" +#include "history/view/history_view_element.h" +#include "history/view/history_view_message.h" +#include "history/view/media/history_view_media_generic.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "ui/chat/chat_style.h" +#include "ui/chat/chat_theme.h" +#include "ui/effects/path_shift_gradient.h" +#include "ui/painter.h" +#include "ui/text/text_utilities.h" +#include "ui/widgets/fields/input_field.h" +#include "window/section_widget.h" +#include "window/themes/window_theme.h" +#include "styles/style_boxes.h" +#include "styles/style_chat.h" +#include "styles/style_layers.h" + +namespace { + +constexpr auto kRankLimit = 16; +constexpr auto kTextLinesAlpha = 0.1; + +using namespace HistoryView; + +class TextLinesPart final : public MediaGenericPart { +public: + TextLinesPart(QMargins margins); + + void draw( + Painter &p, + not_null owner, + const PaintContext &context, + int outerWidth) const override; + QSize countOptimalSize() override; + QSize countCurrentSize(int newWidth) override; + +private: + QMargins _margins; + +}; + +TextLinesPart::TextLinesPart(QMargins margins) +: _margins(margins) { +} + +QSize TextLinesPart::countOptimalSize() { + const auto h = _margins.top() + + 4 * st::tagPreviewLineHeight + + 3 * st::tagPreviewLineSpacing + + _margins.bottom(); + return { st::msgMinWidth, h }; +} + +QSize TextLinesPart::countCurrentSize(int newWidth) { + return { newWidth, minHeight() }; +} + +void TextLinesPart::draw( + Painter &p, + not_null owner, + const PaintContext &context, + int outerWidth) const { + const auto &stm = context.messageStyle(); + auto color = stm->historyTextFg->c; + color.setAlphaF(color.alphaF() * kTextLinesAlpha); + p.setPen(Qt::NoPen); + p.setBrush(color); + auto hq = PainterHighQualityEnabler(p); + + const auto available = outerWidth - _margins.left() - _margins.right(); + const auto lineHeight = st::tagPreviewLineHeight; + const auto radius = lineHeight / 2.0; + const auto fractions = { 1.0, 0.85, 0.65, 0.4 }; + auto y = double(_margins.top()); + for (const auto fraction : fractions) { + const auto w = available * fraction; + p.drawRoundedRect( + QRectF(_margins.left(), y, w, lineHeight), + radius, + radius); + y += lineHeight + st::tagPreviewLineSpacing; + } +} + +class TagPreviewDelegate final : public DefaultElementDelegate { +public: + TagPreviewDelegate( + not_null parent, + not_null st, + Fn update); + + void setTagText(const QString &text); + [[nodiscard]] const QString &tagText() const; + + bool elementAnimationsPaused() override; + not_null elementPathShiftGradient() override; + Context elementContext() override; + QString elementAuthorRank(not_null view) override; + +private: + const not_null _parent; + const std::unique_ptr _pathGradient; + QString _tagText; + +}; + +TagPreviewDelegate::TagPreviewDelegate( + not_null parent, + not_null st, + Fn update) +: _parent(parent) +, _pathGradient(MakePathShiftGradient(st, std::move(update))) { +} + +void TagPreviewDelegate::setTagText(const QString &text) { + _tagText = text; +} + +const QString &TagPreviewDelegate::tagText() const { + return _tagText; +} + +bool TagPreviewDelegate::elementAnimationsPaused() { + return _parent->window()->isActiveWindow(); +} + +auto TagPreviewDelegate::elementPathShiftGradient() +-> not_null { + return _pathGradient.get(); +} + +Context TagPreviewDelegate::elementContext() { + return Context::AdminLog; +} + +QString TagPreviewDelegate::elementAuthorRank( + not_null view) { + return _tagText; +} + +} // namespace + +HistoryView::BadgeRole LookupBadgeRole( + not_null peer, + not_null user) { + if (const auto channel = peer->asMegagroup()) { + const auto info = channel->mgInfo.get(); + if (info && info->creator == user) { + return HistoryView::BadgeRole::Creator; + } + const auto userId = peerToUser(user->id); + if (info && info->admins.contains(userId)) { + return HistoryView::BadgeRole::Admin; + } + } else if (const auto chat = peer->asChat()) { + if (peerToUser(user->id) == chat->creator) { + return HistoryView::BadgeRole::Creator; + } else if (chat->admins.contains(user)) { + return HistoryView::BadgeRole::Admin; + } + } + return HistoryView::BadgeRole::User; +} + +class EditTagControl::PreviewWidget final : public Ui::RpWidget { +public: + PreviewWidget( + QWidget *parent, + not_null session, + not_null user, + const QString &initialText, + HistoryView::BadgeRole role); + ~PreviewWidget(); + + void setTagText(const QString &text); + +private: + void paintEvent(QPaintEvent *e) override; + void createItem(); + void applyBadge(const QString &text); + + const not_null _history; + const not_null _user; + const HistoryView::BadgeRole _role; + const std::unique_ptr _theme; + const std::unique_ptr _style; + const std::unique_ptr _delegate; + AdminLog::OwnedItem _item; + int _topSkip = 0; + int _bottomSkip = 0; + Ui::PeerUserpicView _userpic; + +}; + +EditTagControl::PreviewWidget::PreviewWidget( + QWidget *parent, + not_null session, + not_null user, + const QString &initialText, + HistoryView::BadgeRole role) +: RpWidget(parent) +, _history(session->data().history(PeerData::kServiceNotificationsId)) +, _user(user) +, _role(role) +, _theme(Window::Theme::DefaultChatThemeOn(lifetime())) +, _style(std::make_unique( + session->colorIndicesValue())) +, _delegate(std::make_unique( + this, + _style.get(), + [=] { update(); })) +, _topSkip(st::msgMargin.bottom() * 2) +, _bottomSkip(st::msgMargin.bottom() + st::msgMargin.top()) { + _style->apply(_theme.get()); + _delegate->setTagText(initialText); + + _history->owner().viewRepaintRequest( + ) | rpl::on_next([=](Data::RequestViewRepaint data) { + if (data.view == _item.get()) { + update(); + } + }, lifetime()); + + createItem(); + + widthValue( + ) | rpl::filter([=](int w) { + return w >= st::msgMinWidth; + }) | rpl::on_next([=](int w) { + const auto h = _topSkip + + _item->resizeGetHeight(w) + + _bottomSkip; + resize(w, h); + }, lifetime()); + + _history->owner().itemResizeRequest( + ) | rpl::on_next([=](not_null item) { + if (_item && item == _item->data() && width() >= st::msgMinWidth) { + const auto h = _topSkip + + _item->resizeGetHeight(width()) + + _bottomSkip; + resize(width(), h); + } + }, lifetime()); +} + +EditTagControl::PreviewWidget::~PreviewWidget() { + _item = {}; +} + +void EditTagControl::PreviewWidget::createItem() { + const auto item = _history->addNewLocalMessage({ + .id = _history->nextNonHistoryEntryId(), + .flags = (MessageFlag::FakeHistoryItem + | MessageFlag::HasFromId), + .from = _user->id, + .date = base::unixtime::now(), + }, TextWithEntities(), MTP_messageMediaEmpty()); + + auto owned = AdminLog::OwnedItem(_delegate.get(), item); + owned->overrideMedia(std::make_unique( + owned.get(), + [](not_null, + Fn)> push) { + push(std::make_unique(st::msgPadding)); + })); + _item = std::move(owned); + applyBadge(_delegate->tagText()); + if (width() >= st::msgMinWidth) { + const auto h = _topSkip + + _item->resizeGetHeight(width()) + + _bottomSkip; + resize(width(), h); + } +} + +void EditTagControl::PreviewWidget::applyBadge(const QString &text) { + if (!_item) { + return; + } + auto badgeText = text; + if (badgeText.isEmpty()) { + if (_role == HistoryView::BadgeRole::Admin) { + badgeText = tr::lng_admin_badge(tr::now); + } else if (_role == HistoryView::BadgeRole::Creator) { + badgeText = tr::lng_owner_badge(tr::now); + } + } + _item->overrideRightBadge(badgeText, _role); +} + +void EditTagControl::PreviewWidget::setTagText(const QString &text) { + _delegate->setTagText(text); + applyBadge(text); + update(); +} + +void EditTagControl::PreviewWidget::paintEvent(QPaintEvent *e) { + auto p = Painter(this); + + const auto clip = e->rect(); + if (!clip.isEmpty()) { + p.setClipRect(clip); + Window::SectionWidget::PaintBackground( + p, + _theme.get(), + QSize(width(), window()->height()), + clip); + } + + auto context = _theme->preparePaintContext( + _style.get(), + rect(), + rect(), + e->rect(), + !window()->isActiveWindow()); + p.translate(0, _topSkip); + _item->draw(p, context); + + if (_item->displayFromPhoto()) { + auto userpicBottom = height() + - _bottomSkip + - _item->marginBottom() + - _item->marginTop(); + const auto userpicTop = userpicBottom - st::msgPhotoSize; + _user->paintUserpicLeft( + p, + _userpic, + st::historyPhotoLeft, + userpicTop, + width(), + st::msgPhotoSize); + } +} + +EditTagControl::EditTagControl( + QWidget *parent, + not_null session, + not_null user, + const QString ¤tRank, + HistoryView::BadgeRole role) +: RpWidget(parent) +, _preview(Ui::CreateChild( + this, + session, + user, + TextUtilities::RemoveEmoji(currentRank), + role)) +, _field(Ui::CreateChild( + this, + st::customBadgeField, + (role == HistoryView::BadgeRole::Admin + ? tr::lng_admin_badge() + : role == HistoryView::BadgeRole::Creator + ? tr::lng_owner_badge() + : tr::lng_rights_edit_admin_rank_name()), + TextUtilities::RemoveEmoji(currentRank))) { + _field->setMaxLength(kRankLimit); + _field->setInstantReplaces(Ui::InstantReplaces::TextOnly()); + + _field->changes( + ) | rpl::on_next([=] { + const auto text = _field->getLastText(); + const auto removed = TextUtilities::RemoveEmoji(text); + if (removed != text) { + _field->setText(removed); + } + _preview->setTagText(_field->getLastText()); + }, _field->lifetime()); + + widthValue( + ) | rpl::on_next([=](int w) { + _preview->resizeToWidth(w); + const auto inputMargins = st::boxRowPadding; + _field->resizeToWidth(w - inputMargins.left() - inputMargins.right()); + _field->moveToLeft( + inputMargins.left(), + _preview->height() + st::tagPreviewInputSkip); + resize(w, _field->y() + _field->height()); + }, lifetime()); + + _preview->heightValue( + ) | rpl::skip(1) | rpl::on_next([=] { + _field->moveToLeft( + st::boxRowPadding.left(), + _preview->height() + st::tagPreviewInputSkip); + resize(width(), _field->y() + _field->height()); + }, lifetime()); +} + +EditTagControl::~EditTagControl() = default; + +QString EditTagControl::currentRank() const { + return TextUtilities::RemoveEmoji( + TextUtilities::SingleLine(_field->getLastText().trimmed())); +} + +not_null EditTagControl::field() const { + return _field; +} diff --git a/Telegram/SourceFiles/boxes/peers/edit_tag_control.h b/Telegram/SourceFiles/boxes/peers/edit_tag_control.h new file mode 100644 index 0000000000..ba6e641d61 --- /dev/null +++ b/Telegram/SourceFiles/boxes/peers/edit_tag_control.h @@ -0,0 +1,50 @@ +/* +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 "ui/rp_widget.h" + +namespace Ui { +class InputField; +} // namespace Ui + +namespace Main { +class Session; +} // namespace Main + +class PeerData; +class UserData; + +namespace HistoryView { +enum class BadgeRole : uchar; +} // namespace HistoryView + +[[nodiscard]] HistoryView::BadgeRole LookupBadgeRole( + not_null peer, + not_null user); + +class EditTagControl final : public Ui::RpWidget { +public: + EditTagControl( + QWidget *parent, + not_null session, + not_null user, + const QString ¤tRank, + HistoryView::BadgeRole role); + ~EditTagControl(); + + [[nodiscard]] QString currentRank() const; + [[nodiscard]] not_null field() const; + +private: + class PreviewWidget; + + not_null _preview; + not_null _field; + +}; diff --git a/Telegram/SourceFiles/boxes/peers/tag_info_box.cpp b/Telegram/SourceFiles/boxes/peers/tag_info_box.cpp new file mode 100644 index 0000000000..53b699a963 --- /dev/null +++ b/Telegram/SourceFiles/boxes/peers/tag_info_box.cpp @@ -0,0 +1,531 @@ +/* +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/peers/tag_info_box.h" + +#include "boxes/peers/edit_participants_box.h" +#include "boxes/peers/edit_tag_control.h" +#include "data/data_channel.h" +#include "data/data_chat.h" +#include "data/data_chat_participant_status.h" +#include "data/data_peer.h" +#include "data/data_user.h" +#include "history/view/history_view_message.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "ui/chat/chat_style.h" +#include "ui/chat/chat_theme.h" +#include "ui/layers/generic_box.h" +#include "ui/painter.h" +#include "ui/text/custom_emoji_helper.h" +#include "ui/text/text_utilities.h" +#include "ui/widgets/buttons.h" +#include "ui/widgets/labels.h" +#include "window/section_widget.h" +#include "window/themes/window_theme.h" +#include "styles/style_boxes.h" +#include "styles/style_calls.h" +#include "styles/style_chat.h" +#include "styles/style_info.h" +#include "styles/style_layers.h" +#include "styles/style_widgets.h" + +namespace { + +using HistoryView::BadgeRole; + +constexpr auto kTextLinesAlpha = 0.1; + +[[nodiscard]] QColor RoleColor(BadgeRole role) { + return (role == BadgeRole::Creator) + ? st::rankOwnerFg->c + : (role == BadgeRole::Admin) + ? st::rankAdminFg->c + : st::rankUserFg->c; +} + +[[nodiscard]] object_ptr MakeRoundColoredLogo( + not_null parent, + BadgeRole role) { + const auto &icon = st::tagInfoIcon; + const auto &padding = st::tagInfoIconPadding; + const auto logoSize = icon.size(); + const auto logoOuter = logoSize.grownBy(padding); + auto result = object_ptr(parent); + const auto logo = result.data(); + logo->resize(logo->width(), logoOuter.height()); + logo->paintRequest() | rpl::on_next([=, &icon] { + if (logo->width() < logoOuter.width()) { + return; + } + auto p = QPainter(logo); + auto hq = PainterHighQualityEnabler(p); + const auto x = (logo->width() - logoOuter.width()) / 2; + const auto outer = QRect(QPoint(x, 0), logoOuter); + p.setBrush(RoleColor(role)); + p.setPen(Qt::NoPen); + p.drawEllipse(outer); + icon.paintInCenter(p, outer); + }, logo->lifetime()); + return result; +} + +[[nodiscard]] Ui::Text::PaletteDependentEmoji MakeTagPillEmoji( + const QString &text, + BadgeRole role) { + return { .factory = [=] { + const auto color = RoleColor(role); + const auto &padding = st::msgTagBadgePadding; + auto string = Ui::Text::String(st::defaultTextStyle, text); + const auto textWidth = string.maxWidth(); + const auto isUser = (role == BadgeRole::User); + const auto contentWidth = padding.left() + + textWidth + + padding.right(); + const auto pillHeight = padding.top() + + st::msgFont->height + + padding.bottom(); + const auto imgWidth = isUser + ? textWidth + : std::max(contentWidth, pillHeight); + const auto imgHeight = isUser + ? st::msgFont->height + : pillHeight; + const auto ratio = style::DevicePixelRatio(); + + auto result = QImage( + QSize(imgWidth, imgHeight) * ratio, + QImage::Format_ARGB32_Premultiplied); + result.setDevicePixelRatio(ratio); + result.fill(Qt::transparent); + + auto p = QPainter(&result); + auto hq = PainterHighQualityEnabler(p); + if (!isUser) { + auto bgColor = color; + bgColor.setAlphaF(0.15); + p.setPen(Qt::NoPen); + p.setBrush(bgColor); + p.drawRoundedRect( + 0, + 0, + imgWidth, + imgHeight, + imgHeight / 2., + imgHeight / 2.); + } + p.setPen(color); + string.draw(p, { + .position = QPoint( + isUser ? 0 : ((imgWidth - textWidth) / 2), + isUser ? 0 : padding.top()), + .availableWidth = textWidth, + }); + p.end(); + return result; + }, .margin = st::customEmojiTextBadgeMargin }; +} + +class TagPreviewsWidget final : public Ui::RpWidget { +public: + TagPreviewsWidget( + QWidget *parent, + not_null session, + BadgeRole role); + +protected: + void paintEvent(QPaintEvent *e) override; + +private: + void paintPreview( + QPainter &p, + QRect rect, + BadgeRole previewRole) const; + void paintBubbleToImage( + QRect rect, + BadgeRole previewRole) const; + void invalidateCache(); + + const std::unique_ptr _theme; + std::unique_ptr _style; + BadgeRole _role = BadgeRole::User; + mutable QImage _leftCache; + mutable QImage _rightCache; + +}; + +TagPreviewsWidget::TagPreviewsWidget( + QWidget *parent, + not_null session, + BadgeRole role) +: RpWidget(parent) +, _theme(Window::Theme::DefaultChatThemeOn(lifetime())) +, _style(std::make_unique( + session->colorIndicesValue())) +, _role(role) { + _style->apply(_theme.get()); + resize(width(), st::tagInfoPreviewHeight); + + _theme->repaintBackgroundRequests( + ) | rpl::on_next([=] { + invalidateCache(); + update(); + }, lifetime()); + + style::PaletteChanged() | rpl::on_next([=] { + invalidateCache(); + update(); + }, lifetime()); + + sizeValue() | rpl::skip(1) | rpl::on_next([=] { + invalidateCache(); + }, lifetime()); +} + +void TagPreviewsWidget::invalidateCache() { + _leftCache = QImage(); + _rightCache = QImage(); +} + +void TagPreviewsWidget::paintEvent(QPaintEvent *e) { + auto p = QPainter(this); + + const auto gap = st::tagInfoPreviewGap; + const auto previewWidth = (width() - gap) / 2; + if (previewWidth <= 0) { + return; + } + + const auto leftRect = QRect(0, 0, previewWidth, height()); + const auto rightRect = QRect( + previewWidth + gap, + 0, + width() - previewWidth - gap, + height()); + + paintPreview(p, leftRect, BadgeRole::User); + + const auto rightRole = (_role == BadgeRole::User) + ? BadgeRole::Admin + : _role; + paintPreview(p, rightRect, rightRole); +} + +void TagPreviewsWidget::paintPreview( + QPainter &p, + QRect rect, + BadgeRole previewRole) const { + const auto previewRadius = st::tagInfoPreviewRadius; + + p.save(); + p.translate(rect.topLeft()); + + const auto local = QRect(0, 0, rect.width(), rect.height()); + auto clipPath = QPainterPath(); + clipPath.addRoundedRect(local, previewRadius, previewRadius); + p.setClipPath(clipPath); + + Window::SectionWidget::PaintBackground( + p, + _theme.get(), + QSize(rect.width(), window()->height()), + local); + + auto &cache = (previewRole == BadgeRole::User) + ? _leftCache + : _rightCache; + if (cache.isNull()) { + paintBubbleToImage(rect, previewRole); + } + p.drawImage(0, 0, cache); + + p.restore(); +} + +void TagPreviewsWidget::paintBubbleToImage( + QRect rect, + BadgeRole previewRole) const { + const auto ratio = style::DevicePixelRatio(); + auto &cache = (previewRole == BadgeRole::User) + ? _leftCache + : _rightCache; + cache = QImage( + rect.size() * ratio, + QImage::Format_ARGB32_Premultiplied); + cache.setDevicePixelRatio(ratio); + cache.fill(Qt::transparent); + + const auto &stm = _style->messageStyle(false, false); + const auto &padding = st::tagInfoPreviewBubblePadding; + const auto radius = st::tagInfoPreviewBubbleRadius; + const auto rightMargin = st::tagInfoPreviewBubbleRightMargin; + const auto bubbleTop = padding.top(); + const auto bubbleHeight = rect.height() + - padding.top() + - padding.bottom(); + const auto bubbleRight = rect.width() - rightMargin; + const auto bubbleRect = QRect( + -radius, + bubbleTop, + bubbleRight + radius, + bubbleHeight); + + auto p = QPainter(&cache); + { + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(stm.msgBg); + p.drawRoundedRect(bubbleRect, radius, radius); + } + + const auto innerLeft = padding.left(); + const auto innerRight = bubbleRight - padding.right(); + const auto available = innerRight - innerLeft; + + const auto badgeColor = RoleColor(previewRole); + const auto badgeText = (previewRole == BadgeRole::Creator) + ? tr::lng_tag_info_preview_owner(tr::now) + : (previewRole == BadgeRole::Admin) + ? tr::lng_tag_info_preview_admin(tr::now) + : tr::lng_tag_info_preview_member(tr::now); + auto badgeString = Ui::Text::String(st::defaultTextStyle, badgeText); + const auto badgeTextWidth = badgeString.maxWidth(); + const auto &badgePadding = st::msgTagBadgePadding; + const auto badgeContentWidth = badgePadding.left() + + badgeTextWidth + + badgePadding.right(); + const auto pillHeight = badgePadding.top() + + st::msgFont->height + + badgePadding.bottom(); + const auto pillWidth = std::max(badgeContentWidth, pillHeight); + + const auto badgeRight = innerRight; + const auto badgeLeft = badgeRight - pillWidth; + const auto badgeTop = bubbleTop + st::tagInfoPreviewBadgeTop; + + if (previewRole != BadgeRole::User) { + auto bgColor = badgeColor; + bgColor.setAlphaF(0.15); + const auto pillRect = QRect( + badgeLeft, + badgeTop, + pillWidth, + pillHeight); + auto hq = PainterHighQualityEnabler(p); + p.setPen(Qt::NoPen); + p.setBrush(bgColor); + p.drawRoundedRect(pillRect, pillHeight / 2., pillHeight / 2.); + p.setPen(badgeColor); + badgeString.draw(p, { + .position = QPoint( + badgeLeft + (pillWidth - badgeTextWidth) / 2, + badgeTop + badgePadding.top()), + .availableWidth = badgeTextWidth, + }); + } else if (badgeTextWidth > 0) { + p.setPen(st::rankUserFg); + badgeString.draw(p, { + .position = QPoint(innerRight - badgeTextWidth, badgeTop), + .availableWidth = badgeTextWidth, + }); + } + + p.setFont(st::msgDateFont); + const auto timeText = u"12:00"_q; + const auto timeWidth = st::msgDateFont->width(timeText); + const auto timeX = innerRight - timeWidth; + const auto timeY = bubbleTop + + bubbleHeight + - padding.bottom() + + st::msgDateFont->ascent + - st::msgDateFont->height; + p.setPen(stm.msgDateFg); + p.drawText(timeX, timeY, timeText); + + { + auto color = stm.historyTextFg->c; + color.setAlphaF(color.alphaF() * kTextLinesAlpha); + p.setPen(Qt::NoPen); + p.setBrush(color); + auto hq = PainterHighQualityEnabler(p); + + const auto lineHeight = st::tagInfoPreviewLineHeight; + const auto lineSpacing = st::tagInfoPreviewLineSpacing; + const auto linesTop = badgeTop + + pillHeight + + lineSpacing; + const auto lineRadius = lineHeight / 2.0; + const auto timeAreaLeft = timeX - padding.right(); + const auto fractions = { 1.0, 0.65, 0.65 }; + auto y = double(linesTop); + auto lineIndex = 0; + for (const auto fraction : fractions) { + auto w = available * fraction; + const auto lineBottom = y + lineHeight; + if (lineIndex >= 1 && lineBottom > (timeY - lineSpacing)) { + w = std::min(w, double(timeAreaLeft - innerLeft)); + } + p.drawRoundedRect( + QRectF(innerLeft, y, w, lineHeight), + lineRadius, + lineRadius); + y += lineHeight + lineSpacing; + ++lineIndex; + } + } + + const auto fadeWidth = st::tagInfoPreviewFadeWidth; + p.setCompositionMode(QPainter::CompositionMode_DestinationIn); + auto gradient = QLinearGradient(0, 0, fadeWidth, 0); + gradient.setStops({ + { 0., QColor(255, 255, 255, 0) }, + { 1., QColor(255, 255, 255, 255) }, + }); + p.fillRect(0, 0, fadeWidth, rect.height(), gradient); + p.end(); +} + +[[nodiscard]] QString LookupCurrentRank(not_null peer) { + const auto selfId = peerToUser(peer->session().user()->id); + if (const auto channel = peer->asMegagroup()) { + if (const auto info = channel->mgInfo.get()) { + const auto it = info->memberRanks.find(selfId); + if (it != info->memberRanks.end()) { + return it->second; + } + } + } else if (const auto chat = peer->asChat()) { + const auto it = chat->memberRanks.find(selfId); + if (it != chat->memberRanks.end()) { + return it->second; + } + } + return QString(); +} + +} // namespace + +void TagInfoBox( + not_null box, + std::shared_ptr show, + not_null peer, + not_null author, + const QString &tagText, + HistoryView::BadgeRole role) { + box->setStyle(st::confcallJoinBox); + box->setWidth(st::boxWideWidth); + box->setNoContentMargin(true); + box->addTopButton(st::boxTitleClose, [=] { + box->closeBox(); + }); + + box->addRow( + MakeRoundColoredLogo(box, role), + st::boxRowPadding + st::confcallLinkHeaderIconPadding); + + auto title = (role == BadgeRole::Creator) + ? tr::lng_tag_info_title_owner() + : (role == BadgeRole::Admin) + ? tr::lng_tag_info_title_admin() + : tr::lng_tag_info_title_user(); + box->addRow( + object_ptr(box, std::move(title), st::boxTitle), + st::boxRowPadding + st::confcallLinkTitlePadding, + style::al_top); + + auto helper = Ui::Text::CustomEmojiHelper(); + const auto tagPill = helper.paletteDependent( + MakeTagPillEmoji(tagText, role)); + const auto authorName = author->shortName(); + const auto groupName = peer->name(); + const auto descText = (role == BadgeRole::Creator) + ? tr::lng_tag_info_text_owner( + tr::now, + lt_emoji, + tagPill, + lt_author, + tr::bold(authorName), + lt_group, + tr::bold(groupName), + tr::rich) + : (role == BadgeRole::Admin) + ? tr::lng_tag_info_text_admin( + tr::now, + lt_emoji, + tagPill, + lt_author, + tr::bold(authorName), + lt_group, + tr::bold(groupName), + tr::rich) + : tr::lng_tag_info_text_user( + tr::now, + lt_emoji, + tagPill, + lt_author, + tr::bold(authorName), + lt_group, + tr::bold(groupName), + tr::rich); + const auto context = helper.context(); + const auto desc = box->addRow( + object_ptr( + box, + rpl::single(descText), + st::confcallLinkCenteredText, + st::defaultPopupMenu, + context), + st::boxRowPadding, + style::al_top); + desc->setTryMakeSimilarLines(true); + + box->addRow( + object_ptr( + box, + &peer->session(), + role), + st::boxRowPadding + st::tagInfoPreviewPadding); + + const auto selfUser = peer->session().user(); + const auto selfRole = LookupBadgeRole(peer, selfUser); + const auto isAdmin = (selfRole != BadgeRole::User); + const auto canEditSelf = isAdmin + || !peer->amRestricted(ChatRestriction::EditRank); + + if (canEditSelf) { + const auto currentRank = LookupCurrentRank(peer); + auto buttonText = currentRank.isEmpty() + ? tr::lng_tag_info_add_my_tag() + : tr::lng_tag_info_edit_my_tag(); + box->addButton(std::move(buttonText), [=] { + box->closeBox(); + show->show(Box( + EditCustomRankBox, + show, + peer, + selfUser, + currentRank, + true, + Fn(nullptr))); + }); + } else { + box->addRow( + object_ptr( + box, + tr::lng_tag_info_admins_only(), + st::tagInfoAdminsOnlyLabel), + st::boxRowPadding + st::tagInfoAdminsOnlyPadding, + style::al_top); + box->addButton( + rpl::single(QString()), + [=] { box->closeBox(); } + )->setText(rpl::single(Ui::Text::IconEmoji( + &st::infoStarsUnderstood + ).append(' ').append( + tr::lng_stars_rating_understood(tr::now)))); + } +} diff --git a/Telegram/SourceFiles/boxes/peers/tag_info_box.h b/Telegram/SourceFiles/boxes/peers/tag_info_box.h new file mode 100644 index 0000000000..f54813eef0 --- /dev/null +++ b/Telegram/SourceFiles/boxes/peers/tag_info_box.h @@ -0,0 +1,27 @@ +/* +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 + +namespace Ui { +class GenericBox; +class Show; +} // namespace Ui + +namespace HistoryView { +enum class BadgeRole : uchar; +} // namespace HistoryView + +class PeerData; + +void TagInfoBox( + not_null box, + std::shared_ptr show, + not_null peer, + not_null author, + const QString &tagText, + HistoryView::BadgeRole role); diff --git a/Telegram/SourceFiles/boxes/premium_preview_box.cpp b/Telegram/SourceFiles/boxes/premium_preview_box.cpp index fe23e31a6d..59ce2da496 100644 --- a/Telegram/SourceFiles/boxes/premium_preview_box.cpp +++ b/Telegram/SourceFiles/boxes/premium_preview_box.cpp @@ -144,6 +144,8 @@ void PreloadSticker(const std::shared_ptr &media) { return tr::lng_premium_summary_subtitle_peer_colors(); case PremiumFeature::Gifts: return tr::lng_premium_summary_subtitle_gifts(); + case PremiumFeature::NoForwards: + return tr::lng_premium_summary_subtitle_no_forwards(); case PremiumFeature::BusinessLocation: return tr::lng_business_subtitle_location(); @@ -215,6 +217,8 @@ void PreloadSticker(const std::shared_ptr &media) { return tr::lng_premium_summary_about_peer_colors(); case PremiumFeature::Gifts: return tr::lng_premium_summary_about_gifts(); + case PremiumFeature::NoForwards: + return tr::lng_premium_summary_about_no_forwards(); case PremiumFeature::BusinessLocation: return tr::lng_business_about_location(); @@ -558,6 +562,7 @@ struct VideoPreviewDocument { case PremiumFeature::TodoLists: return "todo"; case PremiumFeature::PeerColors: return "peer_colors"; case PremiumFeature::Gifts: return "gifts"; + case PremiumFeature::NoForwards: return "no_forwards"; case PremiumFeature::BusinessLocation: return "business_location"; case PremiumFeature::BusinessHours: return "business_hours"; diff --git a/Telegram/SourceFiles/boxes/premium_preview_box.h b/Telegram/SourceFiles/boxes/premium_preview_box.h index 0f64a0eaed..cb3944a4ed 100644 --- a/Telegram/SourceFiles/boxes/premium_preview_box.h +++ b/Telegram/SourceFiles/boxes/premium_preview_box.h @@ -75,6 +75,7 @@ enum class PremiumFeature { TodoLists, PeerColors, Gifts, + NoForwards, // Business features. BusinessLocation, diff --git a/Telegram/SourceFiles/boxes/select_future_owner_box.cpp b/Telegram/SourceFiles/boxes/select_future_owner_box.cpp index e040b20cf1..dcefc7d68c 100644 --- a/Telegram/SourceFiles/boxes/select_future_owner_box.cpp +++ b/Telegram/SourceFiles/boxes/select_future_owner_box.cpp @@ -19,6 +19,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "core/application.h" #include "core/core_cloud_password.h" #include "data/data_channel.h" +#include "data/data_chat.h" #include "dialogs/ui/chat_search_empty.h" #include "boxes/peers/channel_ownership_transfer.h" #include "data/data_session.h" @@ -44,58 +45,25 @@ enum class ParticipantType { Members }; -class ParticipantsController : public PeerListController { +class FutureOwnerController : public PeerListController { public: - ParticipantsController( - not_null window, - not_null channel, - ParticipantType type); - - Main::Session &session() const override; - void prepare() override; void rowClicked(not_null row) override; - void loadMoreRows() override; void itemDeselectedHook(not_null peer) override; void setOnRowClicked(Fn callback); rpl::producer<> itemDeselected() const; private: - const not_null _window; - const not_null _channel; - const ParticipantType _type; - MTP::Sender _api; Fn _onRowClicked; rpl::event_stream<> _itemDeselected; - mtpRequestId _loadRequestId = 0; - int _offset = 0; - bool _allLoaded = false; }; -ParticipantsController::ParticipantsController( - not_null window, - not_null channel, - ParticipantType type) -: _window(window) -, _channel(channel) -, _type(type) -, _api(&channel->session().mtp()) { -} - -Main::Session &ParticipantsController::session() const { - return _channel->session(); -} - -void ParticipantsController::setOnRowClicked(Fn callback) { +void FutureOwnerController::setOnRowClicked(Fn callback) { _onRowClicked = callback; } -void ParticipantsController::prepare() { - loadMoreRows(); -} - -void ParticipantsController::rowClicked(not_null row) { +void FutureOwnerController::rowClicked(not_null row) { delegate()->peerListSetRowChecked( row, !delegate()->peerListIsRowChecked(row)); @@ -110,14 +78,51 @@ void ParticipantsController::rowClicked(not_null row) { } } -void ParticipantsController::itemDeselectedHook(not_null peer) { +void FutureOwnerController::itemDeselectedHook(not_null peer) { _itemDeselected.fire({}); } -rpl::producer<> ParticipantsController::itemDeselected() const { +rpl::producer<> FutureOwnerController::itemDeselected() const { return _itemDeselected.events(); } +class ParticipantsController : public FutureOwnerController { +public: + ParticipantsController( + not_null channel, + ParticipantType type); + + Main::Session &session() const override; + void prepare() override; + void loadMoreRows() override; + +private: + const not_null _channel; + const ParticipantType _type; + MTP::Sender _api; + + mtpRequestId _loadRequestId = 0; + int _offset = 0; + bool _allLoaded = false; + +}; + +ParticipantsController::ParticipantsController( + not_null channel, + ParticipantType type) +: _channel(channel) +, _type(type) +, _api(&channel->session().mtp()) { +} + +Main::Session &ParticipantsController::session() const { + return _channel->session(); +} + +void ParticipantsController::prepare() { + loadMoreRows(); +} + void ParticipantsController::loadMoreRows() { if (_loadRequestId || _allLoaded) { return; @@ -191,13 +196,71 @@ void ParticipantsController::loadMoreRows() { }).send(); } +class LegacyParticipantsController : public FutureOwnerController { +public: + LegacyParticipantsController( + not_null chat, + ParticipantType type); + + Main::Session &session() const override; + void prepare() override; + void loadMoreRows() override; + +private: + const not_null _chat; + const ParticipantType _type; + +}; + +LegacyParticipantsController::LegacyParticipantsController( + not_null chat, + ParticipantType type) +: _chat(chat) +, _type(type) { +} + +Main::Session &LegacyParticipantsController::session() const { + return _chat->session(); +} + +void LegacyParticipantsController::prepare() { + if (_chat->noParticipantInfo()) { + _chat->updateFullForced(); + } + const auto &source = (_type == ParticipantType::Admins) + ? _chat->admins + : _chat->participants; + for (const auto &user : source) { + if (user->isBot()) { + continue; + } + if (user->id == peerFromUser(_chat->creator)) { + continue; + } + if (_type == ParticipantType::Members + && _chat->admins.contains(user)) { + continue; + } + delegate()->peerListAppendRow( + std::make_unique(user)); + } + delegate()->peerListRefreshRows(); +} + +void LegacyParticipantsController::loadMoreRows() { +} + } // namespace void SelectFutureOwnerbox( not_null box, - not_null channel, + not_null peer, not_null user) { const auto content = box->verticalLayout(); + const auto channel = peer->asChannel(); + const auto chat = peer->asChat(); + const auto isGroup = peer->isMegagroup() || peer->isChat(); + const auto isLegacy = (chat != nullptr); Ui::AddSkip(content); Ui::AddSkip(content); content->add( @@ -205,7 +268,7 @@ void SelectFutureOwnerbox( content, rpl::single(std::vector>{ user->session().user(), - channel, + peer, }), user, UserpicsTransferType::ChannelFutureOwner), @@ -215,24 +278,36 @@ void SelectFutureOwnerbox( content->add( object_ptr( content, - channel->isMegagroup() + isGroup ? tr::lng_leave_next_owner_box_title_group() : tr::lng_leave_next_owner_box_title(), box->getDelegate()->style().title), st::boxRowPadding); Ui::AddSkip(content); Ui::AddSkip(content); - const auto adminsAreEqual = (channel->adminsCount() <= 1); + const auto adminsCount = [&] { + if (channel) { + return channel->adminsCount(); + } else if (chat) { + return int(chat->admins.size()) + 1; + } + return 0; + }(); + const auto adminsAreEqual = (adminsCount <= 1); content->add( object_ptr( content, - (adminsAreEqual - ? tr::lng_leave_next_owner_box_about - : tr::lng_leave_next_owner_box_about_admin)( + (isLegacy + ? (adminsAreEqual + ? tr::lng_leave_next_owner_box_about_legacy + : tr::lng_leave_next_owner_box_about_admin_legacy) + : (adminsAreEqual + ? tr::lng_leave_next_owner_box_about + : tr::lng_leave_next_owner_box_about_admin))( lt_user, Info::Profile::NameValue(user) | rpl::map(tr::marked), lt_chat, - Info::Profile::NameValue(channel) | rpl::map(tr::marked), + Info::Profile::NameValue(peer) | rpl::map(tr::marked), tr::rich), st::boxLabel), st::boxRowPadding); @@ -264,14 +339,14 @@ void SelectFutureOwnerbox( const auto leave = content->add( object_ptr( content, - channel->isMegagroup() + isGroup ? tr::lng_profile_leave_group() : tr::lng_profile_leave_channel(), st::attentionBoxButton), st::boxRowPadding, style::al_justify); leave->setClickedCallback([=, revoke = false] { - channel->session().api().deleteConversation(channel, revoke); + peer->session().api().deleteConversation(peer, revoke); box->closeBox(); }); select->setClickedCallback([=] { @@ -283,16 +358,31 @@ void SelectFutureOwnerbox( return; } - auto adminsOwned = std::make_unique( - sessionController, - channel, - ParticipantType::Admins); - - auto membersOwned = std::make_unique( - sessionController, - channel, - ParticipantType::Members); - + using Pair = std::pair< + std::unique_ptr, + std::unique_ptr>; + auto makeControllers = [&]() -> Pair { + if (channel) { + return { + std::make_unique( + channel, + ParticipantType::Admins), + std::make_unique( + channel, + ParticipantType::Members), + }; + } else { + return { + std::make_unique( + chat, + ParticipantType::Admins), + std::make_unique( + chat, + ParticipantType::Members), + }; + } + }; + auto [adminsOwned, membersOwned] = makeControllers(); const auto admins = adminsOwned.get(); const auto members = membersOwned.get(); @@ -331,14 +421,14 @@ void SelectFutureOwnerbox( 0, CreatePeerListSectionSubtitle( selectBox, - !channel->isMegagroup() + !isGroup ? tr::lng_select_next_owner_box_sub_admins() : tr::lng_select_next_owner_box_sub_admins_group())); const auto separatorMembers = selectBox->addSeparatorBefore( 1, CreatePeerListSectionSubtitle( selectBox, - !channel->isMegagroup() + !isGroup ? tr::lng_select_next_owner_box_sub_members() : tr::lng_select_next_owner_box_sub_members_group())); rpl::combine( @@ -407,13 +497,13 @@ void SelectFutureOwnerbox( if (const auto user = selected.front()->asUser()) { auto &lifetime = selectBox->lifetime(); lifetime.make_state( - channel, + peer, user, selectBox->uiShow(), [=](std::shared_ptr show) { const auto revoke = false; - channel->session().api().deleteConversation( - channel, + peer->session().api().deleteConversation( + peer, revoke); show->hideLayer(); })->start(); diff --git a/Telegram/SourceFiles/boxes/select_future_owner_box.h b/Telegram/SourceFiles/boxes/select_future_owner_box.h index 7f48bf64c1..a0e1aa716b 100644 --- a/Telegram/SourceFiles/boxes/select_future_owner_box.h +++ b/Telegram/SourceFiles/boxes/select_future_owner_box.h @@ -7,7 +7,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once -class ChannelData; class PeerData; class UserData; @@ -18,5 +17,5 @@ class Show; void SelectFutureOwnerbox( not_null box, - not_null channel, + not_null peer, not_null user); diff --git a/Telegram/SourceFiles/boxes/send_files_box.cpp b/Telegram/SourceFiles/boxes/send_files_box.cpp index 0a2f9b84bf..b259a96e6b 100644 --- a/Telegram/SourceFiles/boxes/send_files_box.cpp +++ b/Telegram/SourceFiles/boxes/send_files_box.cpp @@ -62,6 +62,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_boxes.h" #include "styles/style_chat_helpers.h" #include "styles/style_layers.h" +#include "styles/style_settings.h" +#include "styles/style_menu_icons.h" #include @@ -78,6 +80,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace { constexpr auto kMaxMessageLength = 4096; +constexpr auto kMaxDisplayNameLength = 64; constexpr auto kDragMime = "application/x-tg-sendfile-index"; @@ -117,14 +120,62 @@ void FileDialogCallback( rpl::producer FieldPlaceholder( const Ui::PreparedList &list, - SendFilesWay way) { - return list.canAddCaption( - way.groupFiles() && way.sendImagesAsPhotos(), - way.sendImagesAsPhotos()) + SendFilesWay way, + bool slowmode) { + return Ui::CaptionWillBeAttached(list, way, slowmode) ? tr::lng_photo_caption() : tr::lng_photos_comment(); } +void RenameFileBox( + not_null box, + const QString ¤tName, + Fn apply) { + box->setTitle(tr::lng_rename_file()); + const auto field = box->addRow(object_ptr( + box, + st::settingsDeviceName, + rpl::single(QString()), + currentName)); + const auto extension = [&] { + const auto dot = currentName.lastIndexOf('.'); + return (dot >= 0) ? currentName.mid(dot) : QString(); + }(); + const auto nameWithoutExt = extension.isEmpty() + ? currentName + : currentName.left(currentName.size() - extension.size()); + const auto maxNameLength = kMaxDisplayNameLength - extension.size(); + field->setMaxLength((maxNameLength > 0) ? maxNameLength : 0); + field->setText(nameWithoutExt); + field->selectAll(); + box->setFocusCallback([=] { + field->setFocusFast(); + }); + const auto save = [=] { + const auto newName = field->getLastText().trimmed(); + if (newName.isEmpty()) { + field->showError(); + return; + } + if ((newName.size() + extension.size()) > kMaxDisplayNameLength) { + field->showError(); + return; + } + const auto weak = base::make_weak(box); + apply(newName + extension); + if (const auto strong = weak.get()) { + strong->closeBox(); + } + }; + field->submits() | rpl::on_next([=] { + save(); + }, box->lifetime()); + box->addButton(tr::lng_settings_save(), save); + box->addButton(tr::lng_cancel(), [=] { + box->closeBox(); + }); +} + void EditPriceBox( not_null box, not_null session, @@ -481,6 +532,16 @@ QImage SendFilesBox::Block::generatePriceTagBackground() const { return QImage(); } +bool SendFilesBox::Block::setSingleFileDisplayName( + const QString &displayName) { + if (_isAlbum || _isSingleMedia) { + return false; + } + const auto single = static_cast(_preview.get()); + single->setDisplayName(displayName); + return true; +} + SendFilesBox::SendFilesBox( QWidget*, not_null controller, @@ -708,6 +769,18 @@ void SendFilesBox::refreshAllAfterChanges(int fromItem, Fn perform) { captionResized(); } +bool SendFilesBox::setDisplayNameInSingleFilePreview( + int fileIndex, + const QString &displayName) { + for (auto &block : _blocks) { + if (fileIndex < block.fromIndex() || fileIndex >= block.tillIndex()) { + continue; + } + return block.setSingleFileDisplayName(displayName); + } + return false; +} + void SendFilesBox::openDialogToAddFileToAlbum() { const auto show = uiShow(); const auto checkResult = [=](const Ui::PreparedList &list) { @@ -738,9 +811,9 @@ void SendFilesBox::openDialogToAddFileToAlbum() { void SendFilesBox::refreshMessagesCount() { const auto way = _sendWay.current(); - const auto withCaption = _list.canAddCaption( - way.groupFiles() && way.sendImagesAsPhotos(), - way.sendImagesAsPhotos()); + const auto slowmode = (_limits & SendFilesAllow::OnlyOne) + && (_list.files.size() > 1); + const auto withCaption = Ui::CaptionWillBeAttached(_list, way, slowmode); const auto withComment = !withCaption && _caption && !_caption->isHidden() @@ -774,7 +847,10 @@ void SendFilesBox::refreshButtons() { &_st.tabbed.menu, &_st.tabbed.icons); } - addButton(tr::lng_cancel(), [=] { closeBox(); }); + addButton(tr::lng_cancel(), [=] { + requestToTakeTextWithTags(); + closeBox(); + }); _addFile = addLeftButton( tr::lng_stickers_featured_add(), base::fn_delayed(st::historyAttach.ripple.hideDuration, this, [=] { @@ -1054,7 +1130,9 @@ void SendFilesBox::updateCaptionPlaceholder() { _emojiToggle->hide(); } } else { - _caption->setPlaceholder(FieldPlaceholder(_list, way)); + const auto slowmode = (_limits & SendFilesAllow::OnlyOne) + && (_list.files.size() > 1); + _caption->setPlaceholder(FieldPlaceholder(_list, way, slowmode)); _caption->show(); if (_emojiToggle) { _emojiToggle->show(); @@ -1150,6 +1228,7 @@ void SendFilesBox::pushBlock(int from, int till) { } // Just close the box if it is the only one. if (_list.files.size() == 1) { + requestToTakeTextWithTags(); closeBox(); return; } @@ -1332,6 +1411,40 @@ void SendFilesBox::pushBlock(int from, int till) { _priceTag->update(); } }, widget->lifetime()); + + struct State { + base::unique_qptr menu; + }; + const auto state = widget->lifetime().make_state(); + base::install_event_filter(widget, [=, from = from, till = till]( + not_null e) { + if (e->type() == QEvent::ContextMenu) { + const auto mouse = static_cast(e.get()); + if (from >= till || from >= _list.files.size()) { + return base::EventFilterResult::Continue; + } + const auto fileIndex = from; + state->menu = base::make_unique_q( + widget, + _st.tabbed.menu); + state->menu->addAction(tr::lng_rename_file(tr::now), [=] { + auto &file = _list.files[fileIndex]; + _show->show(Box(RenameFileBox, file.displayName, [=]( + QString newName) { + const auto displayName = std::move(newName); + _list.files[fileIndex].displayName = displayName; + if (!setDisplayNameInSingleFilePreview( + fileIndex, + displayName)) { + refreshAllAfterChanges(from); + } + })); + }, &st::menuIconEdit); + state->menu->popup(mouse->globalPos()); + return base::EventFilterResult::Cancel; + } + return base::EventFilterResult::Continue; + }, widget->lifetime()); } void SendFilesBox::refreshControls(bool initial) { @@ -1509,6 +1622,7 @@ void SendFilesBox::setupCaption() { }, _caption->lifetime()); _caption->cancelled( ) | rpl::on_next([=] { + requestToTakeTextWithTags(); closeBox(); }, _caption->lifetime()); _caption->setMimeDataHook([=]( @@ -1909,6 +2023,16 @@ void SendFilesBox::showFinished() { } } +rpl::producer SendFilesBox::takeTextWithTagsRequests() const { + return _textWithTagsRequests.events(); +} + +void SendFilesBox::requestToTakeTextWithTags() const { + if (_caption && !_caption->isHidden()) { + _textWithTagsRequests.fire_copy(_caption->getTextWithTags()); + } +} + void SendFilesBox::setInnerFocus() { if (_caption && !_caption->isHidden()) { _caption->setFocusFast(); diff --git a/Telegram/SourceFiles/boxes/send_files_box.h b/Telegram/SourceFiles/boxes/send_files_box.h index 3607ce43ca..491c8058de 100644 --- a/Telegram/SourceFiles/boxes/send_files_box.h +++ b/Telegram/SourceFiles/boxes/send_files_box.h @@ -129,6 +129,8 @@ public: _cancelledCallback = std::move(callback); } + [[nodiscard]] rpl::producer takeTextWithTagsRequests() const; + void showFinished() override; ~SendFilesBox(); @@ -177,6 +179,8 @@ private: void applyChanges(); [[nodiscard]] QImage generatePriceTagBackground() const; + [[nodiscard]] bool setSingleFileDisplayName( + const QString &displayName); private: base::unique_qptr _preview; @@ -243,6 +247,9 @@ private: void openDialogToAddFileToAlbum(); void refreshAllAfterChanges(int fromItem, Fn perform = nullptr); + [[nodiscard]] bool setDisplayNameInSingleFilePreview( + int fileIndex, + const QString &displayName); void enqueueNextPrepare(); void addPreparedAsyncFile(Ui::PreparedFile &&file); @@ -250,6 +257,8 @@ private: void checkCharsLimitation(); void refreshMessagesCount(); + void requestToTakeTextWithTags() const; + [[nodiscard]] Fn prepareSendMenuDetails( const SendFilesBoxDescriptor &descriptor); [[nodiscard]] auto prepareSendMenuCallback() @@ -301,7 +310,7 @@ private: object_ptr _scroll; QPointer _inner; - std::vector _blocks; + std::deque _blocks; Fn _whenReadySend; bool _preparing = false; @@ -310,6 +319,8 @@ private: QPointer _send; QPointer _addFile; + rpl::event_stream _textWithTagsRequests; + // AyuGram files reordering [[nodiscard]] bool isFileBlock(int i) const; diff --git a/Telegram/SourceFiles/boxes/share_box.cpp b/Telegram/SourceFiles/boxes/share_box.cpp index 16822de42f..a77bbe09f1 100644 --- a/Telegram/SourceFiles/boxes/share_box.cpp +++ b/Telegram/SourceFiles/boxes/share_box.cpp @@ -1385,24 +1385,26 @@ void ShareBox::Inner::changeCheckState(Chat *chat) { void ShareBox::Inner::chooseForumTopic(not_null forum) { const auto guard = base::make_weak(this); const auto weak = std::make_shared>(); - auto chosen = [=](not_null topic) { + auto chosen = [=](not_null thread) { if (const auto strong = *weak) { strong->closeBox(); } if (!guard) { return; } - const auto row = _chatsIndexed->getRow(topic->owningHistory()); + const auto row = _chatsIndexed->getRow(thread->owningHistory()); if (!row) { return; } const auto chat = getChat(row); - Assert(!chat->topic); - chat->topic = topic; - chat->topic->destroyed( - ) | rpl::on_next([=] { - changePeerCheckState(chat, false); - }, chat->topicLifetime); + if (const auto topic = thread->asTopic()) { + Assert(!chat->topic); + chat->topic = topic; + chat->topic->destroyed( + ) | rpl::on_next([=] { + changePeerCheckState(chat, false); + }, chat->topicLifetime); + } updateChatName(chat); changePeerCheckState(chat, true); }; @@ -1416,8 +1418,8 @@ void ShareBox::Inner::chooseForumTopic(not_null forum) { box->closeBox(); }, box->lifetime()); }; - auto filter = [=](not_null topic) { - return guard && _descriptor.filterCallback(topic); + auto filter = [=](not_null thread) { + return guard && _descriptor.filterCallback(thread); }; auto box = Box( std::make_unique( @@ -1687,6 +1689,7 @@ ShareBox::SubmitCallback ShareBox::DefaultForwardCallback( std::optional videoTimestamp) { struct State final { base::flat_set requests; + mtpRequestId nextRequestKey = 0; }; const auto state = std::make_shared(); return [=]( @@ -1736,13 +1739,6 @@ ShareBox::SubmitCallback ShareBox::DefaultForwardCallback( for (const auto &fullId : existingIds) { mtpMsgIds.push_back(MTP_int(fullId.msg)); } - const auto generateRandom = [&] { - auto result = QVector(existingIds.size()); - for (auto &value : result) { - value = base::RandomValue(); - } - return result; - }; auto &api = history->session().api(); auto &histories = history->owner().histories(); const auto donePhraseArgs = CreateForwardedMessagePhraseArgs( @@ -1751,8 +1747,6 @@ ShareBox::SubmitCallback ShareBox::DefaultForwardCallback( const auto showRecentForwardsToSelf = result.size() == 1 && result.front()->peer()->isSelf() && history->session().premium(); - const auto requestType = Data::Histories::RequestType::Send; - // AyuGram-changed const auto dismiss = [=] @@ -1762,7 +1756,6 @@ ShareBox::SubmitCallback ShareBox::DefaultForwardCallback( } }; - if (AyuForward::isFullAyuForwardNeeded(items.front())) { crl::async([=]{ for (const auto thread : result) { @@ -1776,8 +1769,7 @@ ShareBox::SubmitCallback ShareBox::DefaultForwardCallback( dismiss(); return; - } - if (AyuForward::isAyuForwardNeeded(items)) { + } else if (AyuForward::isAyuForwardNeeded(items)) { crl::async([=] { for (const auto thread : result) { @@ -1793,110 +1785,176 @@ ShareBox::SubmitCallback ShareBox::DefaultForwardCallback( } // AyuGram-changed + for (const auto &thread : result) { + const auto peer = thread->peer(); + const auto threadHistory = thread->owningHistory(); + const auto forum = threadHistory->asForum(); + const auto needNewTopic = forum + && forum->bot() + && Data::IsBotUserCreatesTopics(peer) + && !thread->asTopic(); + const auto effectiveThread = [&]() -> not_null { + if (needNewTopic) { + const auto topic = forum->reserveNewBotTopic(); + Assert(topic != nullptr); + return topic; + } + return thread; + }(); - for (const auto thread : result) { if (!comment.text.isEmpty()) { auto message = Api::MessageToSend( - Api::SendAction(thread, options)); + Api::SendAction(effectiveThread, options)); message.textWithTags = comment; message.action.clearDraft = false; api.sendMessage(std::move(message)); } - const auto topicRootId = thread->topicRootId(); - const auto sublistPeer = thread->maybeSublistPeer(); - const auto kGeneralId = Data::ForumTopic::kGeneralId; - const auto topMsgId = (topicRootId == kGeneralId) - ? MsgId(0) - : topicRootId; - const auto peer = thread->peer(); - const auto threadHistory = thread->owningHistory(); + + const auto topicRootId = effectiveThread->topicRootId(); + const auto sublistPeer = needNewTopic + ? nullptr + : thread->maybeSublistPeer(); + const auto fromPeer = history->peer; + const auto msgCount = int(existingIds.size()); const auto starsPaid = std::min( peer->starsPerMessageChecked(), options.starsApproved); if (starsPaid) { options.starsApproved -= starsPaid; } - histories.sendRequest(threadHistory, requestType, [=]( - Fn finish) { - const auto session = &threadHistory->session(); - auto &api = session->api(); - const auto sendFlags = commonSendFlags - | (topMsgId ? Flag::f_top_msg_id : Flag(0)) - | (ShouldSendSilent(peer, options) - ? Flag::f_silent - : Flag(0)) - | (options.shortcutId - ? Flag::f_quick_reply_shortcut - : Flag(0)) - | (starsPaid ? Flag::f_allow_paid_stars : Flag()) - | (sublistPeer ? Flag::f_reply_to : Flag()) - | (options.suggest ? Flag::f_suggested_post : Flag()) - | (options.effectId ? Flag::f_effect : Flag()); - threadHistory->sendRequestId = api.request( - MTPmessages_ForwardMessages( - MTP_flags(sendFlags), - history->peer->input(), - MTP_vector(mtpMsgIds), - MTP_vector(generateRandom()), - peer->input(), - MTP_int(topMsgId), - (sublistPeer - ? MTP_inputReplyToMonoForum(sublistPeer->input()) - : MTPInputReplyTo()), - MTP_int(options.scheduled), - MTP_int(options.scheduleRepeatPeriod), - MTP_inputPeerEmpty(), // send_as - Data::ShortcutIdToMTP(session, options.shortcutId), - MTP_long(options.effectId), - MTP_int(videoTimestamp.value_or(0)), - MTP_long(starsPaid), - Api::SuggestToMTP(options.suggest) - )).done([=](const MTPUpdates &updates, mtpRequestId reqId) { - threadHistory->session().api().applyUpdates(updates); - if (showRecentForwardsToSelf) { - ApiWrap::ProcessRecentSelfForwards( - &threadHistory->session(), - updates, - peer->id, - history->peer->id); - } - state->requests.remove(reqId); - if (state->requests.empty()) { - if (show->valid()) { - auto phrase = rpl::variable( - ChatHelpers::ForwardedMessagePhrase( - donePhraseArgs)).current(); - if (!phrase.empty()) { - show->showToast(std::move(phrase)); - } - show->hideLayer(); + const auto sendFlags = commonSendFlags + | (ShouldSendSilent(peer, options) + ? Flag::f_silent + : Flag(0)) + | (options.shortcutId + ? Flag::f_quick_reply_shortcut + : Flag(0)) + | (starsPaid ? Flag::f_allow_paid_stars : Flag()) + | (sublistPeer ? Flag::f_reply_to : Flag()) + | (options.suggest ? Flag::f_suggested_post : Flag()) + | (options.effectId ? Flag::f_effect : Flag()); + auto buildMessage = [=]( + not_null history, + FullReplyTo replyTo) + -> Data::Histories::PreparedMessage { + const auto kGeneralId + = Data::ForumTopic::kGeneralId; + const auto realTopMsgId + = (replyTo.topicRootId == kGeneralId) + ? MsgId(0) + : replyTo.topicRootId; + auto flags = sendFlags; + if (realTopMsgId) { + flags |= Flag::f_top_msg_id; + } else { + flags &= ~Flag::f_top_msg_id; + } + auto randoms = QVector(msgCount); + for (auto &value : randoms) { + value = base::RandomValue(); + } + return MTPmessages_ForwardMessages( + MTP_flags(flags), + fromPeer->input(), + MTP_vector(mtpMsgIds), + MTP_vector(randoms), + history->peer->input(), + MTP_int(realTopMsgId), + (sublistPeer + ? MTP_inputReplyToMonoForum( + sublistPeer->input()) + : MTPInputReplyTo()), + MTP_int(options.scheduled), + MTP_int(options.scheduleRepeatPeriod), + MTP_inputPeerEmpty(), + Data::ShortcutIdToMTP( + &history->session(), + options.shortcutId), + MTP_long(options.effectId), + MTP_int(videoTimestamp.value_or(0)), + MTP_long(starsPaid), + Api::SuggestToMTP(options.suggest)); + }; + const auto requestDone = [=]( + const MTPUpdates &updates, + mtpRequestId requestKey) { + const auto &ghost = AyuSettings::ghost(&history->owner().session()); + if (!ghost.sendReadMessages() && ghost.markReadAfterAction() && history->lastMessage()) + { + readHistory(history->lastMessage()); + } + + if (showRecentForwardsToSelf) { + ApiWrap::ProcessRecentSelfForwards( + &threadHistory->session(), + updates, + peer->id, + history->peer->id); + } + state->requests.remove(requestKey); + if (state->requests.empty()) { + if (show->valid()) { + auto phrase = rpl::variable< + TextWithEntities>( + ChatHelpers::ForwardedMessagePhrase( + donePhraseArgs)).current(); + if (!phrase.empty()) { + show->showToast(std::move(phrase)); } + show->hideLayer(); } - - const auto &ghost = AyuSettings::ghost(&history->owner().session()); - if (!ghost.sendReadMessages() && ghost.markReadAfterAction() && history->lastMessage()) - { - readHistory(history->lastMessage()); + } + }; + const auto requestFail = [=]( + const MTP::Error &error, + mtpRequestId requestKey) { + const auto type = error.type(); + if (type.startsWith( + u"ALLOW_PAYMENT_REQUIRED_"_q)) { + show->showToast( + u"Payment requirements changed. " + "Please, try again."_q); + } else if (type + == u"VOICE_MESSAGES_FORBIDDEN"_q) { + show->showToast( + tr::lng_restricted_send_voice_messages( + tr::now, + lt_user, + peer->name())); + } + state->requests.remove(requestKey); + if (state->requests.empty()) { + if (show->valid()) { + show->hideLayer(); } - - finish(); - }).fail([=](const MTP::Error &error) { - const auto type = error.type(); - if (type.startsWith(u"ALLOW_PAYMENT_REQUIRED_"_q)) { - show->showToast(u"Payment requirements changed. " - "Please, try again."_q); - } else if (type == u"VOICE_MESSAGES_FORBIDDEN"_q) { - show->showToast( - tr::lng_restricted_send_voice_messages( - tr::now, - lt_user, - peer->name())); - } - finish(); - }).afterRequest(threadHistory->sendRequestId).send(); - return threadHistory->sendRequestId; - }); - state->requests.insert(threadHistory->sendRequestId); + } + }; + const auto requestKey = ++state->nextRequestKey; + state->requests.insert(requestKey); + histories.sendPreparedMessage( + threadHistory, + FullReplyTo{ .topicRootId = topicRootId }, + uint64(0), + std::move(buildMessage), + [=](const MTPUpdates &updates, + const MTP::Response &) { + requestDone(updates, requestKey); + }, + [=](const MTP::Error &error, + const MTP::Response &) { + requestFail(error, requestKey); + }); + } + if (state->requests.empty()) { + if (show->valid()) { + auto phrase = rpl::variable( + ChatHelpers::ForwardedMessagePhrase( + donePhraseArgs)).current(); + if (!phrase.empty()) { + show->showToast(std::move(phrase)); + } + show->hideLayer(); + } } }; } @@ -2100,7 +2158,7 @@ void FastShareLink( comment.text = url; } auto &api = show->session().api(); - for (const auto thread : result) { + for (const auto &thread : result) { auto message = Api::MessageToSend( Api::SendAction(thread, options)); message.textWithTags = comment; diff --git a/Telegram/SourceFiles/boxes/star_gift_auction_box.cpp b/Telegram/SourceFiles/boxes/star_gift_auction_box.cpp index af11e45a23..805cc67a3b 100644 --- a/Telegram/SourceFiles/boxes/star_gift_auction_box.cpp +++ b/Telegram/SourceFiles/boxes/star_gift_auction_box.cpp @@ -1823,7 +1823,7 @@ TextWithEntities ActiveAuctionsTitle(const Data::ActiveAuctions &auctions) { ).append(' ').append(tr::lng_auction_bar_active(tr::now)); } auto result = tr::marked(); - for (const auto auction : list | ranges::views::take(3)) { + for (const auto &auction : list | ranges::views::take(3)) { result.append(Data::SingleCustomEmoji(auction->gift->document)); } return result.append(' ').append( @@ -1851,7 +1851,7 @@ ManyAuctionsState ActiveAuctionsState(const Data::ActiveAuctions &auctions) { return { std::move(text), !position }; } auto outbid = 0; - for (const auto auction : list) { + for (const auto &auction : list) { if (!winning(auction)) { ++outbid; } @@ -2036,7 +2036,7 @@ Fn ActiveAuctionsCallback( .ends = state.nextRoundAt ? state.nextRoundAt : state.endDate, }; }; - for (const auto auction : list) { + for (const auto &auction : list) { state->list.push_back(singleFrom(*auction)); } return [=] { diff --git a/Telegram/SourceFiles/boxes/star_gift_box.cpp b/Telegram/SourceFiles/boxes/star_gift_box.cpp index 1708b25725..6b404b99e1 100644 --- a/Telegram/SourceFiles/boxes/star_gift_box.cpp +++ b/Telegram/SourceFiles/boxes/star_gift_box.cpp @@ -1332,7 +1332,9 @@ void SendStarsFormRequest( done(Payments::CheckoutResult::Failed, nullptr); }); }).fail([=](const MTP::Error &error) { - show->showToast(error.type()); + if (!ShowGiftErrorToast(show, error)) { + show->showToast(error.type()); + } done(Payments::CheckoutResult::Failed, nullptr); }).send(); } else if (result == BalanceResult::Cancelled) { @@ -1379,7 +1381,9 @@ void UpgradeGift( formDone(Payments::CheckoutResult::Paid, &result); }).fail([=](const MTP::Error &error) { if (const auto strong = weak.get()) { - strong->showToast(error.type()); + if (!ShowGiftErrorToast(strong->uiShow(), error)) { + strong->showToast(error.type()); + } } formDone(Payments::CheckoutResult::Failed, nullptr); }).send(); @@ -2141,7 +2145,7 @@ Controller::Controller(not_null session, PickCallback pick) return aBirthday.day() < bBirthday.day(); }); - for (const auto user : usersWithBirthdays) { + for (const auto &user : usersWithBirthdays) { auto row = std::make_unique(user); if (auto s = status(user->birthday()); !s.isEmpty()) { row->setCustomStatus(std::move(s)); @@ -2860,7 +2864,7 @@ void UpdateGiftSellPrice( const auto newAvailableAt = base::unixtime::now() + seconds; unique->canResellAt = newAvailableAt; ShowResaleGiftLater(show, unique); - } else { + } else if (!ShowGiftErrorToast(show, error)) { show->showToast(type); } }).send(); @@ -3061,9 +3065,10 @@ void SendOfferBuyGift( show->session().api().applyUpdates(result); done(true); }).fail([=](const MTP::Error &error) { - if (error.type() == u""_q) { - } else { - show->showToast(error.type()); + const auto type = error.type(); + if (type == u""_q) { + } else if (!ShowGiftErrorToast(show, error)) { + show->showToast(type); } done(false); }).send(); @@ -4297,7 +4302,9 @@ void RequestOurForm( show->showToast(tr::lng_edit_privacy_gifts_restricted(tr::now)); fail(Payments::CheckoutResult::Cancelled); } else { - show->showToast(type); + if (!ShowGiftErrorToast(show, error)) { + show->showToast(type); + } fail(Payments::CheckoutResult::Failed); } }).send(); @@ -4338,6 +4345,16 @@ void ShowGiftTransferredToast( }); } +bool ShowGiftErrorToast( + std::shared_ptr show, + const MTP::Error &error) { + if (error.type() == u"STARGIFT_ALREADY_BURNED"_q) { + show->showToast(tr::lng_gift_burned_message(tr::now)); + return true; + } + return false; +} + CreditsAmount StarsFromTon( not_null session, CreditsAmount ton) { diff --git a/Telegram/SourceFiles/boxes/star_gift_box.h b/Telegram/SourceFiles/boxes/star_gift_box.h index 2ca942135b..0adfb1a309 100644 --- a/Telegram/SourceFiles/boxes/star_gift_box.h +++ b/Telegram/SourceFiles/boxes/star_gift_box.h @@ -39,6 +39,10 @@ namespace Payments { enum class CheckoutResult; } // namespace Payments +namespace MTP { +class Error; +} // namespace MTP + namespace Settings { struct GiftWearBoxStyleOverride; struct CreditsEntryBoxStyleOverrides; @@ -57,6 +61,7 @@ namespace Ui { class RpWidget; class PopupMenu; class GenericBox; +class Show; class VerticalLayout; void ChooseStarGiftRecipient( @@ -146,6 +151,10 @@ void ShowGiftTransferredToast( not_null to, const Data::UniqueGift &gift); +[[nodiscard]] bool ShowGiftErrorToast( + std::shared_ptr show, + const MTP::Error &error); + [[nodiscard]] CreditsAmount StarsFromTon( not_null session, CreditsAmount ton); diff --git a/Telegram/SourceFiles/boxes/star_gift_cover_box.cpp b/Telegram/SourceFiles/boxes/star_gift_cover_box.cpp index d774f5f05b..5c73421286 100644 --- a/Telegram/SourceFiles/boxes/star_gift_cover_box.cpp +++ b/Telegram/SourceFiles/boxes/star_gift_cover_box.cpp @@ -699,8 +699,12 @@ UniqueGiftCoverWidget::UniqueGiftCoverWidget( + st::uniqueAttributePadding.bottom(); _state->attrs->resize(_state->attrs->width(), attrsHeight); _state->attrs->paintOn([this, astate, attrsHeight](QPainter &p) { + const auto boxPadding = st::giftBoxPadding; const auto skip = st::giftBoxGiftSkip.x(); - const auto available = _state->attrs->width() - 2 * skip; + const auto available = _state->attrs->width() + - boxPadding.left() + - boxPadding.right() + - 2 * skip; const auto single = available / 3; if (single <= 0) { return; @@ -753,10 +757,10 @@ UniqueGiftCoverWidget::UniqueGiftCoverWidget( .position = percent.topLeft(), }); }; - auto left = 0; + auto left = boxPadding.left(); paint(left, astate->model); paint(left + single + skip, astate->backdrop); - paint(_state->attrs->width() - single - left, astate->pattern); + paint(_state->attrs->width() - single - boxPadding.right(), astate->pattern); }); } _state->updateAttrs(*_state->now.gift); @@ -810,10 +814,8 @@ UniqueGiftCoverWidget::UniqueGiftCoverWidget( top += subtitleHeight + (skip / 2); if (_state->attrs) { - _state->attrs->resizeToWidth(width - - st::giftBoxPadding.left() - - st::giftBoxPadding.right()); - _state->attrs->moveToLeft(st::giftBoxPadding.left(), top); + _state->attrs->resizeToWidth(width); + _state->attrs->moveToLeft(0, top); top += _state->attrs->height() + (skip / 2); } else { top += (skip / 2); diff --git a/Telegram/SourceFiles/boxes/star_gift_craft_box.cpp b/Telegram/SourceFiles/boxes/star_gift_craft_box.cpp index e8ecc0e922..0b9c5f5867 100644 --- a/Telegram/SourceFiles/boxes/star_gift_craft_box.cpp +++ b/Telegram/SourceFiles/boxes/star_gift_craft_box.cpp @@ -292,13 +292,12 @@ AbstractButton *MakeRemoveButton( int size, const GiftForCraft &gift, Fn onClick, - rpl::producer edgeColor) { + rpl::producer edgeColor, + const style::icon &icon) { auto remove = object_ptr(parent); - const auto &icon = st::stickerPanDeleteIconFg; const auto add = (size - icon.width()) / 2; remove->resize(icon.size() + QSize(add, add) * 2); - remove->paintOn([=](QPainter &p) { - const auto &icon = st::stickerPanDeleteIconFg; + remove->paintOn([=, &icon](QPainter &p) { icon.paint(p, add, add, add * 2 + icon.width(), st::white->c); }); remove->setAttribute(Qt::WA_TransparentForMouseEvents); @@ -381,13 +380,9 @@ AbstractButton *MakeRemoveButton( }, }, GiftButton::Mode::CraftPreview); entry.button->show(); - if (index > 0) { - entry.button->setClickedCallback([=] { - state->editRequests.fire_copy(index); - }); - } else { - entry.button->setAttribute(Qt::WA_TransparentForMouseEvents); - } + entry.button->setClickedCallback([=] { + state->editRequests.fire_copy(index); + }); entry.button->setGeometry( geometry, state->delegate.buttonExtend()); @@ -423,19 +418,32 @@ AbstractButton *MakeRemoveButton( nullptr, &Entry::button); const auto canRemove = (count > 1); + const auto secondHasAddress = state->entries[1].gift + && !state->entries[1].gift.unique->giftAddress.isEmpty(); for (auto i = 0; i != 4; ++i) { auto &entry = state->entries[i]; if (entry.button) { - if (!canRemove) { - delete base::take(entry.remove); - } else if (!entry.remove) { + delete base::take(entry.remove); + if (canRemove) { + const auto needReplace = (i == 0) && secondHasAddress; + const auto callback = [=] { + if (needReplace) { + state->editRequests.fire_copy(0); + } else { + state->removeRequests.fire_copy(i); + } + }; + const auto &icon = needReplace + ? st::craftReplaceIcon + : st::stickerPanDeleteIconFg; entry.remove = MakeRemoveButton( raw, entry.button, entry.percent->height(), entry.gift, - [=] { state->removeRequests.fire_copy(i); }, - state->edgeColor.value()); + callback, + state->edgeColor.value(), + icon); } } } @@ -664,7 +672,8 @@ void ShowSelectGiftBox( uint64 giftId, Fn chosen, std::vector selected, - Fn boxClosed) { + Fn boxClosed, + bool firstSlot) { struct Entry { Data::SavedStarGift gift; GiftButton *button = nullptr; @@ -694,10 +703,13 @@ void ShowSelectGiftBox( const auto got = crl::guard(box, [=]( std::shared_ptr gift) { - if (!ShowCraftLaterError(box->uiShow(), gift)) { - chosen(GiftForCraft{ .unique = gift }); - box->closeBox(); + if (ShowCraftLaterError(box->uiShow(), gift) + || (firstSlot + && ShowCraftAddressError(box->uiShow(), gift))) { + return; } + chosen(GiftForCraft{ .unique = gift }); + box->closeBox(); }); AddCraftGiftsList( @@ -1194,7 +1206,7 @@ void Craft( ShowSelectGiftBox(controller, giftId, [=](GiftForCraft chosen) { ShowGiftCraftBox(controller, { chosen }, false); closeCurrent(); - }, {}, [=] { *requested = false; }); + }, {}, [=] { *requested = false; }, true); }; StartCraftAnimation( box, @@ -1419,12 +1431,14 @@ void MakeCraftContent( } state->chosen = std::move(copy); }; + const auto first = (state->requestingIndex == 0); ShowSelectGiftBox( controller, giftId, crl::guard(raw, callback), state->chosen.current(), - crl::guard(raw, [=] { state->requestingIndex = -1; })); + crl::guard(raw, [=] { state->requestingIndex = -1; }), + first); }, raw->lifetime()); auto fullName = state->chosen.value( @@ -1501,10 +1515,14 @@ void MakeCraftContent( if (state->crafting) { return; } + const auto &gifts = state->chosen.current(); + if (!gifts.empty() + && ShowCraftAddressError(box->uiShow(), gifts.front().unique)) { + return; + } state->crafting = true; const auto &cs = state->craftState; - const auto &gifts = state->chosen.current(); cs->giftName = state->name.current(); cs->successPermille = state->successPercentPermille.current(); @@ -1735,4 +1753,17 @@ void ShowCraftLaterError( })); } +bool ShowCraftAddressError( + std::shared_ptr show, + std::shared_ptr gift) { + if (gift->giftAddress.isEmpty()) { + return false; + } + show->show(MakeInformBox({ + .text = tr::lng_gift_craft_address_error(tr::marked), + .title = tr::lng_gift_craft_unavailable(), + })); + return true; +} + } // namespace Ui diff --git a/Telegram/SourceFiles/boxes/star_gift_craft_box.h b/Telegram/SourceFiles/boxes/star_gift_craft_box.h index 283235668e..26eb5f2be7 100644 --- a/Telegram/SourceFiles/boxes/star_gift_craft_box.h +++ b/Telegram/SourceFiles/boxes/star_gift_craft_box.h @@ -43,4 +43,8 @@ void ShowCraftLaterError( std::shared_ptr show, TimeId when); +[[nodiscard]] bool ShowCraftAddressError( + std::shared_ptr show, + std::shared_ptr gift); + } // namespace Ui diff --git a/Telegram/SourceFiles/boxes/transfer_gift_box.cpp b/Telegram/SourceFiles/boxes/transfer_gift_box.cpp index 032d8a1dfb..7a093169f9 100644 --- a/Telegram/SourceFiles/boxes/transfer_gift_box.cpp +++ b/Telegram/SourceFiles/boxes/transfer_gift_box.cpp @@ -484,7 +484,9 @@ void TransferGift( ShowTransferGiftLater(strong->uiShow(), gift); } } else if (const auto strong = weak.get()) { - strong->showToast(error.type()); + if (!Ui::ShowGiftErrorToast(strong->uiShow(), error)) { + strong->showToast(type); + } } }).send(); } else { @@ -512,7 +514,9 @@ void ResolveGiftSaleOffer( session->api().applyUpdates(result); done(true); }).fail([=](const MTP::Error &error) { - show->showToast(error.type()); + if (!Ui::ShowGiftErrorToast(show, error)) { + show->showToast(error.type()); + } done(false); }).send(); } diff --git a/Telegram/SourceFiles/boxes/url_auth_box.cpp b/Telegram/SourceFiles/boxes/url_auth_box.cpp index c6e8085199..a0e72efed9 100644 --- a/Telegram/SourceFiles/boxes/url_auth_box.cpp +++ b/Telegram/SourceFiles/boxes/url_auth_box.cpp @@ -9,10 +9,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "apiwrap.h" #include "boxes/url_auth_box_content.h" +#include "chat_helpers/stickers_emoji_pack.h" #include "core/application.h" #include "core/click_handler_types.h" #include "data/data_peer.h" #include "data/data_session.h" +#include "data/stickers/data_custom_emoji.h" #include "data/data_user.h" #include "history/history_item_components.h" #include "history/history_item.h" @@ -24,6 +26,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "main/main_session.h" #include "ui/boxes/confirm_box.h" #include "ui/controls/userpic_button.h" +#include "ui/dynamic_thumbnails.h" +#include "ui/emoji_config.h" #include "ui/layers/generic_box.h" #include "ui/toast/toast.h" #include "ui/widgets/menu/menu_action.h" @@ -38,14 +42,52 @@ namespace UrlAuthBox { namespace { using AnotherSessionFactory = Fn()>; +using OnUserChangedCallback = Fn)>; struct SwitchAccountResult { - not_null widget; + Ui::RpWidget *widget = nullptr; AnotherSessionFactory anotherSession; + OnUserChangedCallback setOnUserChanged; + Fn updateUserIdHint; }; +[[nodiscard]] std::shared_ptr MakeMatchCodeImage( + not_null session, + const QString &code) { + auto emojiLength = 0; + const auto emoji = Ui::Emoji::Find(code, &emojiLength); + if (!emoji || emojiLength != code.size()) { + return nullptr; + } + const auto makeFor = [&](not_null source) { + if (const auto sticker = source->emojiStickersPack() + .stickerForEmoji(emoji)) { + return Ui::MakeEmojiThumbnail( + &source->data(), + Data::SerializeCustomEmojiId(sticker.document), + nullptr, + nullptr, + 1); + } + return std::shared_ptr(); + }; + if (session->isTestMode()) { + for (const auto &account : Core::App().domain().orderedAccounts()) { + if (!account->sessionExists() + || account->session().isTestMode()) { + continue; + } + if (const auto image = makeFor(&account->session())) { + return image; + } + } + } + return makeFor(session); +} + [[nodiscard]] SwitchAccountResult AddAccountsMenu( - not_null parent) { + not_null parent, + UserId userIdHint = UserId()) { const auto session = &Core::App().domain().active().session(); const auto widget = Ui::CreateChild( parent, @@ -53,15 +95,33 @@ struct SwitchAccountResult { struct State { base::unique_qptr menu; UserData *currentUser = nullptr; + Fn onUserChanged; }; const auto state = widget->lifetime().make_state(); - state->currentUser = session->user(); + + const auto isCurrentTest = session->isTestMode(); + const auto findHintedUser = [&]() -> UserData* { + if (!userIdHint) { + return session->user().get(); + } + for (const auto &account : Core::App().domain().orderedAccounts()) { + if (!account->sessionExists() + || (account->session().isTestMode() != isCurrentTest)) { + continue; + } + if (account->session().userId() == userIdHint) { + return account->session().user(); + } + } + return session->user().get(); + }; + + state->currentUser = findHintedUser(); const auto userpic = Ui::CreateChild( parent, state->currentUser, st::restoreUserpicIcon); widget->setUserpic(userpic); - const auto isCurrentTest = session->isTestMode(); const auto filtered = [=] { auto result = std::vector>(); for (const auto &account : Core::App().domain().orderedAccounts()) { @@ -90,6 +150,9 @@ struct SwitchAccountResult { user, st::restoreUserpicIcon); widget->setUserpic(newUserpic); + if (state->onUserChanged) { + state->onUserChanged(); + } }); auto owned = base::make_unique_q( state->menu->menu(), @@ -116,6 +179,25 @@ struct SwitchAccountResult { return { widget, [=] { return &state->currentUser->session(); }, + [=](Fn callback) { state->onUserChanged = callback; }, + [=](UserId newUserIdHint) { + const auto isCurrentTest = session->isTestMode(); + for (const auto &acc : Core::App().domain().orderedAccounts()) { + if (!acc->sessionExists() + || (acc->session().isTestMode() != isCurrentTest)) { + continue; + } + if (acc->session().userId() == newUserIdHint) { + state->currentUser = acc->session().user(); + const auto next = Ui::CreateChild( + parent, + state->currentUser, + st::restoreUserpicIcon); + widget->setUserpic(next); + break; + } + } + }, }; } @@ -159,7 +241,8 @@ void ActivateButton( inputPeer, MTP_int(itemId.msg), MTP_int(buttonId), - MTPstring() // #TODO auth url + MTPstring(), // #TODO auth url + MTPstring() // in_app_origin )).done([=](const MTPUrlAuthResult &result) { const auto button = HistoryMessageMarkupButton::Get( &session->data(), @@ -214,7 +297,8 @@ void ActivateUrl( MTPInputPeer(), MTPint(), // msg_id MTPint(), // button_id - MTP_string(url) + MTP_string(url), + MTPstring() // in_app_origin )).done([=](const MTPUrlAuthResult &result) { result.match([&](const MTPDurlAuthResultAccepted &data) { UrlClickHandler::Open(qs(data.vurl().value_or_empty()), context); @@ -269,19 +353,26 @@ void RequestButton( }; const auto callback = [=](Result result) { if (!result.auth) { + session->api().request(MTPmessages_DeclineUrlAuth( + MTP_string(url) + )).send(); finishWithUrl(url, false); } else if (session->data().message(itemId)) { using Flag = MTPmessages_AcceptUrlAuth::Flag; const auto flags = Flag(0) | (result.allowWrite ? Flag::f_write_allowed : Flag(0)) | (result.sharePhone ? Flag::f_share_phone_number : Flag(0)) + | (result.matchCode.isEmpty() ? Flag(0) : Flag::f_match_code) | (Flag::f_peer | Flag::f_msg_id | Flag::f_button_id); session->api().request(MTPmessages_AcceptUrlAuth( MTP_flags(flags), inputPeer, MTP_int(itemId.msg), MTP_int(buttonId), - MTPstring() // #TODO auth url + MTPstring(), + result.matchCode.isEmpty() + ? MTPstring() + : MTP_string(result.matchCode) )).done([=](const MTPUrlAuthResult &result) { const auto accepted = result.match( [](const MTPDurlAuthResultAccepted &data) { @@ -322,42 +413,70 @@ void RequestUrl( not_null session, const QString &url, QVariant context) { + struct State { + base::weak_qptr box; + AnotherSessionFactory anotherSession = nullptr; + QString firstMatchCode; + rpl::lifetime boxDeclineLifetime; + rpl::lifetime matchCodesBoxDeclineLifetime; + }; const auto bot = request.is_request_write_access() ? session->data().processUser(request.vbot()).get() : nullptr; const auto requestPhone = request.is_request_phone_number(); + const auto matchCodesFirst = request.is_match_codes_first(); const auto domain = qs(request.vdomain()); - const auto box = std::make_shared>(); - const auto finishWithUrl = [=](const QString &url, bool accepted) { - if (*box) { - (*box)->closeBox(); + const auto userIdHint = request.vuser_id_hint() + ? peerToUser(peerFromUser(*request.vuser_id_hint())) + : UserId(); + const auto matchCodes = [&] { + auto result = QStringList(); + if (const auto codes = request.vmatch_codes()) { + for (const auto &code : codes->v) { + result.push_back(qs(code)); + } + } + return result; + }(); + const auto state = std::make_shared(); + const auto finishWithUrl = [=](const QString &to, bool accepted) { + if (state->box) { + state->box->closeBox(); } - if (url.isEmpty() && accepted) { + if ((to.isEmpty() && accepted) || (to == url)) { } else { - UrlClickHandler::Open(url, context); + UrlClickHandler::Open(to, context); } }; - const auto anotherSessionFactory - = std::make_shared(nullptr); + const auto resolveSession = [=] { + return state->anotherSession ? state->anotherSession() : session; + }; + const auto requestDecline = [=] { + resolveSession()->api().request(MTPmessages_DeclineUrlAuth( + MTP_string(url) + )).send(); + }; const auto sendRequest = [=](Result result) { if (!result.auth) { + requestDecline(); finishWithUrl(url, false); } else { const auto sharePhone = result.sharePhone; using Flag = MTPmessages_AcceptUrlAuth::Flag; const auto flags = Flag::f_url | (result.allowWrite ? Flag::f_write_allowed : Flag(0)) - | (sharePhone ? Flag::f_share_phone_number : Flag(0)); - const auto currentSession = anotherSessionFactory - ? (*anotherSessionFactory)() - : session; - currentSession->api().request(MTPmessages_AcceptUrlAuth( + | (sharePhone ? Flag::f_share_phone_number : Flag(0)) + | (result.matchCode.isEmpty() ? Flag(0) : Flag::f_match_code); + resolveSession()->api().request(MTPmessages_AcceptUrlAuth( MTP_flags(flags), MTPInputPeer(), MTPint(), // msg_id MTPint(), // button_id - MTP_string(url) + MTP_string(url), + result.matchCode.isEmpty() + ? MTPstring() + : MTP_string(result.matchCode) )).done([=](const MTPUrlAuthResult &result) { const auto accepted = result.match( [](const MTPDurlAuthResultAccepted &data) { @@ -405,69 +524,157 @@ void RequestUrl( const auto device = qs(request.vplatform().value_or("Unknown platform")); const auto ip = qs(request.vip().value_or("Unknown IP")); const auto region = qs(request.vregion().value_or("Unknown region")); - *box = show->show(Box([=](not_null box) { - const auto callback = [=](Result result) { - if (!requestPhone) { - return sendRequest(result); - } - box->uiShow()->show(Box([=](not_null box) { - box->setTitle(tr::lng_url_auth_phone_sure_title()); - const auto confirm = [=](bool confirmed) { - return [=](Fn close) { - auto copy = result; - copy.sharePhone = confirmed; - sendRequest(copy); - close(); + const auto showAuthBox = [=] { + state->box = show->show(Box([=](not_null box) { + const auto accountResult = box->lifetime().make_state< + SwitchAccountResult>(nullptr); + const auto matchCodesShared = box->lifetime().make_state< + rpl::variable>(matchCodes); + const auto reloadRequest = [=] { + using Flag = MTPmessages_RequestUrlAuth::Flag; + const auto currentSession = resolveSession(); + currentSession->api().request(MTPmessages_RequestUrlAuth( + MTP_flags(Flag::f_url), + MTPInputPeer(), + MTPint(), // msg_id + MTPint(), // button_id + MTP_string(url), + MTPstring() // in_app_origin + )).done(crl::guard(box, [=](const MTPUrlAuthResult &result) { + result.match([&](const MTPDurlAuthResultRequest &data) { + const auto newUserId = data.vuser_id_hint() + ? peerToUser(peerFromUser(*data.vuser_id_hint())) + : UserId(); + accountResult->updateUserIdHint(newUserId); + auto newCodes = QStringList(); + if (const auto codes = data.vmatch_codes()) { + for (const auto &code : codes->v) { + newCodes.push_back(qs(code)); + } + } + *matchCodesShared = newCodes; + }, [](const auto &) {}); + })).send(); + }; + const auto callback = [=](Result result) { + state->boxDeclineLifetime.destroy(); + if (result.matchCode.isEmpty() + && !state->firstMatchCode.isEmpty()) { + result.matchCode = state->firstMatchCode; + } + if (!requestPhone) { + return sendRequest(result); + } + box->uiShow()->show(Box([=](not_null box) { + box->setTitle(tr::lng_url_auth_phone_sure_title()); + const auto confirm = [=](bool confirmed) { + return [=](Fn close) { + auto copy = result; + copy.sharePhone = confirmed; + sendRequest(copy); + close(); + }; }; - }; - const auto currentSession = anotherSessionFactory - ? (*anotherSessionFactory)() - : session; - const auto capitalized = [=](const QString &value) { - return value.left(1).toUpper() + value.mid(1).toLower(); - }; - using namespace Info::Profile; - Ui::ConfirmBox( - box, - Ui::ConfirmBoxArgs{ - .text = tr::lng_url_auth_phone_sure_text( - lt_domain, - rpl::single(tr::bold(capitalized(domain))), - lt_phone, - PhoneValue(currentSession->user()), - tr::rich), - .confirmed = confirm(true), - .cancelled = confirm(false), - .confirmText = tr::lng_allow_bot(), - .cancelText = tr::lng_url_auth_phone_sure_deny(), - }); - })); - }; - ShowDetails( - box, - url, - domain, - callback, - bot - ? object_ptr( - box->verticalLayout(), - bot, - st::defaultUserpicButton, - Ui::PeerUserpicShape::Forum) - : nullptr, - bot ? Info::Profile::NameValue(bot) : nullptr, - browser, - device, - ip, - region); + const auto currentSession = resolveSession(); + const auto capitalized = [=](const QString &v) { + return v.left(1).toUpper() + v.mid(1).toLower(); + }; + using namespace Info::Profile; + Ui::ConfirmBox( + box, + Ui::ConfirmBoxArgs{ + .text = tr::lng_url_auth_phone_sure_text( + lt_domain, + rpl::single(tr::bold(capitalized(domain))), + lt_phone, + PhoneValue(currentSession->user()), + tr::rich), + .confirmed = confirm(true), + .cancelled = confirm(false), + .confirmText = tr::lng_allow_bot(), + .cancelText = tr::lng_url_auth_phone_sure_deny(), + }); + })); + }; + ShowDetails( + box, + url, + domain, + [=](QString code) -> std::shared_ptr { + return MakeMatchCodeImage(resolveSession(), code); + }, + callback, + (bot + ? object_ptr( + box->verticalLayout(), + bot, + st::defaultUserpicButton, + Ui::PeerUserpicShape::Forum) + : nullptr), + bot ? Info::Profile::NameValue(bot) : nullptr, + browser, + device, + ip, + region, + (matchCodesFirst + ? (rpl::single(QStringList()) | rpl::type_erased) + : matchCodesShared->value())); - const auto content = box->verticalLayout(); - const auto accountResult = AddAccountsMenu(content); - content->widthValue() | rpl::on_next([=, w = accountResult.widget] { - w->moveToRight(st::lineWidth * 4, 0); - }, accountResult.widget->lifetime()); - *anotherSessionFactory = accountResult.anotherSession; - })); + *accountResult = AddAccountsMenu( + box->verticalLayout(), + userIdHint); + box->verticalLayout()->widthValue( + ) | rpl::on_next([=, w = (*accountResult).widget] { + w->moveToRight(st::lineWidth * 4, 0); + }, (*accountResult).widget->lifetime()); + state->anotherSession = (*accountResult).anotherSession; + (*accountResult).setOnUserChanged(reloadRequest); + })); + state->box->boxClosing() | rpl::on_next([=] { + requestDecline(); + }, state->boxDeclineLifetime); + }; + if (!matchCodesFirst || matchCodes.isEmpty()) { + showAuthBox(); + return; + } + auto matchCodesBox = base::weak_qptr(); + matchCodesBox = show->show( + Box([=](not_null matchBox) { + ShowMatchCodesBox( + matchBox, + [=](QString code) -> std::shared_ptr { + return MakeMatchCodeImage(resolveSession(), code); + }, + domain, + matchCodes, + [=](QString matchCode) { + state->matchCodesBoxDeclineLifetime.destroy(); + resolveSession()->api().request( + MTPmessages_CheckUrlAuthMatchCode( + MTP_string(url), + MTP_string(matchCode)) + ).done([=](const MTPBool &result) { + if (!mtpIsTrue(result)) { + show->showToast( + tr::lng_url_auth_phone_toast_bad_expired( + tr::now)); + return; + } + state->firstMatchCode = std::move(matchCode); + showAuthBox(); + }).fail([=](const MTP::Error &error) { + show->showToast((error.type() == u"URL_EXPIRED"_q) + ? tr::lng_url_auth_phone_toast_bad_expired( + tr::now) + : error.type()); + }).send(); + }); + }), + Ui::LayerOption::KeepOther); + matchCodesBox->boxClosing() | rpl::on_next([=] { + requestDecline(); + }, state->matchCodesBoxDeclineLifetime); } } // namespace UrlAuthBox diff --git a/Telegram/SourceFiles/boxes/url_auth_box_content.cpp b/Telegram/SourceFiles/boxes/url_auth_box_content.cpp index 94240b3891..fbbd0a0a25 100644 --- a/Telegram/SourceFiles/boxes/url_auth_box_content.cpp +++ b/Telegram/SourceFiles/boxes/url_auth_box_content.cpp @@ -9,13 +9,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/qthelp_url.h" #include "lang/lang_keys.h" +#include "ui/dynamic_image.h" #include "ui/effects/ripple_animation.h" #include "ui/layers/generic_box.h" #include "ui/vertical_list.h" #include "ui/widgets/buttons.h" #include "ui/widgets/checkbox.h" +#include "ui/widgets/horizontal_fit_container.h" #include "ui/widgets/labels.h" #include "ui/widgets/tooltip.h" +#include "ui/emoji_config.h" #include "ui/wrap/vertical_layout.h" #include "ui/ui_utility.h" #include "styles/style_boxes.h" @@ -26,8 +29,184 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace UrlAuthBox { namespace { +constexpr auto kEmojiAnimationActiveFor = crl::time(250); + +void PrepareFullWidthRoundButton( + not_null button, + not_null content, + const style::margins &padding) { + button->setTextTransform(Ui::RoundButton::TextTransform::NoTransform); + button->setFullRadius(true); + const auto paddingHorizontal = padding.left() + padding.right(); + content->widthValue() | rpl::on_next([=](int w) { + button->resize(w - paddingHorizontal, button->height()); + }, button->lifetime()); +} + } // namespace +void ShowMatchCodesBox( + not_null box, + Fn(QString)> emojiImageFactory, + const QString &domain, + const QStringList &codes, + Fn callback) { + box->setWidth(st::boxWidth); + box->setStyle(st::urlAuthBox); + + const auto content = box->verticalLayout(); + + Ui::AddSkip(content); + content->add( + object_ptr( + content, + tr::lng_url_auth_match_code_title(), + st::urlAuthCodesTitle), + st::boxRowPadding, + style::al_top); + + Ui::AddSkip(content); + Ui::AddSkip(content); + + const auto buttons = content->add( + object_ptr( + content, + st::boxRowPadding.left() * 2), + st::boxRowPadding); + buttons->resize(0, st::urlAuthCodesButton.height); + + for (const auto &code : codes) { + auto emojiLength = 0; + const auto emoji = Ui::Emoji::Find(code, &emojiLength); + const auto emojiCode = (emoji && (emojiLength == code.size())); + const auto button = Ui::CreateChild( + buttons, + rpl::single(emojiCode ? QString() : code), + st::urlAuthCodesButton); + if (emojiCode) { + button->setTextFgOverride(QColor(Qt::transparent)); + const auto overlay = Ui::CreateChild(button); + overlay->setAttribute(Qt::WA_TransparentForMouseEvents); + overlay->show(); + struct State { + std::shared_ptr image; + bool hovered = false; + crl::time lastFrameUpdate = 0; + }; + const auto state = overlay->lifetime().make_state(); + const auto animationActive = [=] { + return state->lastFrameUpdate + && (crl::now() - state->lastFrameUpdate + <= kEmojiAnimationActiveFor); + }; + const auto refreshImage = [=] { + if (state->image) { + state->image->subscribeToUpdates(nullptr); + } + state->image = emojiImageFactory + ? emojiImageFactory(code) + : nullptr; + if (state->image) { + state->image->subscribeToUpdates([=] { + state->lastFrameUpdate = crl::now(); + overlay->update(); + }); + } + overlay->update(); + }; + refreshImage(); + overlay->lifetime().add([=] { + if (state->image) { + state->image->subscribeToUpdates(nullptr); + } + }); + button->events() | rpl::on_next([=](not_null e) { + switch (e->type()) { + case QEvent::Enter: + if (!state->hovered) { + state->hovered = true; + if (!animationActive()) { + refreshImage(); + } + } + break; + case QEvent::Leave: + state->hovered = false; + break; + default: + break; + } + }, overlay->lifetime()); + button->sizeValue() | rpl::on_next([=](QSize size) { + overlay->resize(size); + }, overlay->lifetime()); + overlay->paintOn([=](QPainter &p) { + if (state->image) { + const auto side = st::urlAuthCodesButton.height; + const auto frame = state->image->image(side); + const auto visible = frame.isNull() + ? 0 + : (frame.width() / frame.devicePixelRatio()); + p.drawImage( + QPoint( + (overlay->width() - visible) / 2, + (overlay->height() - visible) / 2), + frame); + return; + } + const auto size = Ui::Emoji::GetSizeLarge(); + const auto visible = size / style::DevicePixelRatio(); + Ui::Emoji::Draw( + p, + emoji, + size, + (overlay->width() - visible) / 2, + (overlay->height() - visible) / 2); + }); + } + button->setTextTransform(Ui::RoundButton::TextTransform::NoTransform); + button->setFullRadius(true); + button->setClickedCallback([=] { + callback(code); + box->closeBox(); + }); + buttons->add(button); + } + + Ui::AddSkip(content); + + const auto domainUrl = qthelp::validate_url(domain); + if (!domainUrl.isEmpty()) { + Ui::AddSkip(content); + Ui::AddSkip(content); + content->add( + object_ptr( + content, + tr::lng_url_auth_login_title( + lt_domain, + rpl::single(Ui::Text::Link(domain, domainUrl)), + tr::marked), + st::urlAuthCheckboxAbout), + st::boxRowPadding, + style::al_top); + } + Ui::AddSkip(content); + Ui::AddSkip(content); + { + const auto &padding = st::boxRowPadding; + const auto button = content->add( + object_ptr( + content, + tr::lng_cancel(), + st::attentionBoxButton), + padding); + PrepareFullWidthRoundButton(button, content, padding); + button->setClickedCallback([=] { + box->closeBox(); + }); + } +} + SwitchableUserpicButton::SwitchableUserpicButton( not_null parent, int size) @@ -246,14 +425,17 @@ void ShowDetails( not_null box, const QString &url, const QString &domain, + Fn(QString)> emojiImageFactory, Fn callback, object_ptr userpicOwned, rpl::producer botName, const QString &browser, const QString &platform, const QString &ip, - const QString ®ion) { + const QString ®ion, + rpl::producer matchCodes) { box->setWidth(st::boxWidth); + box->setStyle(st::urlAuthBox); const auto content = box->verticalLayout(); @@ -342,13 +524,64 @@ void ShowDetails( Ui::AddSkip(content); } - box->addButton(tr::lng_url_auth_login_button(), [=] { - callback({ - .auth = true, - .allowWrite = (allowMessages && allowMessages->toggled()), + struct State { + QStringList matchCodes; + }; + const auto state = box->lifetime().make_state(); + std::move(matchCodes) | rpl::on_next([=](const QStringList &codes) { + state->matchCodes = codes; + }, box->lifetime()); + + Ui::AddSkip(content); + { + const auto &padding = st::boxRowPadding; + const auto button = content->add( + object_ptr( + content, + tr::lng_url_auth_login_button(), + st::defaultLightButton), + padding); + PrepareFullWidthRoundButton(button, content, padding); + button->setClickedCallback([=] { + if (state->matchCodes.isEmpty()) { + callback({ + .auth = true, + .allowWrite = (allowMessages && allowMessages->toggled()), + }); + return; + } + box->uiShow()->show(Box([=]( + not_null matchCodesBox) { + ShowMatchCodesBox( + matchCodesBox, + emojiImageFactory, + domain, + state->matchCodes, + [=](QString matchCode) { + callback({ + .auth = true, + .allowWrite = (allowMessages + && allowMessages->toggled()), + .matchCode = std::move(matchCode), + }); + }); + })); }); - }); - box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); + } + Ui::AddSkip(content); + { + const auto &padding = st::boxRowPadding; + const auto button = content->add( + object_ptr( + content, + tr::lng_suggest_action_decline(), + st::attentionBoxButton), + padding); + PrepareFullWidthRoundButton(button, content, padding); + button->setClickedCallback([=] { + box->closeBox(); + }); + } } } // namespace UrlAuthBox diff --git a/Telegram/SourceFiles/boxes/url_auth_box_content.h b/Telegram/SourceFiles/boxes/url_auth_box_content.h index ccaa0b480a..5d0886cd20 100644 --- a/Telegram/SourceFiles/boxes/url_auth_box_content.h +++ b/Telegram/SourceFiles/boxes/url_auth_box_content.h @@ -10,6 +10,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/buttons.h" namespace Ui { +class DynamicImage; class GenericBox; class VerticalLayout; } // namespace Ui @@ -20,6 +21,7 @@ struct Result { bool auth : 1 = false; bool allowWrite : 1 = false; bool sharePhone : 1 = false; + QString matchCode; }; class SwitchableUserpicButton final : public Ui::RippleButton { @@ -51,6 +53,13 @@ void AddAuthInfoRow( const QString &leftText, const style::icon &icon); +void ShowMatchCodesBox( + not_null box, + Fn(QString)> emojiImageFactory, + const QString &domain, + const QStringList &codes, + Fn callback); + void Show( not_null box, const QString &url, @@ -63,12 +72,14 @@ void ShowDetails( not_null box, const QString &url, const QString &domain, + Fn(QString)> emojiImageFactory, Fn callback, object_ptr userpicOwned, rpl::producer botName, const QString &browser, const QString &platform, const QString &ip, - const QString ®ion); + const QString ®ion, + rpl::producer matchCodes = rpl::single(QStringList())); } // namespace UrlAuthBox diff --git a/Telegram/SourceFiles/calls/calls_panel.cpp b/Telegram/SourceFiles/calls/calls_panel.cpp index 8f57effdfb..97a3c9295b 100644 --- a/Telegram/SourceFiles/calls/calls_panel.cpp +++ b/Telegram/SourceFiles/calls/calls_panel.cpp @@ -145,11 +145,17 @@ Panel::Panel(not_null call) , _controlsShownForceTimer([=] { controlsShownForce(false); }) { _decline->setDuration(st::callPanelDuration); _decline->entity()->setText(tr::lng_call_decline()); + _decline->entity()->setAccessibleName(tr::lng_call_decline(tr::now)); _cancel->setDuration(st::callPanelDuration); _cancel->entity()->setText(tr::lng_call_cancel()); + _cancel->entity()->setAccessibleName(tr::lng_call_cancel(tr::now)); _screencast->setDuration(st::callPanelDuration); + _screencast->entity()->setAccessibleName(tr::lng_call_screencast(tr::now)); _addPeople->setDuration(st::callPanelDuration); _addPeople->entity()->setText(tr::lng_call_add_people()); + _addPeople->entity()->setAccessibleName(tr::lng_call_add_people(tr::now)); + _camera->setAccessibleName(tr::lng_call_start_video(tr::now)); + _mute->entity()->setAccessibleName(tr::lng_call_mute_audio(tr::now)); initWindow(); initWidget(); @@ -732,6 +738,9 @@ void Panel::reinitWithCall(Call *call) { _mute->entity()->setText(mute ? tr::lng_call_unmute_audio() : tr::lng_call_mute_audio()); + _mute->entity()->setAccessibleName(mute + ? tr::lng_call_unmute_audio(tr::now) + : tr::lng_call_mute_audio(tr::now)); }, _callLifetime); _call->videoOutgoing()->stateValue( @@ -742,6 +751,9 @@ void Panel::reinitWithCall(Call *call) { _camera->setText(active ? tr::lng_call_stop_video() : tr::lng_call_start_video()); + _camera->setAccessibleName(active + ? tr::lng_call_stop_video(tr::now) + : tr::lng_call_start_video(tr::now)); } { const auto active = _call->isSharingScreen(); @@ -1030,12 +1042,14 @@ void Panel::initMediaDeviceToggles() { { Webrtc::DeviceType::Camera, _call->cameraDeviceIdValue() }, }); }); + _cameraDeviceToggle->setAccessibleName(tr::lng_settings_call_camera(tr::now)); _audioDeviceToggle->setClickedCallback([=] { showDevicesMenu(_audioDeviceToggle, { { Webrtc::DeviceType::Playback, _call->playbackDeviceIdValue() }, { Webrtc::DeviceType::Capture, _call->captureDeviceIdValue() }, }); }); + _audioDeviceToggle->setAccessibleName(tr::lng_settings_call_section_output(tr::now)); } void Panel::showDevicesMenu( @@ -1381,6 +1395,7 @@ void Panel::stateChanged(State state) { st::callStartVideo); _startVideo->show(); _startVideo->setText(tr::lng_call_start_video()); + _startVideo->setAccessibleName(tr::lng_call_start_video(tr::now)); _startVideo->clicks() | rpl::map_to(true) | rpl::start_to_stream( _startOutgoingRequests, _startVideo->lifetime()); @@ -1438,15 +1453,17 @@ void Panel::stateChanged(State state) { void Panel::refreshAnswerHangupRedialLabel() { Expects(_answerHangupRedialState.has_value()); - _answerHangupRedial->setText([&] { + const auto phrase = [&] { switch (*_answerHangupRedialState) { - case AnswerHangupRedialState::Answer: return tr::lng_call_accept(); - case AnswerHangupRedialState::Hangup: return tr::lng_call_end_call(); - case AnswerHangupRedialState::Redial: return tr::lng_call_redial(); - case AnswerHangupRedialState::StartCall: return tr::lng_call_start(); + case AnswerHangupRedialState::Answer: return tr::lng_call_accept; + case AnswerHangupRedialState::Hangup: return tr::lng_call_end_call; + case AnswerHangupRedialState::Redial: return tr::lng_call_redial; + case AnswerHangupRedialState::StartCall: return tr::lng_call_start; } Unexpected("AnswerHangupRedialState value."); - }()); + }(); + _answerHangupRedial->setText(phrase()); + _answerHangupRedial->setAccessibleName(phrase(tr::now)); } void Panel::updateStatusText(State state) { diff --git a/Telegram/SourceFiles/calls/group/calls_group_invite_controller.cpp b/Telegram/SourceFiles/calls/group/calls_group_invite_controller.cpp index 5a81a58a20..537e7d3ed9 100644 --- a/Telegram/SourceFiles/calls/group/calls_group_invite_controller.cpp +++ b/Telegram/SourceFiles/calls/group/calls_group_invite_controller.cpp @@ -344,7 +344,7 @@ void ConfInviteRow::elementsPaint( } void prepare() override { - for (const auto user : _users) { + for (const auto &user : _users) { delegate()->peerListAppendRow( std::make_unique(user, _st)); } @@ -485,7 +485,7 @@ ConfInviteController::ConfInviteController( , _shareLink(std::move(shareLink)) { if (!_shareLink) { _skip.reserve(_prioritize.size()); - for (const auto user : _prioritize) { + for (const auto &user : _prioritize) { _skip.emplace(user); } } @@ -747,7 +747,9 @@ std::unique_ptr InviteController::createRow( || user->isInaccessible()) { return nullptr; } - auto result = std::make_unique(user); + auto result = std::make_unique( + user, + Type{ .chatStyle = _chatStyle.get(), .circleCache = &_pillCircleCache }); _rowAdded.fire_copy(user); _inGroup.emplace(user); if (isAlreadyIn(user)) { diff --git a/Telegram/SourceFiles/calls/group/calls_group_panel.cpp b/Telegram/SourceFiles/calls/group/calls_group_panel.cpp index 02329beaeb..4c8fb805d3 100644 --- a/Telegram/SourceFiles/calls/group/calls_group_panel.cpp +++ b/Telegram/SourceFiles/calls/group/calls_group_panel.cpp @@ -616,6 +616,7 @@ void Panel::initControls() { }, lifetime()); _hangup->setClickedCallback([=] { endCall(); }); + _hangup->setAccessibleName(tr::lng_group_call_leave(tr::now)); const auto scheduleDate = _call->scheduleDate(); if (scheduleDate) { @@ -701,12 +702,14 @@ void Panel::refreshLeftButton() { _settings.destroy(); _callShare.create(widget(), st::groupCallShare); _callShare->setClickedCallback(_callShareLinkCallback); + _callShare->setAccessibleName(tr::lng_group_call_invite(tr::now)); } else { _callShare.destroy(); _settings.create(widget(), st::groupCallSettings); _settings->setClickedCallback([=] { uiShow()->showBox(Box(SettingsBox, _call)); }); + _settings->setAccessibleName(tr::lng_group_call_settings_title(tr::now)); trackControls(_trackControls, true); } const auto raw = _callShare ? _callShare.data() : _settings.data(); @@ -754,6 +757,7 @@ void Panel::refreshVideoButtons(std::optional overrideWideMode) { StickedTooltipHide::Activated); _call->toggleVideo(!_call->isSharingCamera()); }); + _video->setAccessibleName(tr::lng_call_start_video(tr::now)); _video->setColorOverrides( toggleableOverrides(_call->isSharingCameraValue())); _call->isSharingCameraValue( @@ -764,6 +768,9 @@ void Panel::refreshVideoButtons(std::optional overrideWideMode) { StickedTooltipHide::Activated); } _video->setProgress(sharing ? 1. : 0.); + _video->setAccessibleName(sharing + ? tr::lng_call_stop_video(tr::now) + : tr::lng_call_start_video(tr::now)); }, _video->lifetime()); } if (!_screenShare) { @@ -772,17 +779,22 @@ void Panel::refreshVideoButtons(std::optional overrideWideMode) { _screenShare->setClickedCallback([=] { chooseShareScreenSource(); }); + _screenShare->setAccessibleName(tr::lng_group_call_screen_share_start(tr::now)); _screenShare->setColorOverrides( toggleableOverrides(_call->isSharingScreenValue())); _call->isSharingScreenValue( ) | rpl::on_next([=](bool sharing) { _screenShare->setProgress(sharing ? 1. : 0.); + _screenShare->setAccessibleName(sharing + ? tr::lng_group_call_screen_share_stop(tr::now) + : tr::lng_group_call_screen_share_start(tr::now)); }, _screenShare->lifetime()); } if (!_wideMenu) { _wideMenu.create(widget(), st::groupCallMenuToggleSmall); _wideMenu->show(); _wideMenu->setClickedCallback([=] { showMainMenu(); }); + _wideMenu->setAccessibleName(tr::lng_sr_group_call_menu(tr::now)); _wideMenu->setColorOverrides( toggleableOverrides(_wideMenuShown.value())); } @@ -799,6 +811,7 @@ void Panel::createMessageButton() { &st::groupCallMessageActiveSmall); _message->show(); _message->setClickedCallback([=] { toggleMessageTyping(); }); + _message->setAccessibleName(tr::lng_group_call_message(tr::now)); _message->setColorOverrides( toggleableOverrides(_messageTyping.value())); } @@ -880,8 +893,7 @@ void Panel::setupRealMuteButtonState(not_null real) { const auto wide = (mode == PanelMode::Wide); using Type = Ui::CallMuteButtonType; using ExpandType = Ui::CallMuteButtonExpandType; - _mute->setState(Ui::CallMuteButtonState{ - .text = (wide + const auto text = (wide ? QString() : scheduleDate ? (canManage @@ -897,7 +909,10 @@ void Panel::setupRealMuteButtonState(not_null real) { ? tr::lng_group_call_raised_hand(tr::now) : mute == MuteState::Muted ? tr::lng_group_call_unmute(tr::now) - : tr::lng_group_call_you_are_live(tr::now)), + : tr::lng_group_call_you_are_live(tr::now)); + _mute->outer()->setAccessibleName(text); + _mute->setState(Ui::CallMuteButtonState{ + .text = text, .tooltip = ((!scheduleDate && mute == MuteState::Muted) ? tr::lng_group_call_unmute_sub(tr::now) : QString()), @@ -1480,6 +1495,7 @@ void Panel::refreshTopButton() { if (!_menuToggle) { _menuToggle.create(widget(), st::groupCallMenuToggle); _menuToggle->show(); + _menuToggle->setAccessibleName(tr::lng_sr_group_call_menu(tr::now)); _menuToggle->setClickedCallback([=] { showMainMenu(); }); updateControlsGeometry(); raiseControls(); diff --git a/Telegram/SourceFiles/calls/group/calls_group_settings.cpp b/Telegram/SourceFiles/calls/group/calls_group_settings.cpp index 6459b163b7..7f629582bf 100644 --- a/Telegram/SourceFiles/calls/group/calls_group_settings.cpp +++ b/Telegram/SourceFiles/calls/group/calls_group_settings.cpp @@ -198,7 +198,7 @@ object_ptr ShareInviteLinkBox( comment.text = link; } auto &api = peer->session().api(); - for (const auto thread : result) { + for (const auto &thread : result) { auto message = Api::MessageToSend( Api::SendAction(thread, options)); message.textWithTags = comment; diff --git a/Telegram/SourceFiles/chat_helpers/bot_keyboard.cpp b/Telegram/SourceFiles/chat_helpers/bot_keyboard.cpp index 23f20a55fd..808eec46e5 100644 --- a/Telegram/SourceFiles/chat_helpers/bot_keyboard.cpp +++ b/Telegram/SourceFiles/chat_helpers/bot_keyboard.cpp @@ -112,10 +112,10 @@ void Style::paintButtonBg( auto hq = PainterHighQualityEnabler(p); p.setPen(Qt::NoPen); p.setBrush((color == Color::Primary) - ? QColor(0x29, 0x8a, 0xcf) + ? st::botKbPrimaryBg->c : (color == Color::Danger) - ? QColor(0xe0, 0x53, 0x56) - : QColor(0x61, 0xc7, 0x52)); + ? st::botKbDangerBg->c + : st::botKbSuccessBg->c); const auto radius = st::roundRadiusSmall; p.drawRoundedRect(rect, radius, radius); } diff --git a/Telegram/SourceFiles/chat_helpers/chat_helpers.style b/Telegram/SourceFiles/chat_helpers/chat_helpers.style index bdd5b0aa46..e7b781da44 100644 --- a/Telegram/SourceFiles/chat_helpers/chat_helpers.style +++ b/Telegram/SourceFiles/chat_helpers/chat_helpers.style @@ -1423,6 +1423,7 @@ defaultComposeFilesMenu: IconButton(defaultIconButton) { defaultComposeFilesField: InputField(defaultInputField) { textMargins: margins(1px, 26px, 31px, 4px); heightMax: 158px; + style: historyTextStyle; } defaultComposeFiles: ComposeFiles { check: defaultCheck; diff --git a/Telegram/SourceFiles/chat_helpers/compose/compose_show.cpp b/Telegram/SourceFiles/chat_helpers/compose/compose_show.cpp index 192937b6e4..32a2eff1f8 100644 --- a/Telegram/SourceFiles/chat_helpers/compose/compose_show.cpp +++ b/Telegram/SourceFiles/chat_helpers/compose/compose_show.cpp @@ -22,12 +22,12 @@ ResolveWindow ResolveWindowDefault() { return [](not_null session) -> Window::SessionController* { const auto check = [&](Window::Controller *window) { - if (const auto controller = window->sessionController()) { - if (&controller->session() == session) { - return controller; - } - } - return (Window::SessionController*)nullptr; + const auto controller = window + ? window->sessionController() + : nullptr; + return (controller && (&controller->session() == session)) + ? controller + : nullptr; }; auto &app = Core::App(); const auto account = not_null(&session->account()); diff --git a/Telegram/SourceFiles/chat_helpers/field_autocomplete.cpp b/Telegram/SourceFiles/chat_helpers/field_autocomplete.cpp index 4f30f5f7c2..07a019a8b0 100644 --- a/Telegram/SourceFiles/chat_helpers/field_autocomplete.cpp +++ b/Telegram/SourceFiles/chat_helpers/field_autocomplete.cpp @@ -43,7 +43,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/cached_round_corners.h" #include "base/unixtime.h" #include "base/random.h" -#include "base/qt/qt_common_adapters.h" +#include "base/qt/qt_key_modifiers.h" #include "boxes/sticker_set_box.h" #include "window/window_adaptive.h" #include "window/window_session_controller.h" @@ -507,7 +507,7 @@ void FieldAutocomplete::updateFiltered(bool resetScroll) { sorted.emplace(byOnline(user), user); } } - for (const auto user : _chat->lastAuthors) { + for (const auto &user : _chat->lastAuthors) { if (user->isInaccessible()) continue; if (!listAllSuggestions && filterNotPassedByName(user)) continue; if (indexOfInFirstN(mrows, user, recentInlineBots) >= 0) continue; @@ -524,7 +524,7 @@ void FieldAutocomplete::updateFiltered(bool resetScroll) { _channel->session().api().chatParticipants().requestAdmins(_channel); } else { mrows.reserve(mrows.size() + _channel->mgInfo->admins.size()); - for (const auto &[userId, rank] : _channel->mgInfo->admins) { + for (const auto &userId : _channel->mgInfo->admins) { if (const auto user = _channel->owner().userLoaded(userId)) { if (user->isInaccessible()) continue; if (!listAllSuggestions && filterNotPassedByName(user)) continue; @@ -538,7 +538,7 @@ void FieldAutocomplete::updateFiltered(bool resetScroll) { _channel); } else { mrows.reserve(mrows.size() + _channel->mgInfo->lastParticipants.size()); - for (const auto user : _channel->mgInfo->lastParticipants) { + for (const auto &user : _channel->mgInfo->lastParticipants) { if (user->isInaccessible()) continue; if (!listAllSuggestions && filterNotPassedByName(user)) continue; if (indexOfInFirstN(mrows, user, recentInlineBots) >= 0) continue; @@ -1674,7 +1674,9 @@ void InitFieldAutocomplete( raw->mentionChosen( ) | rpl::on_next([=](FieldAutocomplete::MentionChosen data) { const auto user = data.user; - if (data.mention.isEmpty()) { + const auto ctrlClick = base::IsCtrlPressed() + && data.method == FieldAutocomplete::ChooseMethod::ByClick; + if (data.mention.isEmpty() || ctrlClick) { field->insertTag( user->firstName.isEmpty() ? user->name() : user->firstName, PrepareMentionTag(user)); diff --git a/Telegram/SourceFiles/chat_helpers/message_field.cpp b/Telegram/SourceFiles/chat_helpers/message_field.cpp index 91b37bb1e7..dc73b7e753 100644 --- a/Telegram/SourceFiles/chat_helpers/message_field.cpp +++ b/Telegram/SourceFiles/chat_helpers/message_field.cpp @@ -17,6 +17,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/event_filter.h" #include "ui/chat/chat_style.h" #include "ui/layers/generic_box.h" +#include "ui/boxes/calendar_box.h" +#include "ui/boxes/choose_date_time.h" #include "ui/basic_click_handlers.h" #include "ui/rect.h" #include "core/shortcuts.h" @@ -410,8 +412,68 @@ FncommitMarkdownLinkEdit(selection, t, l); + } + }; + const auto savedCallback = std::make_shared< + Fn>( + std::move(callback)); + const auto savedText = std::make_shared(text); + const auto showDateTimeBox = [=](TimeId time) { + const auto dateBox = std::make_shared< + base::weak_qptr>(); + *dateBox = show->show(Box( + Ui::ChooseDateTimeBox, + Ui::ChooseDateTimeBoxArgs{ + .title = tr::lng_formatting_date_title(), + .submit = tr::lng_settings_save(), + .done = [=](TimeId result) { + const auto dateLink + = Ui::InputField::kCustomDateTagStart + + QString::number(result); + (*savedCallback)( + *savedText, + dateLink); + if (const auto box = dateBox->get()) { + box->closeBox(); + } + }, + .min = [] { return TimeId(1); }, + .time = time, + .max = [] { return TimeId(2114380800); }, + })); + }; + if (existingDate > 0) { + showDateTimeBox(existingDate); + } else { + show->show(Box(Ui::CalendarBoxArgs{ + .month = QDate::currentDate(), + .highlighted = QDate::currentDate(), + .callback = [=](QDate chosen, Fn close) { + close(); + const auto midday = QDateTime( + chosen, + QTime(12, 0)); + showDateTimeBox( + base::unixtime::serialize(midday)); + }, + .minDate = QDate(1970, 1, 1), + .maxDate = QDate(2036, 12, 31), + })); + } + return true; } auto callback = [=](const TextWithTags &text, const QString &link) { if (const auto strong = weak.get()) { @@ -1569,3 +1631,22 @@ void FrozenInfoBox( button->resizeToWidth(buttonWidth); }, button->lifetime()); } + +Ui::InputField::MimeDataHook WrappedMessageFieldMimeHook( + Ui::InputField::MimeDataHook original, + not_null field) { + return [field, originalHook = std::move(original)]( + not_null data, + Ui::InputField::MimeAction action) { + if (data->hasFormat(u"application/x-telegram-input-field"_q)) { + if (action == Ui::InputField::MimeAction::Check) { + return true; + } + const auto text = QString::fromUtf8( + data->data(u"application/x-telegram-input-field"_q)); + field->textCursor().insertText(text); + return true; + } + return originalHook ? originalHook(data, action) : false; + }; +} diff --git a/Telegram/SourceFiles/chat_helpers/message_field.h b/Telegram/SourceFiles/chat_helpers/message_field.h index fc8aaea19c..f6d6474e08 100644 --- a/Telegram/SourceFiles/chat_helpers/message_field.h +++ b/Telegram/SourceFiles/chat_helpers/message_field.h @@ -219,3 +219,7 @@ void FrozenInfoBox( not_null box, not_null session, FreezeInfoStyleOverride st); + +[[nodiscard]] Ui::InputField::MimeDataHook WrappedMessageFieldMimeHook( + Ui::InputField::MimeDataHook original, + not_null field); diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp index 3a2ac630c0..6a9e2b7ff3 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.cpp @@ -7,6 +7,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "chat_helpers/stickers_list_widget.h" +#include "base/options.h" #include "base/timer_rpl.h" #include "core/application.h" #include "data/data_document.h" @@ -76,12 +77,20 @@ using Data::StickersPack; using Data::StickersSetThumbnailView; using SetFlag = Data::StickersSetFlag; +base::options::toggle OptionUnlimitedRecentStickers({ + .id = kOptionUnlimitedRecentStickers, + .name = "Unlimited recent stickers", + .description = "Display as much recent stickers as the server provides", +}); + [[nodiscard]] bool SetInMyList(Data::StickersSetFlags flags) { return (flags & SetFlag::Installed) && !(flags & SetFlag::Archived); } } // namespace +const char kOptionUnlimitedRecentStickers[] = "unlimited-recent-stickers"; + struct StickersListWidget::Sticker { not_null document; std::shared_ptr documentMedia; diff --git a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h index ed67bf32df..175cb647ec 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h +++ b/Telegram/SourceFiles/chat_helpers/stickers_list_widget.h @@ -56,6 +56,8 @@ struct FlatLabel; namespace ChatHelpers { +extern const char kOptionUnlimitedRecentStickers[]; + struct StickerIcon; enum class ValidateIconAnimations; class StickersListFooter; diff --git a/Telegram/SourceFiles/chat_helpers/stickers_lottie.cpp b/Telegram/SourceFiles/chat_helpers/stickers_lottie.cpp index 0c62f7680a..e2b9cd80e7 100644 --- a/Telegram/SourceFiles/chat_helpers/stickers_lottie.cpp +++ b/Telegram/SourceFiles/chat_helpers/stickers_lottie.cpp @@ -324,18 +324,14 @@ QSize ComputeStickerSize(not_null document, QSize box) { not_null GenerateLocalSticker( not_null session, const QString &path) { - auto task = FileLoadTask( - session, - path, - QByteArray(), - nullptr, - nullptr, - SendMediaType::File, - FileLoadTo(0, {}, {}, 0), - {}, - false, - nullptr, - LocalStickerId(path)); + auto task = FileLoadTask(FileLoadTask::Args{ + .session = session, + .filepath = path, + .type = SendMediaType::File, + .to = FileLoadTo(0, {}, {}, 0), + .caption = {}, + .idOverride = LocalStickerId(path), + }); task.process({ .generateGoodThumbnail = false }); const auto result = task.peekResult(); Assert(result != nullptr); diff --git a/Telegram/SourceFiles/core/application.cpp b/Telegram/SourceFiles/core/application.cpp index e16041a988..9c1d9cb56a 100644 --- a/Telegram/SourceFiles/core/application.cpp +++ b/Telegram/SourceFiles/core/application.cpp @@ -1104,7 +1104,8 @@ void Application::checkStartUrls() { iv().showTonSite(url.toString(), {}); return false; } else if (_lastActivePrimaryWindow) { - return !openLocalUrl(url.toString(), {}); + const auto local = TryConvertUrlToLocal(url.toString()); + return !openLocalUrl(local, {}); } return true; }) | ranges::to>; diff --git a/Telegram/SourceFiles/core/click_handler_types.cpp b/Telegram/SourceFiles/core/click_handler_types.cpp index 824f7816b2..d7db236cef 100644 --- a/Telegram/SourceFiles/core/click_handler_types.cpp +++ b/Telegram/SourceFiles/core/click_handler_types.cpp @@ -7,17 +7,21 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "core/click_handler_types.h" +#include "base/unixtime.h" #include "lang/lang_keys.h" #include "chat_helpers/bot_command.h" #include "core/application.h" #include "core/local_url_handlers.h" +#include "core/file_utilities.h" #include "mainwidget.h" #include "main/main_session.h" #include "ui/boxes/confirm_box.h" #include "ui/toast/toast.h" +#include "ui/toast/toast_lottie_icon.h" #include "ui/widgets/popup_menu.h" #include "base/qthelp_regex.h" #include "base/qt/qt_key_modifiers.h" +#include "base/random.h" #include "storage/storage_account.h" #include "history/history.h" #include "history/view/history_view_element.h" @@ -29,11 +33,23 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "window/window_controller.h" #include "window/window_session_controller.h" #include "window/window_session_controller_link_info.h" +#include "apiwrap.h" +#include "history/view/history_view_schedule_box.h" +#include "history/view/history_view_scheduled_section.h" +#include "menu/menu_send.h" +#include "data/data_types.h" #include "styles/style_calls.h" // groupCallBoxLabel +#include "styles/style_chat_helpers.h" #include "styles/style_layers.h" +#include "styles/style_menu_icons.h" + +#include +#include namespace { +constexpr auto kReminderSetToastDuration = 4 * crl::time(1000); + [[nodiscard]] TextWithEntities BoldDomainInUrl(const QString &url) { auto result = TextWithEntities{ .text = url }; @@ -93,6 +109,98 @@ void SearchByHashtag(ClickContext context, const QString &tag) { : Dialogs::Key()); } +void ExportToCalendar(TimeId date, const QString &messageText) { + const auto start = QDateTime::fromSecsSinceEpoch( + date, + Qt::UTC); + const auto end = QDateTime::fromSecsSinceEpoch( + date + 3600, + Qt::UTC); + const auto now = QDateTime::currentDateTimeUtc(); + const auto format = u"yyyyMMdd'T'HHmmss'Z'"_q; + const auto locale = QLocale(); + const auto raw = locale.toString( + base::unixtime::parse(date), + QLocale::LongFormat); + auto summary = raw; + summary.replace('\\', u"\\\\"_q); + summary.replace(';', u"\\;"_q); + summary.replace(',', u"\\,"_q); + summary.replace('\n', u"\\n"_q); + auto description = messageText; + description.replace('\\', u"\\\\"_q); + description.replace(';', u"\\;"_q); + description.replace(',', u"\\,"_q); + description.replace('\n', u"\\n"_q); + const auto uid = base::RandomValue(); + const auto content = u"BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Telegram Desktop//EN\r\n" + "BEGIN:VEVENT\r\n" + "DTSTART:%1\r\n" + "DTEND:%2\r\n" + "DTSTAMP:%3\r\n" + "UID:telegram-%4-%7@telegram.org\r\n" + "SUMMARY:%5\r\n" + "DESCRIPTION:%6\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n"_q + .arg(start.toString(format)) + .arg(end.toString(format)) + .arg(now.toString(format)) + .arg(date) + .arg(summary) + .arg(description) + .arg(uid, 0, 16); + const auto dir = cWorkingDir() + u"tdata/temp"_q; + QDir().mkpath(dir); + const auto path = u"%1/event_%2.ics"_q + .arg(dir) + .arg(date); + auto file = QFile(path); + if (file.open(QIODevice::WriteOnly)) { + file.write(content.toUtf8()); + file.close(); + File::Launch(path); + } +} + +void DoneSetReminder(std::shared_ptr show) { + if (!show->valid()) { + return; + } + const auto text = tr::lng_reminder_scheduled_in( + tr::now, + lt_link, + tr::link(tr::bold(tr::lng_saved_messages(tr::now))), + tr::marked); + const auto session = &show->session(); + const auto filter = [=]( + const ClickHandlerPtr &, + Qt::MouseButton) { + if (const auto controller = show->resolveWindow()) { + controller->showSection( + std::make_shared( + session->data().history(session->user()))); + } + return false; + }; + const auto toast = show->showToast({ + .text = text, + .filter = filter, + .st = &st::selfForwardsTaggerToast, + .attach = RectPart::Top, + .duration = kReminderSetToastDuration, + }); + if (const auto strong = toast.get()) { + Ui::AddLottieToToast( + strong->widget(), + st::selfForwardsTaggerToast, + st::selfForwardsTaggerIcon, + u"toast/saved_messages"_q); + } +}; + } // namespace bool UrlRequiresConfirmation(const QUrl &url) { @@ -134,7 +242,7 @@ QString HiddenUrlClickHandler::dragText() const { void HiddenUrlClickHandler::Open(QString url, QVariant context) { url = Core::TryConvertUrlToLocal(url); - if (Core::InternalPassportLink(url)) { + if (Core::InternalPassportOrOAuthLink(url)) { return; } @@ -231,7 +339,7 @@ void HiddenUrlClickHandler::Open(QString url, QVariant context) { void BotGameUrlClickHandler::onClick(ClickContext context) const { const auto url = Core::TryConvertUrlToLocal(this->url()); - if (Core::InternalPassportLink(url)) { + if (Core::InternalPassportOrOAuthLink(url)) { return; } const auto openLink = [=] { @@ -438,3 +546,93 @@ auto MonospaceClickHandler::getTextEntity() const -> TextEntity { QString MonospaceClickHandler::url() const { return _text; } + +FormattedDateClickHandler::FormattedDateClickHandler( + int32 date, + FormattedDateFlags flags) +: _date(date) +, _entityData(SerializeFormattedDateData(date, flags)) { +} + +void FormattedDateClickHandler::onClick(ClickContext context) const { + if (context.button != Qt::LeftButton) { + return; + } + const auto my = context.other.value(); + const auto controller = my.sessionWindow.get(); + if (!controller) { + return; + } + const auto menu = Ui::CreateChild( + controller->content(), + st::popupMenuWithIcons); + + const auto date = _date; + const auto show = controller->uiShow(); + + menu->addAction( + tr::lng_context_copy_date(tr::now), + [date, show] { + const auto text = QLocale().toString( + base::unixtime::parse(date), + QLocale::LongFormat); + TextUtilities::SetClipboardText(TextForMimeData::Simple(text)); + show->showToast(tr::lng_date_copied(tr::now)); + }, + &st::menuIconCopy); + + const auto itemId = my.itemId; + const auto &owner = controller->session().data(); + const auto item = owner.message(itemId); + + const auto messageText = item ? item->originalText().text : QString(); + menu->addAction( + tr::lng_context_add_to_calendar(tr::now), + [date, messageText] { ExportToCalendar(date, messageText); }, + &st::menuIconSchedule); + + const auto canForward = item + && !item->forbidsForward() + && item->history()->peer->allowsForwarding(); + if (canForward) { + menu->addAction( + tr::lng_context_set_reminder(tr::now), + [itemId, show] { + const auto session = &show->session(); + const auto item = session->data().message(itemId); + if (!item) { + return; + } + const auto self = session->user(); + const auto history = self->owner().history(self); + show->showBox(HistoryView::PrepareScheduleBox( + session, + show, + SendMenu::Details{ .type = SendMenu::Type::Reminder }, + [=](Api::SendOptions options) { + auto action = Api::SendAction(history, options); + action.clearDraft = false; + action.generateLocal = false; + session->api().forwardMessages( + Data::ResolvedForwardDraft{ + .items = { item }, + }, + action, + [=] { DoneSetReminder(show); }); + })); + }, + &st::menuIconNotifications); + } + + menu->popup(QCursor::pos()); +} + +auto FormattedDateClickHandler::getTextEntity() const -> TextEntity { + return { EntityType::FormattedDate, _entityData }; +} + +QString FormattedDateClickHandler::tooltip() const { + return QLocale().toString( + base::unixtime::parse(_date), + QLocale::LongFormat); +} diff --git a/Telegram/SourceFiles/core/click_handler_types.h b/Telegram/SourceFiles/core/click_handler_types.h index a1cd3bd35b..0aebaf8416 100644 --- a/Telegram/SourceFiles/core/click_handler_types.h +++ b/Telegram/SourceFiles/core/click_handler_types.h @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "ui/basic_click_handlers.h" +#include "ui/text/text_entity.h" #include "data/data_msg_id.h" constexpr auto kPeerLinkPeerIdProperty = 0x01; @@ -237,3 +238,17 @@ private: const TextEntity _entity; }; + +class FormattedDateClickHandler : public ClickHandler { +public: + FormattedDateClickHandler(TimeId date, FormattedDateFlags flags); + + void onClick(ClickContext context) const override; + TextEntity getTextEntity() const override; + QString tooltip() const override; + +private: + TimeId _date = 0; + QString _entityData; + +}; diff --git a/Telegram/SourceFiles/core/core_settings.cpp b/Telegram/SourceFiles/core/core_settings.cpp index 01ef35c0af..b2d85ff5c7 100644 --- a/Telegram/SourceFiles/core/core_settings.cpp +++ b/Telegram/SourceFiles/core/core_settings.cpp @@ -249,7 +249,8 @@ QByteArray Settings::serialize() const { + sizeof(qint32) * 8 + sizeof(ushort) + sizeof(qint32) // _notificationsDisplayChecksum - + Serialize::bytearraySize(callPanelPosition); + + Serialize::bytearraySize(callPanelPosition) + + sizeof(qint32); auto result = QByteArray(); result.reserve(size); @@ -414,7 +415,8 @@ QByteArray Settings::serialize() const { << qint32(_quickDialogAction) << _notificationsVolume << _notificationsDisplayChecksum - << callPanelPosition; + << callPanelPosition + << qint32(_cornerReply.current() ? 1 : 0); } Ensures(result.size() == size); @@ -519,6 +521,7 @@ void Settings::addFromSerialized(const QByteArray &serialized) { qint32 hardwareAcceleratedVideo = _hardwareAcceleratedVideo ? 1 : 0; qint32 chatQuickAction = static_cast(_chatQuickAction); qint32 suggestAnimatedEmoji = _suggestAnimatedEmoji ? 1 : 0; + qint32 cornerReply = _cornerReply.current() ? 1 : 0; qint32 cornerReaction = _cornerReaction.current() ? 1 : 0; qint32 legacySkipTranslationForLanguage = _translateButtonEnabled ? 1 : 0; qint32 skipTranslationLanguagesCount = 0; @@ -889,6 +892,9 @@ void Settings::addFromSerialized(const QByteArray &serialized) { if (!stream.atEnd()) { stream >> callPanelPosition; } + if (!stream.atEnd()) { + stream >> cornerReply; + } if (stream.status() != QDataStream::Ok) { LOG(("App Error: " "Bad data for Core::Settings::constructFromSerialized()")); @@ -1064,6 +1070,7 @@ void Settings::addFromSerialized(const QByteArray &serialized) { } } _suggestAnimatedEmoji = (suggestAnimatedEmoji == 1); + _cornerReply = (cornerReply == 1); _cornerReaction = (cornerReaction == 1); { // Parse the legacy translation setting. if (legacySkipTranslationForLanguage == 0) { diff --git a/Telegram/SourceFiles/core/core_settings.h b/Telegram/SourceFiles/core/core_settings.h index b1200810ea..c9e033d825 100644 --- a/Telegram/SourceFiles/core/core_settings.h +++ b/Telegram/SourceFiles/core/core_settings.h @@ -499,8 +499,14 @@ public: [[nodiscard]] rpl::producer cornerReactionValue() const { return _cornerReaction.value(); } - [[nodiscard]] rpl::producer cornerReactionChanges() const { - return _cornerReaction.changes(); + void setCornerReply(bool value) { + _cornerReply = value; + } + [[nodiscard]] bool cornerReply() const { + return _cornerReply.current(); + } + [[nodiscard]] rpl::producer cornerReplyValue() const { + return _cornerReply.value(); } void setSpellcheckerEnabled(bool value) { @@ -1040,6 +1046,7 @@ private: bool _suggestEmoji = true; bool _suggestStickersByEmoji = true; bool _suggestAnimatedEmoji = true; + rpl::variable _cornerReply = true; rpl::variable _cornerReaction = true; rpl::variable _spellcheckerEnabled = true; PlaybackSpeed _videoPlaybackSpeed; diff --git a/Telegram/SourceFiles/core/local_url_handlers.cpp b/Telegram/SourceFiles/core/local_url_handlers.cpp index 2942d705ae..ff1abf836d 100644 --- a/Telegram/SourceFiles/core/local_url_handlers.cpp +++ b/Telegram/SourceFiles/core/local_url_handlers.cpp @@ -1985,7 +1985,7 @@ QString TryConvertUrlToLocal(QString url) { return url; } -bool InternalPassportLink(const QString &url) { +bool InternalPassportOrOAuthLink(const QString &url) { const auto urlTrimmed = url.trimmed(); if (!urlTrimmed.startsWith(u"tg://"_q, Qt::CaseInsensitive)) { return false; @@ -1998,23 +1998,33 @@ bool InternalPassportLink(const QString &url) { u"^passport/?\\?(.+)(#|$)"_q, command, matchOptions); + const auto oauthMatch = regex_match( + u"^oauth/?\\?(.+)(#|$)"_q, + command, + matchOptions); const auto usernameMatch = regex_match( u"^resolve/?\\?(.+)(#|$)"_q, command, matchOptions); - const auto usernameValue = usernameMatch->hasMatch() - ? url_parse_params( + auto usernameValue = QString(); + if (usernameMatch->hasMatch()) { + const auto params = url_parse_params( usernameMatch->captured(1), - UrlParamNameTransform::ToLower).value(u"domain"_q) - : QString(); + UrlParamNameTransform::ToLower); + usernameValue = params.value(u"domain"_q); + } const auto authLegacy = (usernameValue == u"telegrampassport"_q); - return authMatch->hasMatch() || authLegacy; + const auto oauthLegacy = (usernameValue == u"oauth"_q); + return authMatch->hasMatch() + || oauthMatch->hasMatch() + || authLegacy + || oauthLegacy; } bool StartUrlRequiresActivate(const QString &url) { return Core::App().passcodeLocked() ? true - : !InternalPassportLink(url); + : !InternalPassportOrOAuthLink(url); } void ResolveAndShowUniqueGift( @@ -2062,7 +2072,9 @@ void ResolveAndShowUniqueGift( } }).fail([=](const MTP::Error &error) { clear(); - show->showToast(u"Error: "_q + error.type()); + if (!Ui::ShowGiftErrorToast(show, error)) { + show->showToast(u"Error: "_q + error.type()); + } }).send(); } diff --git a/Telegram/SourceFiles/core/local_url_handlers.h b/Telegram/SourceFiles/core/local_url_handlers.h index bcdf1de241..af9c2a4bf8 100644 --- a/Telegram/SourceFiles/core/local_url_handlers.h +++ b/Telegram/SourceFiles/core/local_url_handlers.h @@ -42,7 +42,7 @@ struct LocalUrlHandler { [[nodiscard]] QString TryConvertUrlToLocal(QString url); -[[nodiscard]] bool InternalPassportLink(const QString &url); +[[nodiscard]] bool InternalPassportOrOAuthLink(const QString &url); [[nodiscard]] bool StartUrlRequiresActivate(const QString &url); diff --git a/Telegram/SourceFiles/core/ui_integration.cpp b/Telegram/SourceFiles/core/ui_integration.cpp index 1cd03ad86b..de24b855a5 100644 --- a/Telegram/SourceFiles/core/ui_integration.cpp +++ b/Telegram/SourceFiles/core/ui_integration.cpp @@ -32,6 +32,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "window/window_controller.h" #include "window/window_session_controller.h" #include "mainwindow.h" +#include "base/unixtime.h" + +#include +#include // AyuGram includes #include "ayu/ayu_url_handlers.h" @@ -118,6 +122,117 @@ const auto kBadPrefix = u"http://"_q; return cWorkingDir() + "tdata/angle_backend"; } +[[nodiscard]] Ui::Text::FormattedDateResult FormatDateRelative(TimeId date) { + const auto now = base::unixtime::now(); + const auto delta = int64(date) - int64(now); + const auto absDelta = std::abs(delta); + const auto future = (delta > 0); + auto text = QString(); + auto nextUpdate = int32(0); + + if (absDelta < 1) { + text = tr::lng_date_relative_now(tr::now); + nextUpdate = now + 1; + } else if (absDelta < 60) { + const auto count = int(absDelta); + text = (future + ? tr::lng_date_relative_in_seconds + : tr::lng_date_relative_seconds_ago)(tr::now, lt_count, count); + nextUpdate = now + 1; + } else if (absDelta < 3600) { + const auto count = int(absDelta / 60); + text = (future + ? tr::lng_date_relative_in_minutes + : tr::lng_date_relative_minutes_ago)(tr::now, lt_count, count); + nextUpdate = future + ? ((count > 1) + ? (date - (count - 1) * 60) + : (date - 59)) + : (date + (count + 1) * 60); + } else if (absDelta < 86400) { + const auto count = int(absDelta / 3600); + text = (future + ? tr::lng_date_relative_in_hours + : tr::lng_date_relative_hours_ago)(tr::now, lt_count, count); + nextUpdate = future + ? ((count > 1) + ? (date - (count - 1) * 3600) + : (date - 3599)) + : (date + (count + 1) * 3600); + } else if (absDelta < 30 * 86400) { + const auto count = int(absDelta / 86400); + text = (future + ? tr::lng_date_relative_in_days + : tr::lng_date_relative_days_ago)(tr::now, lt_count, count); + nextUpdate = future + ? ((count > 1) + ? (date - (count - 1) * 86400) + : (date - 86399)) + : (date + (count + 1) * 86400); + } else if (absDelta < 365 * 86400) { + const auto count = int(absDelta / (30 * 86400)); + text = (future + ? tr::lng_date_relative_in_months + : tr::lng_date_relative_months_ago)(tr::now, lt_count, count); + nextUpdate = future + ? ((count > 1) + ? (date - (count - 1) * 30 * 86400) + : (date - 30 * 86400 + 1)) + : (date + (count + 1) * 30 * 86400); + } else { + const auto count = int(absDelta / (365 * 86400)); + text = (future + ? tr::lng_date_relative_in_years + : tr::lng_date_relative_years_ago)(tr::now, lt_count, count); + nextUpdate = future + ? ((count > 1) + ? (date - (count - 1) * 365 * 86400) + : (date - 365 * 86400 + 1)) + : (date + (count + 1) * 365 * 86400); + } + return { text, nextUpdate }; +} + +[[nodiscard]] Ui::Text::FormattedDateResult FormatDateWithFlags( + TimeId date, + FormattedDateFlags flags) { + if (flags & FormattedDateFlag::Relative) { + return FormatDateRelative(date); + } + const auto dateTime = base::unixtime::parse(date); + const auto locale = QLocale(); + auto parts = QStringList(); + const auto hasDayOfWeek = (flags & FormattedDateFlag::DayOfWeek); + const auto hasShortDate = (flags & FormattedDateFlag::ShortDate); + const auto hasLongDate = (flags & FormattedDateFlag::LongDate); + const auto hasShortTime = (flags & FormattedDateFlag::ShortTime); + const auto hasLongTime = (flags & FormattedDateFlag::LongTime); + if (hasDayOfWeek) { + parts.push_back(hasLongDate + ? langDayOfWeekFull(dateTime.date()) + : langDayOfWeek(dateTime.date())); + } + if (hasLongDate) { + parts.push_back(langDayOfMonthFull(dateTime.date())); + } else if (hasShortDate) { + parts.push_back(langDayOfMonth(dateTime.date())); + } + if (hasLongTime) { + parts.push_back(locale.toString( + dateTime.time(), + QLocale::LongFormat)); + } else if (hasShortTime) { + parts.push_back(locale.toString( + dateTime.time(), + QLocale::ShortFormat)); + } + auto text = parts.join(u" "_q); + if (text.isEmpty()) { + text = locale.toString(dateTime, QLocale::ShortFormat); + } + return { text, 0 }; +} + } // namespace Ui::Text::MarkedContext TextContext(TextContextArgs &&args) { @@ -150,6 +265,7 @@ Ui::Text::MarkedContext TextContext(TextContextArgs &&args) { return { .repaint = std::move(args.repaint), .customEmojiFactory = std::move(factory), + .formattedDateFactory = FormatDateWithFlags, .other = std::move(args.details), }; } @@ -271,6 +387,12 @@ std::shared_ptr UiIntegration::createLinkHandler( return (my && my->session) ? std::make_shared(my->session, data.text) : nullptr; + case EntityType::FormattedDate: { + const auto [date, flags] = DeserializeFormattedDateData(data.data); + if (date) { + return std::make_shared(date, flags); + } + } break; } return Integration::createLinkHandler(data, context); } @@ -279,7 +401,7 @@ bool UiIntegration::handleUrlClick( const QString &url, const QVariant &context) { const auto local = Core::TryConvertUrlToLocal(url); - if (Core::InternalPassportLink(local)) { + if (Core::InternalPassportOrOAuthLink(local)) { return true; } @@ -402,6 +524,10 @@ QString UiIntegration::phraseFormattingSpoiler() { return tr::lng_menu_formatting_spoiler(tr::now); } +QString UiIntegration::phraseFormattingDate() { + return tr::lng_menu_formatting_date(tr::now); +} + QString UiIntegration::phraseButtonOk() { return tr::lng_box_ok(tr::now); } diff --git a/Telegram/SourceFiles/core/ui_integration.h b/Telegram/SourceFiles/core/ui_integration.h index eae9cca342..dfc923292f 100644 --- a/Telegram/SourceFiles/core/ui_integration.h +++ b/Telegram/SourceFiles/core/ui_integration.h @@ -79,6 +79,7 @@ public: QString phraseFormattingBlockquote() override; QString phraseFormattingMonospace() override; QString phraseFormattingSpoiler() override; + QString phraseFormattingDate() override; QString phraseButtonOk() override; QString phraseButtonClose() override; QString phraseButtonCancel() override; diff --git a/Telegram/SourceFiles/core/version.h b/Telegram/SourceFiles/core/version.h index c67e7584d3..71f5070bd7 100644 --- a/Telegram/SourceFiles/core/version.h +++ b/Telegram/SourceFiles/core/version.h @@ -22,7 +22,7 @@ constexpr auto AppId = "{53F49750-6209-4FBF-9CA8-7A333C87D666}"_cs; constexpr auto AppNameOld = "AyuGram for Windows"_cs; constexpr auto AppName = "AyuGram Desktop"_cs; constexpr auto AppFile = "AyuGram"_cs; -constexpr auto AppVersion = 6005001; -constexpr auto AppVersionStr = "6.5.1"; +constexpr auto AppVersion = 6006002; +constexpr auto AppVersionStr = "6.6.2"; constexpr auto AppBetaVersion = false; constexpr auto AppAlphaVersion = TDESKTOP_ALPHA_VERSION; diff --git a/Telegram/SourceFiles/data/business/data_shortcut_messages.cpp b/Telegram/SourceFiles/data/business/data_shortcut_messages.cpp index 259f38cb96..13c74ef7a1 100644 --- a/Telegram/SourceFiles/data/business/data_shortcut_messages.cpp +++ b/Telegram/SourceFiles/data/business/data_shortcut_messages.cpp @@ -67,6 +67,7 @@ constexpr auto kRequestTimeLimit = 60 * crl::time(1000); data.vid(), data.vfrom_id() ? *data.vfrom_id() : MTPPeer(), MTPint(), // from_boosts_applied + MTPstring(), // from_rank data.vpeer_id(), data.vsaved_peer_id() ? *data.vsaved_peer_id() : MTPPeer(), data.vfwd_from() ? *data.vfwd_from() : MTPMessageFwdHeader(), diff --git a/Telegram/SourceFiles/data/components/recent_shared_media_gifts.cpp b/Telegram/SourceFiles/data/components/recent_shared_media_gifts.cpp index 7b321edb38..2cafa5dace 100644 --- a/Telegram/SourceFiles/data/components/recent_shared_media_gifts.cpp +++ b/Telegram/SourceFiles/data/components/recent_shared_media_gifts.cpp @@ -10,6 +10,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "api/api_credits.h" // InputSavedStarGiftId #include "api/api_premium.h" #include "apiwrap.h" +#include "boxes/star_gift_box.h" #include "chat_helpers/compose/compose_show.h" #include "data/data_document.h" #include "data/data_peer.h" @@ -145,7 +146,9 @@ void RecentSharedMediaGifts::updatePinnedOrder( done(); } }).fail([=](const MTP::Error &error) { - show->showToast(error.type()); + if (!Ui::ShowGiftErrorToast(show, error)) { + show->showToast(error.type()); + } }).send(); } diff --git a/Telegram/SourceFiles/data/components/scheduled_messages.cpp b/Telegram/SourceFiles/data/components/scheduled_messages.cpp index ba5059df46..9fc3b15026 100644 --- a/Telegram/SourceFiles/data/components/scheduled_messages.cpp +++ b/Telegram/SourceFiles/data/components/scheduled_messages.cpp @@ -71,6 +71,7 @@ constexpr auto kRequestTimeLimit = 60 * crl::time(1000); data.vid(), data.vfrom_id() ? *data.vfrom_id() : MTPPeer(), MTPint(), // from_boosts_applied + MTPstring(), // from_rank data.vpeer_id(), data.vsaved_peer_id() ? *data.vsaved_peer_id() : MTPPeer(), data.vfwd_from() ? *data.vfwd_from() : MTPMessageFwdHeader(), @@ -251,6 +252,7 @@ void ScheduledMessages::sendNowSimpleMessage( update.vid(), peerToMTP(local->from()->id), MTPint(), // from_boosts_applied + MTPstring(), // from_rank peerToMTP(history->peer->id), MTPPeer(), // saved_peer_id MTPMessageFwdHeader(), diff --git a/Telegram/SourceFiles/data/data_birthday.cpp b/Telegram/SourceFiles/data/data_birthday.cpp index 34a2b07374..f41e251bff 100644 --- a/Telegram/SourceFiles/data/data_birthday.cpp +++ b/Telegram/SourceFiles/data/data_birthday.cpp @@ -69,12 +69,15 @@ int Birthday::year() const { return _value / 10000; } -QString BirthdayText(Birthday date) { +QString BirthdayText(Birthday date, bool fullMonth) { + const auto wrapMonth = fullMonth + ? Lang::MonthDay + : Lang::MonthSmall; if (const auto year = date.year()) { return tr::lng_month_day_year( tr::now, lt_month, - Lang::MonthSmall(date.month())(tr::now), + wrapMonth(date.month())(tr::now), lt_day, QString::number(date.day()), lt_year, @@ -83,7 +86,7 @@ QString BirthdayText(Birthday date) { return tr::lng_month_day( tr::now, lt_month, - Lang::MonthSmall(date.month())(tr::now), + wrapMonth(date.month())(tr::now), lt_day, QString::number(date.day())); } diff --git a/Telegram/SourceFiles/data/data_birthday.h b/Telegram/SourceFiles/data/data_birthday.h index e728cce420..15858b6e01 100644 --- a/Telegram/SourceFiles/data/data_birthday.h +++ b/Telegram/SourceFiles/data/data_birthday.h @@ -38,7 +38,7 @@ private: }; -[[nodiscard]] QString BirthdayText(Birthday date); +[[nodiscard]] QString BirthdayText(Birthday date, bool fullMonth = false); [[nodiscard]] QString BirthdayCake(); [[nodiscard]] int BirthdayAge(Birthday date); [[nodiscard]] bool IsBirthdayToday(Birthday date); diff --git a/Telegram/SourceFiles/data/data_changes.cpp b/Telegram/SourceFiles/data/data_changes.cpp index 5356d48065..8b5d73c9a8 100644 --- a/Telegram/SourceFiles/data/data_changes.cpp +++ b/Telegram/SourceFiles/data/data_changes.cpp @@ -357,6 +357,17 @@ rpl::producer Changes::chatAdminChanges() const { return _chatAdminChanges.events(); } +void Changes::chatMemberRankChanged( + not_null peer, + not_null user, + QString rank) { + _chatMemberRankChanges.fire({ peer, user, std::move(rank) }); +} + +rpl::producer Changes::chatMemberRankChanges() const { + return _chatMemberRankChanges.events(); +} + void Changes::scheduleNotifications() { if (!_notify) { _notify = true; diff --git a/Telegram/SourceFiles/data/data_changes.h b/Telegram/SourceFiles/data/data_changes.h index 2471bf2e2c..f5c66416af 100644 --- a/Telegram/SourceFiles/data/data_changes.h +++ b/Telegram/SourceFiles/data/data_changes.h @@ -282,6 +282,12 @@ struct ChatAdminChange { QString rank; }; +struct ChatMemberRankChange { + not_null peer; + not_null user; + QString rank; +}; + class Changes final { public: explicit Changes(not_null session); @@ -401,6 +407,12 @@ public: QString rank); [[nodiscard]] rpl::producer chatAdminChanges() const; + void chatMemberRankChanged( + not_null peer, + not_null user, + QString rank); + [[nodiscard]] rpl::producer chatMemberRankChanges() const; + void sendNotifications(); private: @@ -454,6 +466,7 @@ private: Manager _entryChanges; Manager _storyChanges; rpl::event_stream _chatAdminChanges; + rpl::event_stream _chatMemberRankChanges; bool _notify = false; diff --git a/Telegram/SourceFiles/data/data_channel.cpp b/Telegram/SourceFiles/data/data_channel.cpp index 27e5b5cc56..2f84b84daa 100644 --- a/Telegram/SourceFiles/data/data_channel.cpp +++ b/Telegram/SourceFiles/data/data_channel.cpp @@ -520,6 +520,17 @@ void ChannelData::applyEditAdmin( session().changes().peerUpdated(this, UpdateFlag::Admins); } +void ChannelData::applyEditMemberRank( + not_null user, + const QString &rank) { + if (!mgInfo) { + return; + } + const auto userId = peerToUser(user->id); + Data::ChannelMemberRankChanges changes(this); + changes.feed(userId, rank); +} + void ChannelData::applyEditBanned( not_null participant, ChatRestrictionsInfo oldRights, diff --git a/Telegram/SourceFiles/data/data_channel.h b/Telegram/SourceFiles/data/data_channel.h index 32e40e52a7..b5fb613f82 100644 --- a/Telegram/SourceFiles/data/data_channel.h +++ b/Telegram/SourceFiles/data/data_channel.h @@ -143,11 +143,10 @@ public: base::flat_set> bots; rpl::event_stream unrestrictedByBoostsChanges; - // For admin badges, full admins list with ranks. - base::flat_map admins; + base::flat_set admins; + base::flat_map memberRanks; UserData *creator = nullptr; // nullptr means unknown - QString creatorRank; int botStatus = 0; // -1 - no bots, 0 - unknown, 1 - one bot, that sees all history, 2 - other bool joinedMessageFound = false; bool adminsLoaded = false; @@ -308,6 +307,9 @@ public: ChatAdminRightsInfo oldRights, ChatAdminRightsInfo newRights, const QString &rank); + void applyEditMemberRank( + not_null user, + const QString &rank); void applyEditBanned( not_null participant, ChatRestrictionsInfo oldRights, @@ -357,7 +359,6 @@ public: [[nodiscard]] bool autoTranslation() const { return flags() & Flag::AutoTranslation; } - [[nodiscard]] auto adminRights() const { return _adminRights.current(); } diff --git a/Telegram/SourceFiles/data/data_channel_admins.cpp b/Telegram/SourceFiles/data/data_channel_admins.cpp index e7ac8ced18..49764162cc 100644 --- a/Telegram/SourceFiles/data/data_channel_admins.cpp +++ b/Telegram/SourceFiles/data/data_channel_admins.cpp @@ -10,31 +10,51 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history.h" #include "data/data_channel.h" #include "data/data_session.h" +#include "data/data_user.h" #include "main/main_session.h" namespace Data { ChannelAdminChanges::ChannelAdminChanges(not_null channel) : _channel(channel) -, _admins(_channel->mgInfo->admins) { +, _admins(_channel->mgInfo->admins) +, _oldCreator(_channel->mgInfo->creator) { } void ChannelAdminChanges::add(UserId userId, const QString &rank) { - const auto i = _admins.find(userId); - if (i == end(_admins) || i->second != rank) { - _admins[userId] = rank; + if (_admins.emplace(userId).second) { _changes.emplace(userId); } + auto &ranks = _channel->mgInfo->memberRanks; + if (!rank.isEmpty()) { + const auto i = ranks.find(userId); + if (i == end(ranks) || i->second != rank) { + ranks[userId] = rank; + _changes.emplace(userId); + } + } else { + if (ranks.remove(userId)) { + _changes.emplace(userId); + } + } } void ChannelAdminChanges::remove(UserId userId) { - if (_admins.contains(userId)) { - _admins.remove(userId); + if (_admins.remove(userId)) { _changes.emplace(userId); } } ChannelAdminChanges::~ChannelAdminChanges() { + const auto creator = _channel->mgInfo->creator; + if (creator != _oldCreator) { + if (creator) { + _changes.emplace(peerToUser(creator->id)); + } + if (_oldCreator) { + _changes.emplace(peerToUser(_oldCreator->id)); + } + } if (_changes.size() > 1 || (!_changes.empty() && _changes.front() != _channel->session().userId())) { @@ -44,4 +64,49 @@ ChannelAdminChanges::~ChannelAdminChanges() { } } +ChannelMemberRankChanges::ChannelMemberRankChanges( + not_null channel) +: _channel(channel) +, _memberRanks(_channel->mgInfo->memberRanks) { +} + +void ChannelMemberRankChanges::feed( + UserId userId, + const QString &rank) { + if (rank.isEmpty()) { + if (_memberRanks.remove(userId)) { + _changes.emplace(userId); + } + } else { + const auto i = _memberRanks.find(userId); + if (i == end(_memberRanks) || i->second != rank) { + _memberRanks[userId] = rank; + _changes.emplace(userId); + } + } +} + +ChannelMemberRankChanges::~ChannelMemberRankChanges() { + if (_changes.empty()) { + return; + } + const auto info = _channel->mgInfo.get(); + auto adminChanges = base::flat_set(); + for (const auto &userId : _changes) { + if (info->admins.contains(userId) + || (info->creator + && peerToUser(info->creator->id) == userId)) { + adminChanges.emplace(userId); + } + } + if (adminChanges.size() > 1 + || (!adminChanges.empty() + && adminChanges.front() != _channel->session().userId())) { + if (const auto history + = _channel->owner().historyLoaded(_channel)) { + history->applyGroupAdminChanges(adminChanges); + } + } +} + } // namespace Data diff --git a/Telegram/SourceFiles/data/data_channel_admins.h b/Telegram/SourceFiles/data/data_channel_admins.h index 7fa1b06fd9..b97d3259f3 100644 --- a/Telegram/SourceFiles/data/data_channel_admins.h +++ b/Telegram/SourceFiles/data/data_channel_admins.h @@ -20,7 +20,23 @@ public: private: not_null _channel; - base::flat_map &_admins; + base::flat_set &_admins; + base::flat_set _changes; + UserData *_oldCreator = nullptr; + +}; + +class ChannelMemberRankChanges { +public: + ChannelMemberRankChanges(not_null channel); + + void feed(UserId userId, const QString &rank); + + ~ChannelMemberRankChanges(); + +private: + not_null _channel; + base::flat_map &_memberRanks; base::flat_set _changes; }; diff --git a/Telegram/SourceFiles/data/data_chat.cpp b/Telegram/SourceFiles/data/data_chat.cpp index ef60a655ce..fb1a92b100 100644 --- a/Telegram/SourceFiles/data/data_chat.cpp +++ b/Telegram/SourceFiles/data/data_chat.cpp @@ -451,6 +451,32 @@ void ApplyChatUpdate( session->changes().peerUpdated(chat, UpdateFlag::Admins); } +void ApplyChatUpdate( + not_null chat, + const MTPDupdateChatParticipantRank &update) { + if (chat->applyUpdateVersion(update.vversion().v) + != ChatData::UpdateStatus::Good) { + return; + } + const auto rank = qs(update.vrank().v); + const auto userId = UserId(update.vuser_id().v); + if (rank.isEmpty()) { + chat->memberRanks.remove(userId); + } else { + chat->memberRanks[userId] = rank; + } + if (userId != chat->session().userId()) { + if (const auto history = chat->owner().historyLoaded(chat)) { + auto changes = base::flat_set(); + changes.emplace(userId); + history->applyGroupAdminChanges(changes); + } + } + chat->session().changes().peerUpdated( + chat, + Data::PeerUpdate::Flag::Members); +} + void ApplyChatUpdate( not_null chat, const MTPDupdateChatDefaultBannedRights &update) { @@ -546,6 +572,7 @@ void ApplyChatUpdate( chat->participants.clear(); chat->invitedByMe.clear(); chat->admins.clear(); + chat->memberRanks.clear(); chat->setAdminRights(ChatAdminRights()); const auto selfUserId = session->userId(); for (const auto &participant : list) { @@ -572,13 +599,25 @@ void ApplyChatUpdate( participant.match([&](const MTPDchatParticipantCreator &data) { chat->creator = userId; + const auto rank = qs(data.vrank().value_or_empty()); + if (!rank.isEmpty()) { + chat->memberRanks[userId] = rank; + } }, [&](const MTPDchatParticipantAdmin &data) { chat->admins.emplace(user); if (user->isSelf()) { chat->setAdminRights( chat->defaultAdminRights(user).flags); } - }, [](const MTPDchatParticipant &) { + const auto rank = qs(data.vrank().value_or_empty()); + if (!rank.isEmpty()) { + chat->memberRanks[userId] = rank; + } + }, [&](const MTPDchatParticipant &data) { + const auto rank = qs(data.vrank().value_or_empty()); + if (!rank.isEmpty()) { + chat->memberRanks[userId] = rank; + } }); } if (chat->participants.empty()) { diff --git a/Telegram/SourceFiles/data/data_chat.h b/Telegram/SourceFiles/data/data_chat.h index 2e36008223..459811b112 100644 --- a/Telegram/SourceFiles/data/data_chat.h +++ b/Telegram/SourceFiles/data/data_chat.h @@ -181,6 +181,7 @@ public: base::flat_set> admins; std::deque> lastAuthors; base::flat_set> markupSenders; + base::flat_map memberRanks; int botStatus = 0; // -1 - no bots, 0 - unknown, 1 - one bot, that sees all history, 2 - other private: @@ -218,6 +219,9 @@ void ApplyChatUpdate( void ApplyChatUpdate( not_null chat, const MTPDupdateChatParticipantAdmin &update); +void ApplyChatUpdate( + not_null chat, + const MTPDupdateChatParticipantRank &update); void ApplyChatUpdate( not_null chat, const MTPDupdateChatDefaultBannedRights &update); diff --git a/Telegram/SourceFiles/data/data_chat_participant_status.cpp b/Telegram/SourceFiles/data/data_chat_participant_status.cpp index 0656bddcb9..08f169885d 100644 --- a/Telegram/SourceFiles/data/data_chat_participant_status.cpp +++ b/Telegram/SourceFiles/data/data_chat_participant_status.cpp @@ -52,6 +52,9 @@ namespace { | (data.is_delete_stories() ? Flag::DeleteStories : Flag()) | (data.is_manage_direct_messages() ? Flag::ManageDirect + : Flag()) + | (data.is_manage_ranks() + ? Flag::ManageRanks : Flag()); }); } @@ -77,7 +80,8 @@ namespace { | (data.is_change_info() ? Flag::ChangeInfo : Flag()) | (data.is_invite_users() ? Flag::AddParticipants : Flag()) | (data.is_pin_messages() ? Flag::PinMessages : Flag()) - | (data.is_manage_topics() ? Flag::CreateTopics : Flag()); + | (data.is_manage_topics() ? Flag::CreateTopics : Flag()) + | (data.is_edit_rank() ? Flag::EditRank : Flag()); }); } @@ -116,6 +120,9 @@ MTPChatAdminRights AdminRightsToMTP(ChatAdminRightsInfo info) { | ((flags & R::DeleteStories) ? Flag::f_delete_stories : Flag()) | ((flags & R::ManageDirect) ? Flag::f_manage_direct_messages + : Flag()) + | ((flags & R::ManageRanks) + ? Flag::f_manage_ranks : Flag()))); } @@ -147,7 +154,8 @@ MTPChatBannedRights RestrictionsToMTP(ChatRestrictionsInfo info) { | ((flags & R::ChangeInfo) ? Flag::f_change_info : Flag()) | ((flags & R::AddParticipants) ? Flag::f_invite_users : Flag()) | ((flags & R::PinMessages) ? Flag::f_pin_messages : Flag()) - | ((flags & R::CreateTopics) ? Flag::f_manage_topics : Flag())), + | ((flags & R::CreateTopics) ? Flag::f_manage_topics : Flag()) + | ((flags & R::EditRank) ? Flag::f_edit_rank : Flag())), MTP_int(info.until)); } diff --git a/Telegram/SourceFiles/data/data_chat_participant_status.h b/Telegram/SourceFiles/data/data_chat_participant_status.h index 073096b09b..0d14508a1e 100644 --- a/Telegram/SourceFiles/data/data_chat_participant_status.h +++ b/Telegram/SourceFiles/data/data_chat_participant_status.h @@ -37,6 +37,7 @@ enum class ChatAdminRight { EditStories = (1 << 15), DeleteStories = (1 << 16), ManageDirect = (1 << 17), + ManageRanks = (1 << 18), }; inline constexpr bool is_flag_type(ChatAdminRight) { return true; } using ChatAdminRights = base::flags; @@ -63,6 +64,7 @@ enum class ChatRestriction { AddParticipants = (1 << 15), PinMessages = (1 << 17), CreateTopics = (1 << 18), + EditRank = (1 << 26), }; inline constexpr bool is_flag_type(ChatRestriction) { return true; } using ChatRestrictions = base::flags; @@ -105,6 +107,7 @@ struct AdminRightsSetOptions { struct RestrictionsSetOptions { bool isForum = false; + bool isUserSpecific = false; }; [[nodiscard]] std::vector ListOfRestrictions( diff --git a/Telegram/SourceFiles/data/data_forum.cpp b/Telegram/SourceFiles/data/data_forum.cpp index 2ce2861839..0c059f9490 100644 --- a/Telegram/SourceFiles/data/data_forum.cpp +++ b/Telegram/SourceFiles/data/data_forum.cpp @@ -527,6 +527,15 @@ MsgId Forum::reserveCreatingId( return result; } +ForumTopic *Forum::reserveNewBotTopic() { + const auto &colors = ForumTopicColorIds(); + const auto colorId = colors[base::RandomIndex(colors.size())]; + return topicFor(reserveCreatingId( + tr::lng_bot_new_chat(tr::now), + colorId, + DocumentId())); +} + void Forum::discardCreatingId(MsgId rootId) { Expects(creating(rootId)); diff --git a/Telegram/SourceFiles/data/data_forum.h b/Telegram/SourceFiles/data/data_forum.h index 0b38ca6232..96f4fcb49f 100644 --- a/Telegram/SourceFiles/data/data_forum.h +++ b/Telegram/SourceFiles/data/data_forum.h @@ -87,6 +87,7 @@ public: void discardCreatingId(MsgId rootId); [[nodiscard]] bool creating(MsgId rootId) const; void created(MsgId rootId, MsgId realId); + [[nodiscard]] ForumTopic *reserveNewBotTopic(); void clearAllUnreadMentions(); void clearAllUnreadReactions(); diff --git a/Telegram/SourceFiles/data/data_group_call.cpp b/Telegram/SourceFiles/data/data_group_call.cpp index 10ba213f3d..a18477b26b 100644 --- a/Telegram/SourceFiles/data/data_group_call.cpp +++ b/Telegram/SourceFiles/data/data_group_call.cpp @@ -976,7 +976,7 @@ void GroupCall::checkFinishSpeakingByActive() { ++i; } } - for (const auto participantPeer : stop) { + for (const auto &participantPeer : stop) { const auto participant = findParticipant(participantPeer); Assert(participant != nullptr); if (participant->speaking) { diff --git a/Telegram/SourceFiles/data/data_histories.cpp b/Telegram/SourceFiles/data/data_histories.cpp index dbad55cbe3..2db35c9323 100644 --- a/Telegram/SourceFiles/data/data_histories.cpp +++ b/Telegram/SourceFiles/data/data_histories.cpp @@ -1004,7 +1004,7 @@ void Histories::deleteMessages(const MessageIdsList &ids, bool revoke) { document->owner().savedMusic().remove(document); } - for (const auto item : remove) { + for (const auto &item : remove) { const auto history = item->history(); const auto wasLast = (history->lastMessage() == item); const auto wasInChats = (history->chatListMessage() == item); diff --git a/Telegram/SourceFiles/data/data_histories.h b/Telegram/SourceFiles/data/data_histories.h index fd6ee38108..773dec16c1 100644 --- a/Telegram/SourceFiles/data/data_histories.h +++ b/Telegram/SourceFiles/data/data_histories.h @@ -112,7 +112,8 @@ public: MTPmessages_SendMessage, MTPmessages_SendMedia, MTPmessages_SendInlineBotResult, - MTPmessages_SendMultiMedia>; + MTPmessages_SendMultiMedia, + MTPmessages_ForwardMessages>; int sendPreparedMessage( not_null history, FullReplyTo replyTo, diff --git a/Telegram/SourceFiles/data/data_peer.cpp b/Telegram/SourceFiles/data/data_peer.cpp index 5882e8915c..73527509af 100644 --- a/Telegram/SourceFiles/data/data_peer.cpp +++ b/Telegram/SourceFiles/data/data_peer.cpp @@ -1706,8 +1706,8 @@ bool PeerData::isAyuNoForwards() const { } bool PeerData::allowsForwarding() const { - if (isUser()) { - return true; + if (const auto user = asUser()) { + return user->allowsForwarding(); } else if (const auto channel = asChannel()) { return channel->allowsForwarding(); } else if (const auto chat = asChat()) { @@ -1862,6 +1862,17 @@ bool PeerData::canManageGroupCall() const { Unexpected("Peer type in PeerData::canManageGroupCall."); } +bool PeerData::canManageRanks() const { + if (const auto chat = asChat()) { + return chat->amCreator() + || (chat->adminRights() & ChatAdminRight::ManageRanks); + } else if (const auto channel = asChannel()) { + return channel->amCreator() + || (channel->adminRights() & ChatAdminRight::ManageRanks); + } + return false; +} + bool PeerData::amMonoforumAdmin() const { if (const auto channel = asChannel()) { return channel->flags() & ChannelDataFlag::MonoforumAdmin; @@ -2213,9 +2224,9 @@ std::optional ColorIndexFromColor(const MTPPeerColor *color) { }); } -bool IsBotCanManageTopics(not_null peer) { +bool IsBotUserCreatesTopics(not_null peer) { if (const auto user = peer->asUser()) { - return user->botInfo && user->botInfo->canManageTopics; + return user->botInfo && user->botInfo->userCreatesTopics; } return false; } diff --git a/Telegram/SourceFiles/data/data_peer.h b/Telegram/SourceFiles/data/data_peer.h index 9daad00e25..6df672719c 100644 --- a/Telegram/SourceFiles/data/data_peer.h +++ b/Telegram/SourceFiles/data/data_peer.h @@ -318,6 +318,7 @@ public: [[nodiscard]] rpl::producer slowmodeAppliedValue() const; [[nodiscard]] int slowmodeSecondsLeft() const; [[nodiscard]] bool canManageGroupCall() const; + [[nodiscard]] bool canManageRanks() const; [[nodiscard]] bool amMonoforumAdmin() const; [[nodiscard]] int starsPerMessage() const; @@ -678,6 +679,6 @@ void SetTopPinnedMessageId( [[nodiscard]] uint64 BackgroundEmojiIdFromColor(const MTPPeerColor *color); [[nodiscard]] std::optional ColorIndexFromColor(const MTPPeerColor *); -[[nodiscard]] bool IsBotCanManageTopics(not_null); +[[nodiscard]] bool IsBotUserCreatesTopics(not_null); } // namespace Data diff --git a/Telegram/SourceFiles/data/data_search_calendar.cpp b/Telegram/SourceFiles/data/data_search_calendar.cpp new file mode 100644 index 0000000000..1d4cc94fcd --- /dev/null +++ b/Telegram/SourceFiles/data/data_search_calendar.cpp @@ -0,0 +1,221 @@ +/* +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 "data/data_search_calendar.h" + +#include "apiwrap.h" +#include "base/unixtime.h" +#include "data/data_document.h" +#include "data/data_media_types.h" +#include "data/data_peer.h" +#include "data/data_search_controller.h" // PrepareSearchFilter +#include "data/data_session.h" +#include "history/history_item.h" +#include "history/history.h" +#include "main/main_session.h" +#include "ui/dynamic_thumbnails.h" + +namespace Api { + +SearchCalendarController::SearchCalendarController( + not_null session, + PeerId peerId, + Storage::SharedMediaType type) +: _session(session) +, _peerId(peerId) +, _type(type) +, _api(&session->mtp()) { +} + +void SearchCalendarController::monthThumbnails( + TimeId date, + Fn)> onFinish) { + const auto parsed = base::unixtime::parse(date).date(); + const auto key = MonthKey{ + .peerId = _peerId, + .year = parsed.year(), + .month = parsed.month(), + }; + + if (const auto it = _months.find(key); it != _months.end()) { + if (!it->second.cache.empty()) { + onFinish(it->second.cache); + return; + } + } + + _months[key].callbacks.push_back(std::move(onFinish)); + + if (!_months[key].requestId) { + performMonthRequest(key); + } +} + +void SearchCalendarController::performMonthRequest(const MonthKey &key) { + const auto peer = _session->data().peer(key.peerId); + const auto filter = PrepareSearchFilter(_type); + + const auto parsed = QDate(key.year, key.month, 1); + const auto endDate = base::unixtime::serialize(QDateTime( + parsed.addMonths(1).addDays(-1), + QTime(23, 59, 59))); + + auto &state = _months[key].state; + + _months[key].requestId = _api.request( + MTPmessages_GetSearchResultsCalendar( + MTP_flags(0), + peer->input(), + MTPInputPeer(), + filter, + MTP_int(state.offsetId), + MTP_int(state.offsetDate ? state.offsetDate : endDate) + )).done([=](const MTPmessages_SearchResultsCalendar &result) { + _months[key].requestId = 0; + const auto &data = result.data(); + _session->data().processUsers(data.vusers()); + _session->data().processChats(data.vchats()); + _session->data().processMessages( + data.vmessages(), + NewMessageType::Existing); + + auto messageIds = std::vector(); + messageIds.reserve(data.vmessages().v.size()); + for (const auto &message : data.vmessages().v) { + messageIds.push_back( + FullMsgId(key.peerId, IdFromMessage(message))); + } + + auto &monthState = _months[key].state; + const auto prevOffsetId = monthState.offsetId; + const auto prevOffsetDate = monthState.offsetDate; + monthState.offsetId = data.vmin_msg_id().v; + monthState.offsetDate = data.vmin_date().v; + + const auto noMoreData = (prevOffsetId == monthState.offsetId + && prevOffsetDate == monthState.offsetDate + && prevOffsetId != 0); + + processMonthMessages( + key, + messageIds, + data.vmin_date().v, + data.vmin_msg_id().v, + noMoreData); + }).fail([=] { + auto &data = _months[key]; + data.requestId = 0; + data.cache = {}; + for (const auto &callback : data.callbacks) { + callback({}); + } + data.callbacks.clear(); + }).send(); +} + +void SearchCalendarController::processMonthMessages( + const MonthKey &key, + const std::vector &messages, + TimeId minDate, + MsgId minMsgId, + bool noMoreData) { + auto result = std::vector(); + auto seenDays = base::flat_set(); + + const auto targetMonth = QDate(key.year, key.month, 1); + const auto targetStart = base::unixtime::serialize( + QDateTime(targetMonth, QTime())); + const auto targetEnd = base::unixtime::serialize(QDateTime( + targetMonth.addMonths(1).addDays(-1), + QTime(23, 59, 59))); + + for (const auto &fullId : messages) { + const auto item = _session->data().message(fullId); + if (!item) { + continue; + } + + const auto date = item->date(); + if (date < targetStart || date > targetEnd) { + continue; + } + + const auto parsed = base::unixtime::parse(date).date(); + const auto dayStart = base::unixtime::serialize( + QDateTime(parsed, QTime())); + + if (seenDays.contains(dayStart)) { + continue; + } + + const auto media = item->media(); + if (!media) { + continue; + } + + auto image = std::shared_ptr(); + + if (const auto photo = media->photo()) { + image = Ui::MakePhotoThumbnail(photo, item->fullId()); + } else if (const auto document = media->document()) { + if (document->isVideoFile()) { + image = Ui::MakeDocumentThumbnail(document, item->fullId()); + } + } + + if (image) { + seenDays.insert(dayStart); + result.push_back(DayThumbnail{ + .date = dayStart, + .image = std::move(image), + .msgId = fullId.msg, + }); + } + } + + if (result.empty() + && minDate < targetStart + && !_months[key].requestId + && !noMoreData) { + performMonthRequest(key); + } else { + auto &data = _months[key]; + data.cache = result; + for (const auto &callback : data.callbacks) { + callback(result); + } + data.callbacks.clear(); + } +} + +std::optional SearchCalendarController::resolveMsgIdByDate( + TimeId date) const { + const auto parsed = base::unixtime::parse(date).date(); + const auto key = MonthKey{ + .peerId = _peerId, + .year = parsed.year(), + .month = parsed.month(), + }; + + const auto it = _months.find(key); + if (it == _months.end() || it->second.cache.empty()) { + return std::nullopt; + } + + const auto dayStart = base::unixtime::serialize( + QDateTime(parsed, QTime())); + + for (const auto &thumb : it->second.cache) { + if (thumb.date == dayStart) { + return thumb.msgId; + } + } + + return std::nullopt; +} + +} // namespace Api \ No newline at end of file diff --git a/Telegram/SourceFiles/data/data_search_calendar.h b/Telegram/SourceFiles/data/data_search_calendar.h new file mode 100644 index 0000000000..3fcd4758c8 --- /dev/null +++ b/Telegram/SourceFiles/data/data_search_calendar.h @@ -0,0 +1,97 @@ +/* +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 "storage/storage_shared_media.h" + +namespace Main { +class Session; +} // namespace Main + +namespace Ui { +class DynamicImage; +} // namespace Ui + +namespace Api { + +struct CalendarPeriod { + TimeId date = 0; + MsgId minMsgId = 0; + MsgId maxMsgId = 0; + int count = 0; +}; + +struct CalendarResult { + std::vector periods; + int count = 0; + TimeId minDate = 0; + MsgId minMsgId = 0; +}; + +struct DayThumbnail { + TimeId date = 0; + std::shared_ptr image; + MsgId msgId = 0; +}; + +class SearchCalendarController final { +public: + SearchCalendarController( + not_null session, + PeerId peerId, + Storage::SharedMediaType type); + + void monthThumbnails( + TimeId date, + Fn)> onFinish); + + [[nodiscard]] std::optional resolveMsgIdByDate(TimeId date) const; + +private: + struct MonthKey { + PeerId peerId = 0; + int year = 0; + int month = 0; + + friend inline auto operator<=>( + const MonthKey &, + const MonthKey &) = default; + }; + + struct MonthState { + MsgId offsetId = 0; + TimeId offsetDate = 0; + }; + + struct MonthData { + std::vector cache; + std::vector)>> callbacks; + mtpRequestId requestId = 0; + MonthState state; + }; + + void performMonthRequest(const MonthKey &key); + void processMonthMessages( + const MonthKey &key, + const std::vector &messages, + TimeId minDate, + MsgId minMsgId, + bool noMoreData); + + const not_null _session; + const PeerId _peerId; + const Storage::SharedMediaType _type; + + MTP::Sender _api; + + base::flat_map _months; + +}; + +} // namespace Api diff --git a/Telegram/SourceFiles/data/data_session.cpp b/Telegram/SourceFiles/data/data_session.cpp index 4fb3c39357..fee8b7c15f 100644 --- a/Telegram/SourceFiles/data/data_session.cpp +++ b/Telegram/SourceFiles/data/data_session.cpp @@ -242,6 +242,7 @@ Session::Session(not_null session) , _contactsList(Dialogs::SortMode::Name) , _contactsNoChatsList(Dialogs::SortMode::Name) , _ttlCheckTimer([=] { checkTTLs(); }) +, _formattedDateTimer([=] { checkFormattedDateUpdates(); }) , _selfDestructTimer([=] { checkSelfDestructItems(); }) , _pollsClosingTimer([=] { checkPollsClosings(); }) , _watchForOfflineTimer([=] { checkLocalUsersWentOffline(); }) @@ -575,6 +576,28 @@ not_null Session::processUser(const MTPUser &data) { result->setStarsPerMessage(0); } + if (!minimal) { + result->setBotInfoVersion(data.vbot_info_version().value_or(-1)); + if (const auto info = result->botInfo.get()) { + info->readsAllHistory = data.is_bot_chat_history(); + if (info->cantJoinGroups != data.is_bot_nochats()) { + info->cantJoinGroups = data.is_bot_nochats(); + flags |= UpdateFlag::BotCanBeInvited; + } + if (const auto value = data.vbot_inline_placeholder()) { + info->inlinePlaceholder = '_' + qs(*value); + } else { + info->inlinePlaceholder = QString(); + } + info->supportsAttachMenu = data.is_bot_attach_menu(); + info->supportsBusiness = data.is_bot_business(); + info->canEditInformation = data.is_bot_can_edit(); + info->activeUsers = data.vbot_active_users().value_or_empty(); + info->hasMainApp = data.is_bot_has_main_app(); + info->userCreatesTopics = data.is_bot_forum_can_manage_topics(); + } + } + using Flag = UserDataFlag; const auto flagsMask = Flag::Deleted | Flag::Verified @@ -736,12 +759,14 @@ not_null Session::processUser(const MTPUser &data) { status = data.vstatus(); if (!minimal) { const auto newUsername = uname; - const auto newUsernames = data.vusernames() - ? Api::Usernames::FromTL(*data.vusernames()) - : !newUsername.isEmpty() - ? Data::Usernames{{ newUsername, true, true }} - : Data::Usernames(); - result->setUsernames(newUsernames); + if (data.vusernames()) { + result->setUsernames( + Api::Usernames::FromTL(*data.vusernames())); + } else if (!newUsername.isEmpty()) { + result->setUsernames({{ newUsername, true, true }}); + } else { + result->setUsernames({}); + } } } if (const auto &status = data.vemoji_status()) { @@ -750,28 +775,6 @@ not_null Session::processUser(const MTPUser &data) { result->setEmojiStatus(EmojiStatusId()); } if (!minimal) { - if (const auto botInfoVersion = data.vbot_info_version()) { - result->setBotInfoVersion(botInfoVersion->v); - result->botInfo->readsAllHistory = data.is_bot_chat_history(); - if (result->botInfo->cantJoinGroups != data.is_bot_nochats()) { - result->botInfo->cantJoinGroups = data.is_bot_nochats(); - flags |= UpdateFlag::BotCanBeInvited; - } - if (const auto placeholder = data.vbot_inline_placeholder()) { - result->botInfo->inlinePlaceholder = '_' + qs(*placeholder); - } else { - result->botInfo->inlinePlaceholder = QString(); - } - result->botInfo->supportsAttachMenu = data.is_bot_attach_menu(); - result->botInfo->supportsBusiness = data.is_bot_business(); - result->botInfo->canEditInformation = data.is_bot_can_edit(); - result->botInfo->activeUsers = data.vbot_active_users().value_or_empty(); - result->botInfo->hasMainApp = data.is_bot_has_main_app(); - result->botInfo->canManageTopics - = data.is_bot_forum_can_manage_topics(); - } else { - result->setBotInfoVersion(-1); - } result->setIsContact(data.is_contact() || data.is_mutual_contact()); } @@ -969,15 +972,17 @@ not_null Session::processChat(const MTPChat &data) { { const auto newUsername = qs(data.vusername().value_or_empty()); - const auto newUsernames = data.vusernames() - ? Api::Usernames::FromTL(*data.vusernames()) - : !newUsername.isEmpty() - ? Data::Usernames{ Data::Username{ newUsername, true, true } } - : Data::Usernames(); channel->setName( qs(data.vtitle()), TextUtilities::SingleLine(newUsername)); - channel->setUsernames(newUsernames); + if (data.vusernames()) { + channel->setUsernames( + Api::Usernames::FromTL(*data.vusernames())); + } else if (!newUsername.isEmpty()) { + channel->setUsernames({ { newUsername, true, true } }); + } else { + channel->setUsernames({}); + } } const auto hasUsername = !channel->username().isEmpty(); @@ -1323,6 +1328,21 @@ void Session::maybeStopWatchForOffline(not_null user) { } } +void Session::recordSharingDisabledTime(not_null user) { + _sharingDisabledTimes[user] = base::unixtime::now(); +} + +bool Session::sharingRecentlyDisabledByMe( + not_null user) const { + const auto i = _sharingDisabledTimes.find(user); + return (i != end(_sharingDisabledTimes)) + && (base::unixtime::now() - i->second < 86400); +} + +void Session::clearSharingDisabledTime(not_null user) { + _sharingDisabledTimes.remove(user); +} + void Session::checkLocalUsersWentOffline() { _watchForOfflineTimer.cancel(); @@ -1704,7 +1724,7 @@ void Session::enumerateItemViews( not_null item, Method method) { if (const auto i = _views.find(item); i != _views.end()) { - for (const auto view : i->second) { + for (const auto &view : i->second) { method(view); } } @@ -2292,7 +2312,7 @@ void Session::unloadHeavyViewParts( remove.push_back(view); } } - for (const auto view : remove) { + for (const auto &view : remove) { view->unloadHeavyPart(); } } @@ -2312,7 +2332,7 @@ void Session::unloadHeavyViewParts( remove.push_back(view); } } - for (const auto view : remove) { + for (const auto &view : remove) { view->unloadHeavyPart(); } } @@ -2865,6 +2885,49 @@ void Session::checkTTLs() { scheduleNextTTLs(); } +void Session::registerFormattedDateUpdate( + TimeId when, + not_null view) { + _formattedDateUpdates[when].push_back( + base::make_weak(view.get())); + const auto nearest = _formattedDateUpdates.begin()->first; + if (nearest < when && _formattedDateTimer.isActive()) { + return; + } + scheduleNextFormattedDateUpdate(); +} + +void Session::scheduleNextFormattedDateUpdate() { + if (_formattedDateUpdates.empty()) { + return; + } + const auto nearest = _formattedDateUpdates.begin()->first; + const auto now = base::unixtime::now(); + const auto maxTimeout = TimeId(86400); + const auto timeout = std::min( + std::max(now, nearest) - now, + maxTimeout); + _formattedDateTimer.callOnce(timeout * crl::time(1000)); +} + +void Session::checkFormattedDateUpdates() { + _formattedDateTimer.cancel(); + const auto now = base::unixtime::now(); + auto expired = std::vector>(); + while (!_formattedDateUpdates.empty() + && _formattedDateUpdates.begin()->first <= now) { + auto &list = _formattedDateUpdates.begin()->second; + expired.insert(expired.end(), list.begin(), list.end()); + _formattedDateUpdates.erase(_formattedDateUpdates.begin()); + } + for (const auto &weak : expired) { + if (const auto strong = weak.get()) { + requestItemTextRefresh(strong->data()); + } + } + scheduleNextFormattedDateUpdate(); +} + void Session::processMessagesDeleted( PeerId peerId, const QVector &data) { @@ -4471,6 +4534,12 @@ void Session::applyUpdate(const MTPDupdateChatParticipantAdmin &update) { } } +void Session::applyUpdate(const MTPDupdateChatParticipantRank &update) { + if (const auto chat = chatLoaded(update.vchat_id().v)) { + ApplyChatUpdate(chat, update); + } +} + void Session::applyUpdate(const MTPDupdateChatDefaultBannedRights &update) { if (const auto peer = peerLoaded(peerFromMTP(update.vpeer()))) { if (const auto chat = peer->asChat()) { @@ -4674,7 +4743,7 @@ void Session::registerContactItem( } if (const auto i = _views.find(item); i != _views.end()) { - for (const auto view : i->second) { + for (const auto &view : i->second) { if (const auto media = view->media()) { media->updateSharedContactUserId(contactId); } @@ -4741,7 +4810,7 @@ void Session::unregisterStoryItem( void Session::refreshStoryItemViews(FullStoryId id) { const auto i = _storyItems.find(id); if (i != _storyItems.end()) { - for (const auto item : i->second) { + for (const auto &item : i->second) { if (const auto media = item->media()) { if (media->storyMention()) { item->updateStoryMentionText(); @@ -5178,6 +5247,7 @@ void Session::insertCheckedServiceNotification( MTP_int(0), // Not used (would've been trimmed to 32 bits). peerToMTP(PeerData::kServiceNotificationsId), MTPint(), // from_boosts_applied + MTPstring(), // from_rank peerToMTP(PeerData::kServiceNotificationsId), MTPPeer(), // saved_peer_id MTPMessageFwdHeader(), diff --git a/Telegram/SourceFiles/data/data_session.h b/Telegram/SourceFiles/data/data_session.h index d4096e7845..a46318e213 100644 --- a/Telegram/SourceFiles/data/data_session.h +++ b/Telegram/SourceFiles/data/data_session.h @@ -286,6 +286,11 @@ public: void watchForOffline(not_null user, TimeId now = 0); void maybeStopWatchForOffline(not_null user); + void recordSharingDisabledTime(not_null user); + [[nodiscard]] bool sharingRecentlyDisabledByMe( + not_null user) const; + void clearSharingDisabledTime(not_null user); + [[nodiscard]] auto invitedToCallUsers(CallId callId) const -> const base::flat_map, bool> &; void registerInvitedToCallUser( @@ -469,6 +474,7 @@ public: void applyUpdate(const MTPDupdateChatParticipantAdd &update); void applyUpdate(const MTPDupdateChatParticipantDelete &update); void applyUpdate(const MTPDupdateChatParticipantAdmin &update); + void applyUpdate(const MTPDupdateChatParticipantRank &update); void applyUpdate(const MTPDupdateChatDefaultBannedRights &update); void applyDialogs( @@ -527,6 +533,10 @@ public: void registerMessageTTL(TimeId when, not_null item); void unregisterMessageTTL(TimeId when, not_null item); + void registerFormattedDateUpdate( + TimeId when, + not_null view); + // Returns true if item found and it is not detached. bool updateExistingMessage(const MTPDmessage &data); void updateEditedMessage(const MTPMessage &data); @@ -957,6 +967,9 @@ private: void scheduleNextTTLs(); void checkTTLs(); + void scheduleNextFormattedDateUpdate(); + void checkFormattedDateUpdates(); + int computeUnreadBadge(const Dialogs::UnreadState &state) const; bool computeUnreadBadgeMuted(const Dialogs::UnreadState &state) const; @@ -1146,6 +1159,9 @@ private: std::map>> _ttlMessages; base::Timer _ttlCheckTimer; + std::map>> _formattedDateUpdates; + base::Timer _formattedDateTimer; + std::unordered_map> _nonChannelMessages; base::flat_map _messageByRandomId; @@ -1274,6 +1290,7 @@ private: base::flat_map, TimeId> _watchingForOffline; base::Timer _watchForOfflineTimer; + base::flat_map, TimeId> _sharingDisabledTimes; base::flat_map, MTP::DcId> _peerStatsDcIds; diff --git a/Telegram/SourceFiles/data/data_user.cpp b/Telegram/SourceFiles/data/data_user.cpp index 95fcfbb83e..64c922a0ba 100644 --- a/Telegram/SourceFiles/data/data_user.cpp +++ b/Telegram/SourceFiles/data/data_user.cpp @@ -669,6 +669,21 @@ bool UserData::readDatesPrivate() const { return (flags() & UserDataFlag::ReadDatesPrivate); } +bool UserData::allowsForwarding() const { + return !(flags() & Flag::NoForwardsMyEnabled) + && !(flags() & Flag::NoForwardsPeerEnabled); +} + +void UserData::setNoForwardsFlags(bool myEnabled, bool peerEnabled) { + const auto mask = Flag::NoForwardsMyEnabled | Flag::NoForwardsPeerEnabled; + setFlags((flags() & ~mask) + | (myEnabled ? Flag::NoForwardsMyEnabled : Flag()) + | (peerEnabled ? Flag::NoForwardsPeerEnabled : Flag())); + if (!myEnabled && !peerEnabled) { + owner().clearSharingDisabledTime(this); + } +} + int UserData::starsPerMessage() const { return _starsPerMessage; } @@ -1059,6 +1074,10 @@ void ApplyUserUpdate(not_null user, const MTPDuserFull &update) { user->setNote(TextWithEntities()); } + user->setNoForwardsFlags( + update.is_noforwards_my_enabled(), + update.is_noforwards_peer_enabled()); + user->fullUpdated(); } diff --git a/Telegram/SourceFiles/data/data_user.h b/Telegram/SourceFiles/data/data_user.h index 4243548d93..e969efc4b6 100644 --- a/Telegram/SourceFiles/data/data_user.h +++ b/Telegram/SourceFiles/data/data_user.h @@ -98,7 +98,7 @@ struct BotInfo { bool canManageEmojiStatus : 1 = false; bool supportsBusiness : 1 = false; bool hasMainApp : 1 = false; - bool canManageTopics : 1 = false; + bool userCreatesTopics : 1 = false; private: std::unique_ptr _forum; @@ -135,6 +135,8 @@ enum class UserDataFlag : uint32 { StoriesCorrespondent = (1 << 26), Forum = (1 << 27), HasActiveVideoStream = (1 << 28), + NoForwardsMyEnabled = (1 << 29), + NoForwardsPeerEnabled = (1 << 30), }; inline constexpr bool is_flag_type(UserDataFlag) { return true; }; using UserDataFlags = base::flags; @@ -201,6 +203,8 @@ public: [[nodiscard]] bool messageMoneyRestrictionsKnown() const; [[nodiscard]] bool canSendIgnoreMoneyRestrictions() const; [[nodiscard]] bool readDatesPrivate() const; + [[nodiscard]] bool allowsForwarding() const; + void setNoForwardsFlags(bool myEnabled, bool peerEnabled); [[nodiscard]] bool isForum() const { return flags() & Flag::Forum; } diff --git a/Telegram/SourceFiles/data/stickers/data_stickers.cpp b/Telegram/SourceFiles/data/stickers/data_stickers.cpp index d6f3549fc5..5d6c142301 100644 --- a/Telegram/SourceFiles/data/stickers/data_stickers.cpp +++ b/Telegram/SourceFiles/data/stickers/data_stickers.cpp @@ -261,7 +261,7 @@ void Stickers::incrementSticker(not_null document) { set->emoji[emoji].push_front(document); } } else if (!removedFromEmoji.empty()) { - for (const auto emoji : removedFromEmoji) { + for (const auto &emoji : removedFromEmoji) { set->emoji[emoji].push_front(document); } } else { @@ -1465,7 +1465,7 @@ std::vector> Stickers::getListByEmoji( const auto others = session().api().stickersByEmoji(key); if (others) { result.reserve(result.size() + others->size()); - for (const auto document : *others) { + for (const auto &document : *others) { add(document, CreateOtherSortKey(document)); } } else if (!forceAllResults) { diff --git a/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp b/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp index 35109bc8b3..4c9923c6cb 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp +++ b/Telegram/SourceFiles/dialogs/dialogs_inner_widget.cpp @@ -103,7 +103,8 @@ namespace { constexpr auto kHashtagResultsLimit = 5; constexpr auto kStartReorderThreshold = 30; -constexpr auto kStartDragToFilterThreshold = 35; +constexpr auto kStartDragToFilterThresholdX = kStartReorderThreshold; +constexpr auto kStartDragToFilterThresholdY = 75; constexpr auto kQueryPreviewLimit = 32; constexpr auto kPreviewPostsLimit = 3; @@ -533,17 +534,18 @@ InnerWidget::InnerWidget( RowDescriptor previous, RowDescriptor next) { const auto update = [&](const RowDescriptor &descriptor) { + const auto msgId = descriptor.fullId; if (const auto topic = descriptor.key.topic()) { if (_openedForum == topic->forum()) { updateDialogRow(descriptor); } else { - updateDialogRow({ { topic->owningHistory() }, {} }); + updateDialogRow({ { topic->owningHistory() }, msgId }); } } else if (const auto sublist = descriptor.key.sublist()) { if (_savedSublists == sublist->parent()) { updateDialogRow(descriptor); } else { - updateDialogRow({ { sublist->owningHistory() }, {} }); + updateDialogRow({ { sublist->owningHistory() }, msgId }); } } else { updateDialogRow(descriptor); @@ -1746,9 +1748,14 @@ void InnerWidget::mouseMoveEvent(QMouseEvent *e) { if (_pressed && (e->buttons() & Qt::LeftButton)) { const auto local = e->pos(); - const auto outside = _dragging ? !rect().contains(local) : true; - const auto distanceExceeded = (local - _dragStart).manhattanLength() - >= style::ConvertScale(kStartDragToFilterThreshold); + const auto outside = _dragging ? false : true; + const auto delta = local - _dragStart; + const auto thresholdY = _pressed->entry()->isPinnedDialog(_filterId) + ? kStartDragToFilterThresholdY + : kStartDragToFilterThresholdX; + const auto distanceExceeded = std::abs(delta.x()) + >= style::ConvertScale(kStartDragToFilterThresholdX) + || std::abs(delta.y()) >= style::ConvertScale(thresholdY); if (!_qdragging && outside && distanceExceeded) { if (_pressed->history()) { @@ -1769,7 +1776,7 @@ void InnerWidget::mouseMoveEvent(QMouseEvent *e) { } void InnerWidget::performDrag() { - if (!_qdragging) { + if (!_qdragging || !session().data().chatsFilters().has()) { return; } const auto history = _qdragging->history(); @@ -1789,6 +1796,13 @@ void InnerWidget::performDrag() { u"application/x-telegram-dialog"_q, std::move(byteArray)); + if (const auto u = history->peer->username(); !u.isEmpty()) { + mimeData->setText(history->peer->session().createInternalLinkFull(u)); + mimeData->setData( + u"application/x-telegram-input-field"_q, + ('@' + u).toUtf8()); + } + const auto &st = st::defaultDialogRow; auto pixmap = QPixmap(Size(st.height * style::DevicePixelRatio())); pixmap.setDevicePixelRatio(style::DevicePixelRatio()); diff --git a/Telegram/SourceFiles/dialogs/dialogs_widget.cpp b/Telegram/SourceFiles/dialogs/dialogs_widget.cpp index 6445df6291..cdbc395a01 100644 --- a/Telegram/SourceFiles/dialogs/dialogs_widget.cpp +++ b/Telegram/SourceFiles/dialogs/dialogs_widget.cpp @@ -27,6 +27,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/history_view_requests_bar.h" #include "history/view/history_view_top_bar_widget.h" #include "boxes/peers/edit_peer_requests_box.h" +#include "boxes/choose_filter_box.h" #include "ui/text/text_utilities.h" #include "ui/widgets/buttons.h" #include "ui/widgets/chat_filters_tabs_strip.h" @@ -612,6 +613,20 @@ Widget::Widget( _search->submits( ) | rpl::on_next([=] { submit(); }, _search->lifetime()); + _search->setMimeDataHook([=]( + not_null data, + Ui::InputField::MimeAction action) { + if (data->hasFormat(u"application/x-telegram-dialog"_q)) { + if (const auto history = HistoryFromMimeData(data, &session())) { + if (action != Ui::InputField::MimeAction::Check) { + controller->searchInChat(history); + } + return true; + } + } + return false; + }); + QObject::connect( _search->rawTextEdit().get(), &QTextEdit::cursorPositionChanged, @@ -634,14 +649,20 @@ Widget::Widget( _cancelSearch->setClickedCallback([=] { cancelSearch({ .jumpBackToSearchedChat = true }); }); + _cancelSearch->setAccessibleName(tr::lng_sr_cancel_search(tr::now)); _jumpToDate->entity()->setClickedCallback([=] { showCalendar(); }); + _jumpToDate->entity()->setAccessibleName( + tr::lng_sr_search_date(tr::now)); _chooseFromUser->entity()->setClickedCallback([=] { showSearchFrom(); }); + _chooseFromUser->entity()->setAccessibleName( + tr::lng_search_messages_from(tr::now)); rpl::single(rpl::empty) | rpl::then( session().domain().local().localPasscodeChanged() ) | rpl::on_next([=] { updateLockUnlockVisibility(); }, lifetime()); const auto lockUnlock = _lockUnlock->entity(); + lockUnlock->setAccessibleName(tr::lng_shortcuts_lock(tr::now)); lockUnlock->setClickedCallback([=] { lockUnlock->setIconOverride( &st::dialogsUnlockIcon, @@ -656,6 +677,7 @@ Widget::Widget( setupStories(); } + _searchForNarrowLayout->setAccessibleName(tr::lng_dlg_filter(tr::now)); _searchForNarrowLayout->setClickedCallback([=] { _search->setFocusFast(); if (_childList) { @@ -1095,6 +1117,7 @@ void Widget::scrollToDefaultChecked(bool verytop) { void Widget::setupScrollUpButton() { _scrollToTop->setClickedCallback([=] { scrollToDefaultChecked(); }); + _scrollToTop->setAccessibleName(tr::lng_sr_scroll_to_top(tr::now)); trackScroll(_scrollToTop); trackScroll(this); updateScrollUpVisibility(); @@ -3435,6 +3458,11 @@ void Widget::updateCancelSearch() { || (!_searchState.inChat && (_searchHasFocus || _searchSuggestionsLocked)); _cancelSearch->toggle(shown, anim::type::normal); + if (_searchState.inChat) { + _cancelSearch->setAccessibleName(shown + ? tr::lng_sr_clear_search(tr::now) + : tr::lng_sr_cancel_search(tr::now)); + } } QString Widget::validateSearchQuery() { @@ -3841,7 +3869,7 @@ void Widget::clearSearchCache(bool clearPosts) { void Widget::showCalendar() { if (_searchState.inChat) { - controller()->showCalendar(_searchState.inChat, QDate()); + controller()->showCalendar({ _searchState.inChat }); } } diff --git a/Telegram/SourceFiles/dialogs/ui/chat_search_in.cpp b/Telegram/SourceFiles/dialogs/ui/chat_search_in.cpp index e426f453b9..f82ebe8baf 100644 --- a/Telegram/SourceFiles/dialogs/ui/chat_search_in.cpp +++ b/Telegram/SourceFiles/dialogs/ui/chat_search_in.cpp @@ -449,6 +449,7 @@ void ChatSearchIn::updateSection( const auto st = &st::dialogsCancelSearchInPeer; section->cancel = std::make_unique(raw, *st); + section->cancel->setAccessibleName(tr::lng_cancel(tr::now)); section->cancel->show(); raw->sizeValue() | rpl::on_next([=](QSize size) { const auto left = size.width() - section->cancel->width(); diff --git a/Telegram/SourceFiles/export/data/export_data_types.cpp b/Telegram/SourceFiles/export/data/export_data_types.cpp index 7af24dd128..cb6a0e391d 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.cpp +++ b/Telegram/SourceFiles/export/data/export_data_types.cpp @@ -303,7 +303,8 @@ std::vector ParseText( return Type::Blockquote; }, [](const MTPDmessageEntityBankCard&) { return Type::BankCard; }, [](const MTPDmessageEntitySpoiler&) { return Type::Spoiler; }, - [](const MTPDmessageEntityCustomEmoji&) { return Type::CustomEmoji; }); + [](const MTPDmessageEntityCustomEmoji&) { return Type::CustomEmoji; }, + [](const MTPDmessageEntityFormattedDate&) { return Type::Unknown; }); part.text = mid(start, length); part.additional = entity.match( [](const MTPDmessageEntityPre &data) { @@ -1857,6 +1858,15 @@ ServiceAction ParseServiceAction( auto content = ActionChangeCreator(); content.newCreatorId = data.vnew_creator_id().v; result.content = content; + }, [&](const MTPDmessageActionNoForwardsToggle &data) { + auto content = ActionNoForwardsToggle(); + content.newValue = (data.vnew_value().type() == mtpc_boolTrue); + result.content = content; + }, [&](const MTPDmessageActionNoForwardsRequest &data) { + auto content = ActionNoForwardsRequest(); + content.expired = data.is_expired(); + content.newValue = (data.vnew_value().type() == mtpc_boolTrue); + result.content = content; }, [](const MTPDmessageActionEmpty &data) {}); return result; } diff --git a/Telegram/SourceFiles/export/data/export_data_types.h b/Telegram/SourceFiles/export/data/export_data_types.h index dc6cb4b8bf..20d08427cc 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.h +++ b/Telegram/SourceFiles/export/data/export_data_types.h @@ -738,6 +738,15 @@ struct ActionSuggestBirthday { Birthday birthday; }; +struct ActionNoForwardsToggle { + bool newValue = false; +}; + +struct ActionNoForwardsRequest { + bool expired = false; + bool newValue = false; +}; + struct ActionNewCreatorPending { UserId newCreatorId = 0; }; @@ -800,6 +809,8 @@ struct ServiceAction { ActionSuggestedPostSuccess, ActionSuggestedPostRefund, ActionSuggestBirthday, + ActionNoForwardsToggle, + ActionNoForwardsRequest, ActionNewCreatorPending, ActionChangeCreator> content; }; diff --git a/Telegram/SourceFiles/export/output/export_output_html.cpp b/Telegram/SourceFiles/export/output/export_output_html.cpp index 15417fa1a2..89f58df707 100644 --- a/Telegram/SourceFiles/export/output/export_output_html.cpp +++ b/Telegram/SourceFiles/export/output/export_output_html.cpp @@ -1553,6 +1553,14 @@ auto HtmlWriter::Wrap::pushMessage( }() + (data.birthday.year() ? (' ' + QByteArray::number(data.birthday.year())) : QByteArray()); + }, [&](const ActionNoForwardsToggle &data) { + return serviceFrom + + (data.newValue + ? " disabled sharing in this chat" + : " enabled sharing in this chat"); + }, [&](const ActionNoForwardsRequest &data) { + return serviceFrom + + " requested to enable sharing in this chat"; }, [&](const ActionNewCreatorPending &data) { return peers.wrapUserName(data.newCreatorId) + " will become the new main admin in 7 days if " diff --git a/Telegram/SourceFiles/export/output/export_output_json.cpp b/Telegram/SourceFiles/export/output/export_output_json.cpp index 434c69ebc9..cddb7fd6d6 100644 --- a/Telegram/SourceFiles/export/output/export_output_json.cpp +++ b/Telegram/SourceFiles/export/output/export_output_json.cpp @@ -743,6 +743,15 @@ QByteArray SerializeMessage( if (const auto year = data.birthday.year()) { push("year", year); } + }, [&](const ActionNoForwardsToggle &data) { + pushActor(); + pushAction("no_forwards_toggle"); + push("new_value", data.newValue); + }, [&](const ActionNoForwardsRequest &data) { + pushActor(); + pushAction("no_forwards_request"); + push("expired", data.expired); + push("new_value", data.newValue); }, [&](const ActionNewCreatorPending &data) { pushActor(); pushAction("new_creator_pending"); diff --git a/Telegram/SourceFiles/export/view/export_view_settings.cpp b/Telegram/SourceFiles/export/view/export_view_settings.cpp index b5b0e8bd6b..85c7061cc0 100644 --- a/Telegram/SourceFiles/export/view/export_view_settings.cpp +++ b/Telegram/SourceFiles/export/view/export_view_settings.cpp @@ -617,11 +617,11 @@ void SettingsWidget::editDateLimit( } })); }; - const auto callback = crl::guard(this, [=](const QDate &date) { + const auto callback = crl::guard(this, [=]( + const QDate &date, + Fn close) { done(base::unixtime::serialize(date.startOfDay())); - if (const auto weak = shared->get()) { - weak->closeBox(); - } + close(); }); auto box = Box(Ui::CalendarBoxArgs{ .month = month, diff --git a/Telegram/SourceFiles/history/admin_log/history_admin_log_filter.cpp b/Telegram/SourceFiles/history/admin_log/history_admin_log_filter.cpp index 9d5222d6eb..27613d8d43 100644 --- a/Telegram/SourceFiles/history/admin_log/history_admin_log_filter.cpp +++ b/Telegram/SourceFiles/history/admin_log/history_admin_log_filter.cpp @@ -34,6 +34,7 @@ EditFlagsDescriptor FilterValueLabels(bool isChannel) { auto members = std::vector