diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000000..495c2b73ea --- /dev/null +++ b/TASKS.md @@ -0,0 +1,402 @@ +# Forum Topics Implementation Tasks + +| Waiting | In Progress | Completed | +|---------|-------------|-----------| +| | | TDLib bindings available | +| | | ForumTopicInfoListener infrastructure | +| | | Topic info caching in Tdlib.java | +| | | Service message handling | +| | | Message topic ID filtering | +| | | Permission checks | +| | | ForumTopicsController - main topics list screen | +| | | ForumTopicView - topic list item view | +| | | Navigation integration (TdlibUi.java) | +| | | Pin/Unpin topics UI | +| | | Close/Reopen topics UI | +| | | Build successful (arm64 + x64 debug APK) +| | | Topic message filtering fix (GetForumTopicHistory) +| | | Topic-specific unread counter fix +| | | Mark messages as read fix (onForumTopicUpdated) +| | | Open topic at first unread position +| | | Cross-device read state sync (GetForumTopic refresh) +| | | Fix server-side read marking (needForceRead delay) +| | | Unread topics count in chat list (forum supergroups) +| | | Fix: Per-chat unread topic count refresh (BetterChatView/VerticalChatView) +| | | Fix: Main chat list unread topic count refresh (ChatsController/TGChat) +| | | Topic icon/avatar display (custom emoji + colored circle fallback) +| | | Transparent background for loaded custom emoji icons +| | | Topic Creation Dialog (CreateForumTopic with FAB button) +| | | Topic Editing Dialog (EditForumTopic - via long-press menu) +| | | Topic Header in Chat (ChatHeaderView shows topic name + chat name) +| | | Topic Notifications Settings (per-topic mute/unmute via long-press menu) +| | | Topic-specific typing indicator (per-topic send/receive) +| | | Tabs layout support (ForumTopicTabsController + hasForumTabs check) +| | | Forum Toggle in Group Settings (ToggleSupergroupIsForum via ProfileController) +| | | Search topics functionality (client-side name filtering + highlighting) +| | | Notification topic separation (shows "Chat > Topic" in notification title) +| | | Fix: Tab-style forums (hasForumTabs) always showing tabs +| | | Fix: "Topic icon changed" message instead of "Topic created" for icon edits +| | | "View as chat" option in ForumTopicsController (ToggleChatViewAsTopics) +| | | Fix: ForumTopicTabsController tabs and menu display (loading placeholder + more menu) +| | | Fix: ForumTopicTabsController "View as chat" navigation (destroyStackItemAt pattern) +| | | Fix: ForumTopicTabsController tabs layout margin (getMenuButtonsWidth override) +| | | "View as topics" option in MessagesController (for switching back from unified chat view) +| | | Fix: "View as topics" direct navigation (avoid stale chat.viewAsTopics issue) +| | | Default forum navigation to topics view (TdlibUi.java - matches official Telegram behavior) +| | | Fix: Topic-specific pinned messages in tabs mode (pass topicId to MessageListManager) +| | | Fix: Search button in ForumTopicTabsController (added search/clear mode support) +| | | Topic actions in tabs mode (mute, close, pin, edit via 3 dots menu) +| | | Fix: New messages appearing in wrong topic tab (updateNewMessage topic filtering) +| | | Fix: Topic mention/reaction counters not updating (onForumTopicUpdated extended) +| | | Fix: Chat list preview showing "Topic created" for non-forum service messages (switch fallthrough bug) +| | | Create topic option in tabs mode (ForumTopicTabsController - via 3 dots menu) +| | | Permission checks for topic actions UI (hide create/edit/pin/close/delete based on user rights) +| | | Group Info access from tabs mode (ForumTopicTabsController - admin-only menu option) +| | | Forum layout toggle (tabs vs list) in ProfileController (ToggleSupergroupIsForum with hasForumTabs) +| | | Fix: Forum layout toggle instant apply (wasForumTabsChanged check in processEditContentChanged) +| | | Fix: Visual flash when entering forum tabs (LoadingController placeholder instead of MessagesController) +| | | Fix: External forum toggle detection (onSupergroupUpdated handler for non-admin users) +| | | Change topic icon feature (GetForumTopicDefaultIcons + EditForumTopic with iconCustomEmojiId) +| | | Fix: Muted topic notifications (client-side filter in TdlibNotificationHelper.updateGroup) +| | | Fix: Closed topic input disabled (isTopicClosedForUser check in MessagesController.updateBottomBar) +| | | Search messages in topics (toggle Topics/Messages search in ForumTopicsController) +| | | Flat message search results with sender avatar and topic icon in corner (ForumTopicView) +| | | Message search pagination (infinite scroll with nextFromMessageId in ForumTopicsController) +| | | Filter message search results by topic (FAB button with multi-select checkboxes) +| | | Fix: Preserve search results when navigating back from topic (allowLeavingSearchMode override) +| | | Fix: Topic filter missing old messages (multi-page preloading + auto-retry) +| | | Topic filter dialog with proper icons (TopicIconModifier with colored circles + custom emoji) +| | | Fix: Settings popup Done/Cancel buttons ripple effect (use ?android:attr/colorControlHighlight for theme-adaptive ripple) +| | | Message search loading indicator (ClearButton spinner in search bar instead of centered ProgressComponentView) +| | | Fix: Topic filter dialog icon positioning (LEFT_OFFSET_DP 68β†’18dp to place icons in left padding area) +| | | Fix: ForumTopicView emoji rendering (use Text class instead of canvas.drawText for proper emoji support) +| | | Fix: ForumTopicView icon-based indicators (mute/lock icons instead of emoji prefixes) +| | | Fix: ForumTopicView text width calculation (prevent overlap with time/counters) +| | | Fix: ForumTopicsController header margin (prevent title overlap with menu buttons) +| | | Fix: Forums with tabs always open in tabs view (TdlibUi.java hasForumTabs condition) +| | | Fix: View as chat navigation (direct MessagesController instead of openChat) +| | | Star/Paid reactions support (TdExt.kt, TGStickerObj, TGReaction, TGReactions, Tdlib.java) +| | | Fix: Premium bot crash on Buy button (TGInlineKeyboard null check + payment form handling) +| | | Fix: Windows file lock issue (kotlin.compiler.execution.strategy=in-process in gradle.properties) +| | | View Forum navigation from topic (btn_viewForum menu option when viewing topic via message link) +| | | Stories Settings screen (SettingsStoriesController - new settings section) +| | | Customizable story ring colors (StoryColorPickerController - 1-3 color gradient picker) +| | | Optional "Add Story" button border (SETTING_FLAG_SHOW_ADD_STORY_BORDER) +| | | Story bar as RecyclerView item (scrolls with chat list instead of overlay) + +## Implementation Notes + +### Files Created +- `app/src/main/java/org/thunderdog/challegram/ui/ForumTopicsController.java` - Main controller for forum topics list +- `app/src/main/java/org/thunderdog/challegram/ui/ForumTopicView.java` - Custom view for topic items +- `app/src/main/java/org/thunderdog/challegram/ui/ForumTopicTabsController.java` - ViewPager-based tabs controller for forum topics (used when hasForumTabs is enabled) +- `app/src/main/java/org/thunderdog/challegram/util/TopicIconModifier.java` - DrawModifier for rendering topic icons (colored circles + custom emoji) in list items + +### Files Modified +- `app/src/main/java/org/thunderdog/challegram/telegram/TdlibUi.java` - Added forum navigation hook (lines 2117-2134) +- `app/src/main/java/org/thunderdog/challegram/component/chat/MessagesLoader.java` - Added forum topic history loading using GetForumTopicHistory (lines 1154-1158) +- `app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java` - Added forumTopic field, unread counter fix (updateCounters), and onForumTopicUpdated override for read state handling +- `app/src/main/java/org/thunderdog/challegram/component/chat/MessagesManager.java` - Fixed needForceRead to delay read marking for forum topics until user scrolls (lines 782-792) +- `app/src/main/java/org/thunderdog/challegram/telegram/Tdlib.java` - Added forum unread topic count caching and methods (forumUnreadTopicCount, fetchForumUnreadTopicCount, updateForumTopicUnreadCount) +- `app/src/main/java/org/thunderdog/challegram/telegram/TdlibListeners.java` - Added updateForumUnreadTopicCount listener method +- `app/src/main/java/org/thunderdog/challegram/telegram/ChatListener.java` - Added onForumUnreadTopicCountChanged callback +- `app/src/main/java/org/thunderdog/challegram/data/TGChat.java` - Modified getUnreadCount() to return unread topic count for forum chats +- `app/src/main/java/org/thunderdog/challegram/widget/BetterChatView.java` - Added onForumUnreadTopicCountChanged handler +- `app/src/main/java/org/thunderdog/challegram/widget/VerticalChatView.java` - Added onForumUnreadTopicCountChanged handler +- `app/src/main/java/org/thunderdog/challegram/data/TGChat.java` - Added updateForumUnreadTopicCount() method +- `app/src/main/java/org/thunderdog/challegram/component/dialogs/ChatsAdapter.java` - Added updateForumUnreadTopicCount() method +- `app/src/main/java/org/thunderdog/challegram/v/ChatsRecyclerView.java` - Added updateForumUnreadTopicCount() method +- `app/src/main/java/org/thunderdog/challegram/ui/ChatsController.java` - Added onForumUnreadTopicCountChanged callback handler +- `app/src/main/res/values/ids.xml` - Added controller_forumTopics and button IDs +- `app/src/main/res/values/strings.xml` - Added topic-related strings +- `app/src/main/java/org/thunderdog/challegram/component/chat/ChatHeaderView.java` - Added forum topic header support (topic name as title, chat name as subtitle) +- `app/src/main/java/org/thunderdog/challegram/ui/ProfileController.java` - Added forum toggle for supergroup owners (ToggleSupergroupIsForum) +- `app/src/main/java/org/thunderdog/challegram/telegram/ForumTopicInfoListener.java` - Extended onForumTopicUpdated to include unreadMentionCount and unreadReactionCount + +### Current Status: Build Successful + Tested +- arm64 APK: `app/build/outputs/apk/arm64/debug/TGX-Example-0.28.2.1778-arm64-v8a-debug.apk` +- x64 APK: `app/build/outputs/apk/x64/debug/TGX-Example-0.28.2.1778-x64-debug.apk` + +Running on emulator (Medium_Phone_API_36.1). Each topic now shows only its own messages. + +### TDLib Functions Used +- `GetForumTopics` - Fetch topics list (implemented in loadTopics/loadMoreTopics) +- `GetForumTopicHistory` - Load messages for a specific topic (fixed in MessagesLoader.java) +- `ToggleForumTopicIsClosed` - Close/reopen (implemented) +- `ToggleForumTopicIsPinned` - Pin/unpin (implemented) +- `DeleteForumTopic` - Delete topic (implemented) +- `CreateForumTopic` - Create new topic (FAB button + dialog in ForumTopicsController) +- `EditForumTopic` - Edit topic name (long-press menu in ForumTopicsController) +- `SetForumTopicNotificationSettings` - Per-topic mute/unmute (long-press menu in ForumTopicsController) +- `ToggleSupergroupIsForum` - Enable/disable forum topics mode (toggle in ProfileController group settings) +- `ToggleChatViewAsTopics` - Toggle between topics view and unified chat view (more menu in ForumTopicsController) +- `SearchChatMessages` - Search messages in forum chat, group by topicId for message search mode + +### Future Enhancements (TODO) +- [x] Tabs layout support (`hasForumTabs`) - Show topics as horizontal tabs when admin enables "Tabs" layout +- [x] User typing in topics - Show typing indicator per-topic instead of per-chat + +All major forum topics features have been implemented. + +--- + +## Bug Fixes + +### InternalLinkTypeInvoice Crash Fix +Fixed ClassCastException when opening invoice links. The crash occurred because `InternalLinkTypeInvoice` was falling through to `InternalLinkTypeBuyStars` case in the switch statement, causing an invalid cast. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/telegram/TdlibUi.java`: + - Separated `InternalLinkTypeInvoice` from `InternalLinkTypeBuyStars` cases + - Added new `openPaymentForm(TdlibDelegate, String invoiceName, ...)` method + - Invoice links now properly open payment forms via `GetPaymentForm` API + +### MessageGift Support +Added support for the `MessageGift` message type which was previously showing as "Unsupported message". + +**Files Created:** +- `app/src/main/java/org/thunderdog/challegram/data/TGMessageGiftRegular.java` - Handler for regular gift messages (extends TGMessageGiveawayBase) + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/data/TGMessage.java`: + - Added case for `MessageGift.CONSTRUCTOR` β†’ `TGMessageGiftRegular` + - Removed `MessageGift` from unsupported message types list +- `app/src/main/res/values/strings.xml` - Added gift-related strings: + - GiftReceived, GiftSent, GiftConverted, GiftUpgraded, GiftRefunded + - ViewGift, xGiftValue, xGiftCanBeSold + +### Visual HSV Color Picker +Replaced hex keyboard input with visual HSV color picker for story ring color customization. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/ui/StoryColorPickerController.java`: + - Added `ColorPickerPopupView` inner class with: + - Saturation/Value gradient square + - Hue rainbow bar + - Color preview circle + - Cancel/Done buttons + - Touch handling for dragging color selection + +### Choose Gift Recipient Button Fix +Fixed "Choose Gift Recipient" keyboard button (and similar bot buttons) doing nothing on click. + +**Root Cause:** `CommandKeyboardLayout.onClick` was missing handlers for `KeyboardButtonTypeRequestUsers` and `KeyboardButtonTypeRequestChat` button types. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/component/chat/CommandKeyboardLayout.java`: + - Added cases for `KeyboardButtonTypeRequestUsers` and `KeyboardButtonTypeRequestChat` + - Added `onRequestUsers()` and `onRequestChat()` to `Callback` interface +- `app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java`: + - Implemented `onRequestUsers()` - opens contact picker, then calls `ShareUsersWithBot` API + - Implemented `onRequestChat()` - shows "not yet supported" (chat picker not implemented) + +**TDLib Function Used:** +- `ShareUsersWithBot(chatId, messageId, buttonId, sharedUserIds, onlyCheck)` - shares selected users with the bot after pressing a `KeyboardButtonTypeRequestUsers` button + +### Contact Picker Navigation Fix +Fixed contact picker not navigating back after selecting a contact for "Choose Gift Recipient" button. + +**Root Cause:** `ContactsController.onFoundChatClick()` wasn't calling `navigateBack()` after delegate callback. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/ui/ContactsController.java`: + - Added `navigateBack()` call after `delegate.onSenderPick()` returns true + +### MessageUsersShared / MessageChatShared Support +Added support for `MessageUsersShared` and `MessageChatShared` service message types which were showing as "Unsupported message". + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/data/TGMessageService.java`: + - Added constructor for `MessageUsersShared` - shows "You shared [user name]" + - Added constructor for `MessageChatShared` - shows "You shared [chat name]" +- `app/src/main/java/org/thunderdog/challegram/data/TGMessage.java`: + - Added cases for `MessageUsersShared` and `MessageChatShared` + - Removed from unsupported message types list +- `app/src/main/res/values/strings.xml`: + - Added `YouSharedUser`, `YouSharedUsers`, `YouSharedChat` strings + +### User Sharing Confirmation Toast +Added toast notification when sharing user with bot to provide UX feedback. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java`: + - Added toast showing "You shared [user name]" after successful ShareUsersWithBot call + +### Payment Card Input Validation & Formatting +Fixed payment card input fields lacking proper validation and formatting. + +**Issues Fixed:** +- Card number, expiry, CVC fields now show numeric keyboard +- Card number auto-formats as `XXXX XXXX XXXX XXXX` +- Expiry date auto-formats as `MM/YY` +- CVC limited to 3-4 digits +- Card holder shows text keyboard with auto-capitalization +- Cannot type letters/symbols in numeric fields + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/ui/PaymentFormController.java`: + - Added imports for `Editable`, `InputFilter`, `InputType`, `TextWatcher` + - Overrode `modifyEditText()` in adapter to configure each field: + - Card number: `TYPE_CLASS_PHONE` + custom filter (digits/spaces) + formatting TextWatcher + - Expiry: `TYPE_CLASS_PHONE` + custom filter (digits/slash) + formatting TextWatcher + - CVC: `TYPE_CLASS_NUMBER` + max length 4 + - Card holder: `TYPE_CLASS_TEXT | TYPE_TEXT_FLAG_CAP_CHARACTERS` + +### Paid Reaction Crash Fix +Fixed crash when opening reactions selector with paid (star) reactions. + +**Root Cause:** `TGReaction.newCenterAnimationSicker()` and `newStaticIconSicker()` didn't handle paid reactions - they fell through to code that accessed null `customReaction` field. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/data/TGReaction.java`: + - Added `isPaid` check to `newStaticIconSicker()` - returns cached or new paid star sticker + - Added `isPaid` check to `newCenterAnimationSicker()` - returns cached or new paid star sticker + +### Archive Pin/Unpin Overlap with Stories Fix +Fixed archive row scroll handling using hardcoded positions that didn't account for story bar. + +**Root Cause:** The archive collapse/expand scroll listener used hardcoded positions (0, 1) assuming archive was always at position 0. With story bar at position 0, archive is at position 1, causing incorrect scroll behavior and visual overlap. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/ui/ChatsController.java`: + - Updated `onScrollStateChanged` to use dynamic `archivePosition` from `adapter.getArchiveItemPosition()` + - Updated `onScrolled` to check against dynamic archive position instead of hardcoded 0 + - Updated `getLiveLocationPosition()` to account for story bar offset + - Fixed ItemDecoration to not apply negative collapse offset when story bar is present + - Changed story bar loading to only add to adapter when content is available + - Added `scrollToPosition(0)` when story bar is first added to ensure visibility on app start +- `app/src/main/java/org/thunderdog/challegram/widget/StoryBarView.java`: + - Changed initial visibility from GONE to VISIBLE (adapter now controls presence) + - Removed GONE state from updateVisibility() - adapter handles add/remove + +### Paid Reaction Empty Icon Fix +Fixed paid/star reactions showing as empty (no icon visible) on channels. + +**Root Cause:** `StickerSmallView.setSticker()` didn't handle stickers with `isDefaultPremiumStar()` flag. When a paid reaction sticker was set, `getImage()` and `getPreviewAnimation()` returned null (no actual sticker file), so nothing was drawn. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/component/sticker/StickerSmallView.java`: + - Added check for `sticker.isDefaultPremiumStar()` in `setSticker()` + - When true, sets `premiumStarDrawable` from `R.drawable.baseline_premium_star_28` + - Clears `premiumStarDrawable` for normal stickers to avoid stale state +- `app/src/main/java/org/thunderdog/challegram/data/TD.java`: + - Added error translation for "BALANCE_TOO_LOW" and "not enough stars" β†’ `PaidReactionInsufficientStars` +- `app/src/main/res/values/strings.xml`: + - Added `PaidReactionInsufficientStars` string with user-friendly message + +### ForumTopicView Custom Emoji Crash Fix +Fixed crash when opening forum topics with custom emoji in message preview. + +**Root Cause:** `ForumTopicView.buildTextLayouts()` was passing `FormattedText` (which may contain custom emoji) to `Text.Builder` without a `TextMediaListener`. When `Text.newOrExistingMedia()` is called without a listener, it throws `IllegalStateException`. + +**Solution:** Implemented proper custom emoji support in ForumTopicView: +- Made ForumTopicView implement `Text.TextMediaListener` +- Added `textMediaReceiver` (ComplexReceiver) for loading custom emoji +- Pass `this` as textMediaListener when building Text with FormattedText +- Call `requestTextMedia()` after building displayPreview +- Pass textMediaReceiver to `displayPreview.draw()` for rendering custom emoji + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/ui/ForumTopicView.java`: + - Implemented `Text.TextMediaListener` interface + - Added `textMediaReceiver` field initialized in constructor + - Added `onInvalidateTextMedia()` callback for view invalidation + - Added `requestTextMedia()` helper method + - Updated `buildTextLayouts()` to pass `this` as listener + - Updated `displayPreview.draw()` to pass textMediaReceiver + - Updated attach/detach/destroy to handle textMediaReceiver lifecycle + +### Forum Preview Swipe-Up Fix +Fixed long-pressing forum chat in chat list and swiping up opening old chat interface instead of ForumTopicsController. + +**Root Cause:** `BaseView.openChatPreviewAsync()` always created `MessagesController` for preview, even for forums. + +**Solution:** Added check for forum chats - skip preview and fall through to normal long-press menu behavior. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/widget/BaseView.java`: + - Added `tdlib.isForum(chat.id)` check in `onLongPressRequestedAt()` + - Forums now skip preview mode + +### Star Reaction Icon Size Fix +Fixed star icon in reaction bubbles being too large (overflowing its border). + +**Root Cause:** `Drawables.draw()` ignores `setBounds()` and draws at the drawable's intrinsic size. The star was drawn at 24dp regardless of the reaction bubble bounds. + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/data/TGReactions.java`: + - Changed `drawReceiver()` to use `drawable.draw(canvas)` directly instead of `Drawables.draw()` + - Now properly respects the bounds set with `setBounds(l, t, r, b)` + - Star icon scales to fit the reaction bubble correctly + +### Forum Topic UI Improvements (from logopek/reX) +Applied topic-related fixes from https://github.com/logopek/reX repository. + +**Changes Applied:** + +1. **TdlibUi.java - Forums with tabs always open in tabs view** + - Changed condition from `chat.viewAsTopics` to `chat.viewAsTopics || hasForumTabs` + - Forums with tabs layout now correctly open in tabs mode by default + +2. **ForumTopicView.java - Icon-based indicators instead of emojis** + - Removed emoji prefixes from topic title (πŸ”’, πŸ“Œ, πŸ”•) + - Added mute icon (deproko_baseline_notifications_off_24) drawn after title text (14dp, 4dp gap) + - Added lock icon (deproko_baseline_lock_24) where counter normally is when topic is closed and has no unread (18dp) + - Improved text width calculation to reserve space for time, status icons, counters, and mute icon + - Prevents text overlap with icons/counters on right side + +3. **ForumTopicsController.java - Header margin fix** + - Added `headerCell.setInnerMargins(56dp, 104dp)` to prevent chat title overlapping menu buttons + - Right margin: 48dp (more button) + 48dp (search button) + 8dp (padding) = 104dp + +4. **ForumTopicsController.java - View as chat navigation fix** + - Changed "View as chat" to create MessagesController directly instead of using openChat() + - Prevents forum from re-opening in tabs mode after switching to unified view + - Uses focus listener to remove ForumTopicsController from navigation stack + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/telegram/TdlibUi.java` (line 2139) +- `app/src/main/java/org/thunderdog/challegram/ui/ForumTopicView.java` (lines 222-223, 329-359, 563-623) +- `app/src/main/java/org/thunderdog/challegram/ui/ForumTopicsController.java` (lines 599-601, 708-724) + +### Forum Topic List Not Updating After Sending Reply +Fixed topic list not showing latest message after sending a reply in a forum topic. The reply would be sent successfully (visible inside the topic), but returning to the topic list wouldn't show the updated preview. + +**Root Cause:** When a user sends a message to a forum topic: +1. `updateNewMessage` fires with a pending message where `message.topicId` is null +2. `ForumTopicsController.onNewMessage()` checks `if (topicId == 0) return;` and exits early +3. When message send succeeds, `updateMessageSendSucceeded` fires with the completed message (which has `topicId` populated) +4. `ForumTopicsController` didn't implement `onMessageSendSucceeded()`, so the topic list was never updated + +**Solution:** Implement `onMessageSendSucceeded` in `ForumTopicsController` that routes to `onNewMessage()` with the completed message (which now has `topicId` properly populated). + +**Files Modified:** +- `app/src/main/java/org/thunderdog/challegram/ui/ForumTopicsController.java`: + - Added `onMessageSendSucceeded(TdApi.Message message, long oldMessageId)` override + - Routes to `onNewMessage(message)` to update the topic list + +--- + +## DevOps & Tooling + +### MantisBT MCP Server Integration +Created MCP server for integrating with MantisBT bug tracker at https://bugtracker.hikaro.space + +**Files Created:** +- `C:\Users\japananimetime\mcp-servers\mantisbt\package.json` - Node.js package configuration +- `C:\Users\japananimetime\mcp-servers\mantisbt\index.js` - MCP server with MantisBT REST API integration +- `C:\Users\japananimetime\mcp-servers\mantisbt\README.md` - Setup documentation +- `F:\Telegram-X\.mcp.json` - MCP server configuration for this project + +**Available MCP Tools:** +- `mantis_list_projects` - List all projects +- `mantis_get_project` - Get project details +- `mantis_list_issues` - List issues with filters +- `mantis_get_issue` - Get issue details +- `mantis_create_issue` - Create new issue +- `mantis_update_issue` - Update existing issue +- `mantis_add_note` - Add comment to issue +- `mantis_delete_issue` - Delete issue +- `mantis_list_users` - List users +- `mantis_get_config` - Get MantisBT configuration +- `mantis_list_filters` - List saved filters diff --git a/app/src/main/java/org/thunderdog/challegram/MainActivity.java b/app/src/main/java/org/thunderdog/challegram/MainActivity.java index 4c0d29c324..5ff8144768 100644 --- a/app/src/main/java/org/thunderdog/challegram/MainActivity.java +++ b/app/src/main/java/org/thunderdog/challegram/MainActivity.java @@ -783,8 +783,9 @@ public void onPasscodeShowing (BaseActivity context, boolean isShowing) { } } long messageId = intent.getLongExtra("message_id", 0); + long messageThreadId = intent.getLongExtra("message_thread_id", 0); if (accountId != TdlibAccount.NO_ID && chatId != 0) { - openMessagesController(accountId, chatId, messageId); + openMessagesController(accountId, chatId, messageId, messageThreadId); return true; } else { Log.e("Cannot open chat, no information found: %s", intent); @@ -1384,7 +1385,7 @@ private void openMainController (int accountId) { } } - private void openMessagesController (int accountId, long chatId, long specificMessageId) { + private void openMessagesController (int accountId, long chatId, long specificMessageId, long messageThreadId) { final Tdlib tdlib = TdlibManager.instanceForAccountId(accountId).account(accountId).tdlib(); tdlib.awaitInitialization(() -> { tdlib.incrementUiReferenceCount(); @@ -1393,6 +1394,10 @@ private void openMessagesController (int accountId, long chatId, long specificMe final TdlibUi.ChatOpenParameters params = new TdlibUi.ChatOpenParameters().onDone(tdlib::decrementUiReferenceCount); if (specificMessageId != 0) params.highlightMessage(new MessageId(chatId, specificMessageId)); + // Handle forum topic opening + if (messageThreadId != 0) { + params.messageTopic(new TdApi.MessageTopicForum((int) messageThreadId)); + } tdlib.ui().openChat(context, chatId, params); }); }); diff --git a/app/src/main/java/org/thunderdog/challegram/component/chat/ChatHeaderView.java b/app/src/main/java/org/thunderdog/challegram/component/chat/ChatHeaderView.java index 3504390dd5..074652b670 100644 --- a/app/src/main/java/org/thunderdog/challegram/component/chat/ChatHeaderView.java +++ b/app/src/main/java/org/thunderdog/challegram/component/chat/ChatHeaderView.java @@ -15,30 +15,48 @@ package org.thunderdog.challegram.component.chat; import android.content.Context; +import android.graphics.Canvas; import android.view.MotionEvent; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.drinkless.tdlib.TdApi; +import org.thunderdog.challegram.data.AvatarPlaceholder; import org.thunderdog.challegram.data.ThreadInfo; import org.thunderdog.challegram.loader.AvatarReceiver; +import org.thunderdog.challegram.loader.ComplexReceiver; +import org.thunderdog.challegram.loader.ImageFile; +import org.thunderdog.challegram.loader.gif.GifFile; import org.thunderdog.challegram.navigation.ComplexHeaderView; import org.thunderdog.challegram.navigation.HeaderView; import org.thunderdog.challegram.navigation.ViewController; import org.thunderdog.challegram.telegram.Tdlib; +import org.thunderdog.challegram.telegram.TdlibAccentColor; +import org.thunderdog.challegram.telegram.TdlibEmojiManager; +import org.thunderdog.challegram.theme.Theme; import org.thunderdog.challegram.theme.ThemeDeprecated; +import org.thunderdog.challegram.tool.Paints; import org.thunderdog.challegram.tool.Screen; +import org.thunderdog.challegram.util.text.Letters; import me.vkryl.core.StringUtils; import tgx.td.ChatId; -public class ChatHeaderView extends ComplexHeaderView { +public class ChatHeaderView extends ComplexHeaderView implements TdlibEmojiManager.Watcher { public interface Callback { void onChatHeaderClick (); } private Callback callback; + // Topic icon support + private long topicCustomEmojiId; + private TdlibEmojiManager.Entry topicCustomEmoji; + private ComplexReceiver topicIconReceiver; + private ImageFile topicImageFile; + private GifFile topicGifFile; + public ChatHeaderView (Context context, Tdlib tdlib, @Nullable ViewController parent) { super(context, tdlib, parent); setPhotoOpenDisabled(true); @@ -50,6 +68,81 @@ public ChatHeaderView (Context context, Tdlib tdlib, @Nullable ViewController setUseDefaultClickListener(true); setBackgroundResource(ThemeDeprecated.headerSelector()); setInnerMargins(Screen.dp(56f), Screen.dp(49f)); + topicIconReceiver = new ComplexReceiver(this); + } + + @Override + protected void onAttachedToWindow () { + super.onAttachedToWindow(); + topicIconReceiver.attach(); + } + + @Override + protected void onDetachedFromWindow () { + super.onDetachedFromWindow(); + topicIconReceiver.detach(); + } + + @Override + public void onCustomEmojiLoaded (TdlibEmojiManager context, TdlibEmojiManager.Entry entry) { + if (entry.customEmojiId == topicCustomEmojiId) { + topicCustomEmoji = entry; + loadTopicEmojiFiles(); + invalidate(); + } + } + + private void loadTopicEmojiFiles () { + if (topicCustomEmoji == null || topicCustomEmoji.isNotFound()) { + topicImageFile = null; + topicGifFile = null; + topicIconReceiver.clear(); + return; + } + + TdApi.Sticker sticker = topicCustomEmoji.value; + if (sticker == null) { + topicImageFile = null; + topicGifFile = null; + topicIconReceiver.clear(); + return; + } + + int size = Screen.dp(40f); // Avatar size + // Check sticker format to determine file type + // WebP stickers use ImageFile, TGS and WEBM use GifFile + switch (sticker.format.getConstructor()) { + case TdApi.StickerFormatWebp.CONSTRUCTOR: { + topicImageFile = new ImageFile(tdlib, sticker.sticker); + topicImageFile.setSize(size); + topicImageFile.setScaleType(ImageFile.CENTER_CROP); + topicGifFile = null; + topicIconReceiver.getImageReceiver(0).requestFile(topicImageFile); + break; + } + case TdApi.StickerFormatTgs.CONSTRUCTOR: + case TdApi.StickerFormatWebm.CONSTRUCTOR: { + topicGifFile = new GifFile(tdlib, sticker); + topicGifFile.setOptimizationMode(GifFile.OptimizationMode.EMOJI); + topicGifFile.setRequestedSize(size); + topicImageFile = null; + topicIconReceiver.getGifReceiver(0).requestFile(topicGifFile); + break; + } + } + } + + private void clearTopicEmoji () { + if (topicCustomEmojiId != 0 && topicCustomEmoji == null && tdlib != null) { + tdlib.emoji().forgetWatcher(topicCustomEmojiId, this); + } + topicCustomEmojiId = 0; + topicCustomEmoji = null; + topicImageFile = null; + topicGifFile = null; + if (topicIconReceiver != null) { + topicIconReceiver.clear(); + } } private CharSequence forcedSubtitle; @@ -91,20 +184,68 @@ public boolean onTouchEvent (MotionEvent e) { } public void setChat (Tdlib tdlib, TdApi.Chat chat, @Nullable ThreadInfo messageThread) { + setChat(tdlib, chat, messageThread, null); + } + + public void setChat (Tdlib tdlib, TdApi.Chat chat, @Nullable ThreadInfo messageThread, @Nullable TdApi.ForumTopic forumTopic) { this.tdlib = tdlib; + // Clear previous topic emoji + clearTopicEmoji(); + if (chat == null) { setText("Debug controller", "nobody should find this view"); return; } - getAvatarReceiver().requestChat(tdlib, chat.id, AvatarReceiver.Options.FULL_SIZE); + // For forum topics, show topic icon instead of chat avatar + if (forumTopic != null) { + TdApi.ForumTopicIcon icon = forumTopic.info.icon; + int topicColor = icon != null ? icon.color : 0x6FB9F0; + // Ensure color has alpha + if (topicColor < 0x01000000) { + topicColor = 0xFF000000 | topicColor; + } + + // Check if topic has custom emoji icon + if (icon != null && icon.customEmojiId != 0) { + // Request custom emoji + topicCustomEmojiId = icon.customEmojiId; + topicCustomEmoji = tdlib.emoji().findOrPostponeRequest(topicCustomEmojiId, this); + if (topicCustomEmoji != null) { + loadTopicEmojiFiles(); + } + // Use placeholder while loading or as fallback + String letter = forumTopic.info.forumTopicId == 1 ? "#" : + (!StringUtils.isEmpty(forumTopic.info.name) ? forumTopic.info.name.substring(0, 1).toUpperCase() : "?"); + TdlibAccentColor accentColor = new TdlibAccentColor(topicColor); + AvatarPlaceholder.Metadata metadata = new AvatarPlaceholder.Metadata(accentColor, new Letters(letter)); + getAvatarReceiver().requestPlaceholder(tdlib, metadata, AvatarReceiver.Options.FULL_SIZE); + } else { + // No custom emoji - show colored placeholder with letter/hash + String letter = forumTopic.info.forumTopicId == 1 ? "#" : + (!StringUtils.isEmpty(forumTopic.info.name) ? forumTopic.info.name.substring(0, 1).toUpperCase() : "?"); + TdlibAccentColor accentColor = new TdlibAccentColor(topicColor); + AvatarPlaceholder.Metadata metadata = new AvatarPlaceholder.Metadata(accentColor, new Letters(letter)); + getAvatarReceiver().requestPlaceholder(tdlib, metadata, AvatarReceiver.Options.FULL_SIZE); + } + } else { + getAvatarReceiver().requestChat(tdlib, chat.id, AvatarReceiver.Options.FULL_SIZE); + } + setShowVerify(tdlib.chatVerified(chat)); setShowScam(tdlib.chatScam(chat)); setShowFake(tdlib.chatFake(chat)); setShowMute(tdlib.chatNeedsMuteIcon(chat)); setShowLock(ChatId.isSecret(chat.id)); - if (messageThread != null) { + if (forumTopic != null) { + // Forum topic: show topic name as title, chat name as subtitle + setEmojiStatus(null); + setText(forumTopic.info.name, !StringUtils.isEmpty(forcedSubtitle) ? forcedSubtitle : tdlib.chatTitle(chat)); + setExpandedSubtitle(null); + setUseRedHighlight(false); + attachChatStatus(chat.id, new TdApi.MessageTopicForum(forumTopic.info.forumTopicId)); + } else if (messageThread != null) { setEmojiStatus(null); setText(messageThread.chatHeaderTitle(), !StringUtils.isEmpty(forcedSubtitle) ? forcedSubtitle : messageThread.chatHeaderSubtitle()); setExpandedSubtitle(null); @@ -129,4 +270,32 @@ public void updateUserStatus (TdApi.Chat chat) { setExpandedSubtitle(tdlib.status().chatStatusExpanded(chat)); } } + + @Override + protected void onDraw (@NonNull Canvas c) { + super.onDraw(c); + + // Draw topic custom emoji icon on top of avatar if loaded + if (topicCustomEmojiId != 0 && topicCustomEmoji != null && !topicCustomEmoji.isNotFound()) { + AvatarReceiver avatarReceiver = getAvatarReceiver(); + int left = avatarReceiver.getLeft(); + int top = avatarReceiver.getTop(); + int right = avatarReceiver.getRight(); + int bottom = avatarReceiver.getBottom(); + float centerX = (left + right) / 2f; + float centerY = (top + bottom) / 2f; + float radius = (right - left) / 2f; + + // Cover the placeholder with header background before drawing emoji + c.drawCircle(centerX, centerY, radius, Paints.fillingPaint(Theme.headerColor())); + + if (topicImageFile != null) { + topicIconReceiver.getImageReceiver(0).setBounds(left, top, right, bottom); + topicIconReceiver.getImageReceiver(0).draw(c); + } else if (topicGifFile != null) { + topicIconReceiver.getGifReceiver(0).setBounds(left, top, right, bottom); + topicIconReceiver.getGifReceiver(0).draw(c); + } + } + } } diff --git a/app/src/main/java/org/thunderdog/challegram/component/chat/MessagesLoader.java b/app/src/main/java/org/thunderdog/challegram/component/chat/MessagesLoader.java index 740981916c..6aed32db1b 100644 --- a/app/src/main/java/org/thunderdog/challegram/component/chat/MessagesLoader.java +++ b/app/src/main/java/org/thunderdog/challegram/component/chat/MessagesLoader.java @@ -1151,6 +1151,11 @@ private void load (final MessageId fromMessageId, final int offset, final int li loadingLocal = false; Log.ensureReturnType(TdApi.GetMessageThreadHistory.class, TdApi.Messages.class); function = new TdApi.GetMessageThreadHistory(sourceChatId, messageThread.getOldestMessageId(), (lastFromMessageId = fromMessageId).getMessageId(), lastOffset = offset, lastLimit = limit); + } else if (topicId != null && topicId.getConstructor() == TdApi.MessageTopicForum.CONSTRUCTOR) { + loadingLocal = false; + Log.ensureReturnType(TdApi.GetForumTopicHistory.class, TdApi.Messages.class); + int forumTopicId = ((TdApi.MessageTopicForum) topicId).forumTopicId; + function = new TdApi.GetForumTopicHistory(sourceChatId, forumTopicId, (lastFromMessageId = fromMessageId).getMessageId(), lastOffset = offset, lastLimit = limit); } else { Log.ensureReturnType(TdApi.GetChatHistory.class, TdApi.Messages.class); function = new TdApi.GetChatHistory(sourceChatId, (lastFromMessageId = fromMessageId).getMessageId(), lastOffset = offset, lastLimit = limit, loadingLocal); diff --git a/app/src/main/java/org/thunderdog/challegram/component/chat/MessagesManager.java b/app/src/main/java/org/thunderdog/challegram/component/chat/MessagesManager.java index c214be3901..3cf7d9cc21 100644 --- a/app/src/main/java/org/thunderdog/challegram/component/chat/MessagesManager.java +++ b/app/src/main/java/org/thunderdog/challegram/component/chat/MessagesManager.java @@ -601,7 +601,7 @@ public void openSearch (TdApi.Chat chat, String query, TdApi.MessageSender sende loader.setSearchParameters(query, sender, filter); adapter.setChatType(chat.type); if (filter != null && Td.isPinnedFilter(filter)) { - initPinned(chat.id, 1, 1); + initPinned(chat.id, null, 1, 1); } if (highlightMessageId != null) { loadFromMessage(highlightMessageId, highlightMode, true); @@ -692,8 +692,8 @@ private void setPinnedMessagesAvailable (boolean areAvailable) { } } - private void initPinned (long chatId, int initialLoadCount, int loadCount) { - this.pinnedMessages = new MessageListManager(tdlib, initialLoadCount, loadCount, pinnedMessageListener, chatId, 0, null, null, null, new TdApi.SearchMessagesFilterPinned()); + private void initPinned (long chatId, @Nullable TdApi.MessageTopic topicId, int initialLoadCount, int loadCount) { + this.pinnedMessages = new MessageListManager(tdlib, initialLoadCount, loadCount, pinnedMessageListener, chatId, 0, topicId, null, null, new TdApi.SearchMessagesFilterPinned()); this.pinnedMessages.addMaxMessageIdListener(pinnedMessageAvailabilityChangeListener); this.pinnedMessages.addChangeListener(new MessageListManager.ChangeListener() { @Override @@ -718,7 +718,7 @@ public void openChat (TdApi.Chat chat, @Nullable ThreadInfo messageThread, @Null // readOneShot = true; } if (chat.id != 0 && messageThread == null && !areScheduled && needPinnedMessages) { - initPinned(chat.id, 10, 50); + initPinned(chat.id, topicId, 10, 50); } else { this.pinnedMessages = null; } @@ -781,6 +781,13 @@ public boolean onMessageViewed (TdlibMessageViewer.Viewport viewport, View view, @Override public boolean needForceRead (TdlibMessageViewer.Viewport viewport) { + // For forum topics opened at first unread position, don't force-read on initial load + // to avoid marking all visible messages as read on the server + TdApi.MessageTopic topicId = loader.getTopicId(); + if (topicId != null && topicId.getConstructor() == TdApi.MessageTopicForum.CONSTRUCTOR) { + // Only force-read after user has scrolled + return canRead() && wasScrollByUser; + } return canRead(); } @@ -1838,8 +1845,11 @@ private void updateNewMessage (TGMessage message) { case MessagesLoader.SPECIAL_MODE_SEARCH: return; } - TdApi.MessageTopic topicId = loader.getMessageTopicId(); - if (!Td.matchesTopic(message.getMessageTopicId(), topicId)) { + // Check both messageThread topic (for comment threads) and forum topicId (for forum tabs) + TdApi.MessageTopic messageThreadTopicId = loader.getMessageTopicId(); + TdApi.MessageTopic forumTopicId = loader.getTopicId(); + TdApi.MessageTopic effectiveTopicId = messageThreadTopicId != null ? messageThreadTopicId : forumTopicId; + if (!Td.matchesTopic(message.getMessageTopicId(), effectiveTopicId)) { return; } ThreadInfo messageThread = loader.getMessageThread(); @@ -2551,10 +2561,9 @@ private void saveScrollPosition () { if (view != null && view.getParent() != null) { scrollOffsetInPixels = calculateOffsetInPixels(view, message.getExtraPadding()); } - if (readFully && scrollOffsetInPixels == 0) { - scrollMessageId = scrollMessageChatId = 0; - scrollMessageOtherIds = null; - } else if (isBottomSponsored) { + // Don't clear scroll position when at bottom - keep it so we can restore to bottom on re-entry. + // The readFully flag is still saved in SavedMessageId to indicate user was at the end. + if (isBottomSponsored) { if (message.isSponsoredMessage()) { // the bottom VISIBLE message is sponsored - no need to save that data scrollMessageId = scrollMessageChatId = scrollOffsetInPixels = 0; diff --git a/app/src/main/java/org/thunderdog/challegram/component/dialogs/ChatsAdapter.java b/app/src/main/java/org/thunderdog/challegram/component/dialogs/ChatsAdapter.java index 6312d5969a..83a210d535 100644 --- a/app/src/main/java/org/thunderdog/challegram/component/dialogs/ChatsAdapter.java +++ b/app/src/main/java/org/thunderdog/challegram/component/dialogs/ChatsAdapter.java @@ -439,6 +439,14 @@ public int updateChatUnreadMentionCount (long chatId, int unreadMentionCount) { return -1; } + public int updateForumUnreadTopicCount (long chatId) { + int index = indexOfChat(chatId); + if (index != -1 && chats.get(index).updateForumUnreadTopicCount(chatId)) { + return getItemPositionByChatIndex(index); + } + return -1; + } + public int updateChatHasScheduledMessages (long chatId, boolean hasScheduledMessages) { int index = indexOfChat(chatId); if (index != -1 && chats.get(index).updateChatHasScheduledMessages(chatId, hasScheduledMessages)) { diff --git a/app/src/main/java/org/thunderdog/challegram/data/ContentPreview.java b/app/src/main/java/org/thunderdog/challegram/data/ContentPreview.java index 7a6c4a9cbf..85afdc13ec 100644 --- a/app/src/main/java/org/thunderdog/challegram/data/ContentPreview.java +++ b/app/src/main/java/org/thunderdog/challegram/data/ContentPreview.java @@ -720,6 +720,28 @@ public ContentPreview runBuilder (TdApi.Message updatedMessage) { case TdApi.MessageGroupCall.CONSTRUCTOR: case TdApi.MessagePaidMessagesRefunded.CONSTRUCTOR: case TdApi.MessagePaidMessagePriceChanged.CONSTRUCTOR: + break; + + // Forum topic service messages + case TdApi.MessageForumTopicCreated.CONSTRUCTOR: { + return new ContentPreview(EMOJI_GROUP, R.string.TopicWasCreated); + } + case TdApi.MessageForumTopicEdited.CONSTRUCTOR: { + TdApi.MessageForumTopicEdited edited = (TdApi.MessageForumTopicEdited) message.content; + if (edited.name != null && !edited.name.isEmpty()) { + return new ContentPreview(EMOJI_GROUP, 0, Lang.getString(R.string.TopicWasRenamed, edited.name), true); + } + // Icon was changed (name is empty/null means only icon changed) + return new ContentPreview(EMOJI_GROUP, R.string.TopicIconChanged); + } + case TdApi.MessageForumTopicIsClosedToggled.CONSTRUCTOR: { + TdApi.MessageForumTopicIsClosedToggled toggled = (TdApi.MessageForumTopicIsClosedToggled) message.content; + return new ContentPreview(EMOJI_GROUP, toggled.isClosed ? R.string.TopicWasClosed : R.string.TopicWasReopened); + } + case TdApi.MessageForumTopicIsHiddenToggled.CONSTRUCTOR: { + TdApi.MessageForumTopicIsHiddenToggled toggled = (TdApi.MessageForumTopicIsHiddenToggled) message.content; + return new ContentPreview(EMOJI_GROUP, toggled.isHidden ? R.string.GeneralTopicWasHidden : R.string.GeneralTopicWasShown); + } // Handled by getSimpleContentPreview, but unsupported case TdApi.MessageUnsupported.CONSTRUCTOR: @@ -727,10 +749,6 @@ public ContentPreview runBuilder (TdApi.Message updatedMessage) { case TdApi.MessageChatShared.CONSTRUCTOR: case TdApi.MessageSuggestProfilePhoto.CONSTRUCTOR: case TdApi.MessageSuggestBirthdate.CONSTRUCTOR: - case TdApi.MessageForumTopicCreated.CONSTRUCTOR: - case TdApi.MessageForumTopicEdited.CONSTRUCTOR: - case TdApi.MessageForumTopicIsClosedToggled.CONSTRUCTOR: - case TdApi.MessageForumTopicIsHiddenToggled.CONSTRUCTOR: case TdApi.MessagePassportDataSent.CONSTRUCTOR: case TdApi.MessageChatSetBackground.CONSTRUCTOR: case TdApi.MessageChecklist.CONSTRUCTOR: @@ -1544,15 +1562,21 @@ else if (isChatsList) case TdApi.MessagePaymentRefunded.CONSTRUCTOR: throw new IllegalArgumentException(Integer.toString(type)); + // Forum topic service messages + case TdApi.MessageForumTopicCreated.CONSTRUCTOR: + return new ContentPreview(EMOJI_GROUP, R.string.TopicWasCreated); + case TdApi.MessageForumTopicEdited.CONSTRUCTOR: + return new ContentPreview(EMOJI_GROUP, R.string.TopicIconChanged); + case TdApi.MessageForumTopicIsClosedToggled.CONSTRUCTOR: + return new ContentPreview(EMOJI_GROUP, arg1 == ARG_TRUE ? R.string.TopicWasClosed : R.string.TopicWasReopened); + case TdApi.MessageForumTopicIsHiddenToggled.CONSTRUCTOR: + return new ContentPreview(EMOJI_GROUP, arg1 == ARG_TRUE ? R.string.GeneralTopicWasHidden : R.string.GeneralTopicWasShown); + case TdApi.MessageStory.CONSTRUCTOR: case TdApi.MessageUsersShared.CONSTRUCTOR: case TdApi.MessageChatShared.CONSTRUCTOR: case TdApi.MessageSuggestProfilePhoto.CONSTRUCTOR: case TdApi.MessageSuggestBirthdate.CONSTRUCTOR: - case TdApi.MessageForumTopicCreated.CONSTRUCTOR: - case TdApi.MessageForumTopicEdited.CONSTRUCTOR: - case TdApi.MessageForumTopicIsClosedToggled.CONSTRUCTOR: - case TdApi.MessageForumTopicIsHiddenToggled.CONSTRUCTOR: case TdApi.MessagePassportDataSent.CONSTRUCTOR: case TdApi.MessageChatSetBackground.CONSTRUCTOR: case TdApi.MessagePremiumGiftCode.CONSTRUCTOR: diff --git a/app/src/main/java/org/thunderdog/challegram/data/TGChat.java b/app/src/main/java/org/thunderdog/challegram/data/TGChat.java index 17bbcbb01d..937eddffac 100644 --- a/app/src/main/java/org/thunderdog/challegram/data/TGChat.java +++ b/app/src/main/java/org/thunderdog/challegram/data/TGChat.java @@ -618,6 +618,14 @@ public boolean updateChatReadInbox (long chatId, final long lastReadInboxMessage return false; } + public boolean updateForumUnreadTopicCount (long chatId) { + if (chat.id == chatId && tdlib.isForum(chatId)) { + setCounter(true); + return true; + } + return false; + } + public boolean updateChatUnreadReactionCount (long chatId, int unreadReactionCount) { if (chat.id == chatId) { boolean hadBadge = chat.unreadReactionCount > 0; @@ -826,6 +834,15 @@ public int getUnreadCount () { return getTotalUnreadCount(); } else if (getSource() != null) { return 0; + } else if (tdlib.isForum(chat.id)) { + // For forum chats, show unread topic count instead of message count + int unreadTopics = tdlib.forumUnreadTopicCount(chat.id); + if (unreadTopics == -1) { + // Not loaded yet, trigger fetch and show indicator if there are unread messages + tdlib.fetchForumUnreadTopicCount(chat.id, null); + return chat.unreadCount > 0 ? Tdlib.CHAT_MARKED_AS_UNREAD : 0; + } + return unreadTopics > 0 ? unreadTopics : chat.isMarkedAsUnread ? Tdlib.CHAT_MARKED_AS_UNREAD : 0; } else { return chat.unreadCount > 0 ? chat.unreadCount : chat.isMarkedAsUnread ? Tdlib.CHAT_MARKED_AS_UNREAD : 0; } diff --git a/app/src/main/java/org/thunderdog/challegram/data/TGFoundChat.java b/app/src/main/java/org/thunderdog/challegram/data/TGFoundChat.java index fc2a2c3e4c..7b4df643cf 100644 --- a/app/src/main/java/org/thunderdog/challegram/data/TGFoundChat.java +++ b/app/src/main/java/org/thunderdog/challegram/data/TGFoundChat.java @@ -360,7 +360,18 @@ public boolean notificationsEnabled () { } public int getUnreadCount () { - return (flags & FLAG_NO_UNREAD) != 0 ? 0 : chat != null ? (chat.unreadCount > 0 ? chat.unreadCount : chat.isMarkedAsUnread ? Tdlib.CHAT_MARKED_AS_UNREAD : 0) : 0; + if ((flags & FLAG_NO_UNREAD) != 0 || chat == null) { + return 0; + } + if (tdlib.isForum(chat.id)) { + int unreadTopics = tdlib.forumUnreadTopicCount(chat.id); + if (unreadTopics == -1) { + tdlib.fetchForumUnreadTopicCount(chat.id, null); + return chat.unreadCount > 0 ? Tdlib.CHAT_MARKED_AS_UNREAD : 0; + } + return unreadTopics > 0 ? unreadTopics : chat.isMarkedAsUnread ? Tdlib.CHAT_MARKED_AS_UNREAD : 0; + } + return chat.unreadCount > 0 ? chat.unreadCount : chat.isMarkedAsUnread ? Tdlib.CHAT_MARKED_AS_UNREAD : 0; } private void setTitleImpl (String title, @Nullable TdApi.Chat chat) { diff --git a/app/src/main/java/org/thunderdog/challegram/data/TGMessageService.java b/app/src/main/java/org/thunderdog/challegram/data/TGMessageService.java index e5b22aa5b2..77a4e29aad 100644 --- a/app/src/main/java/org/thunderdog/challegram/data/TGMessageService.java +++ b/app/src/main/java/org/thunderdog/challegram/data/TGMessageService.java @@ -1148,22 +1148,38 @@ public TGMessageService (MessagesManager context, TdApi.Message msg, TdApi.Messa public TGMessageService (MessagesManager context, TdApi.Message msg, TdApi.MessageForumTopicCreated forumTopicCreated) { super(context, msg); - setUnsupportedTextCreator(); + setTextCreator(() -> + getText(R.string.TopicWasCreated) + ); } public TGMessageService (MessagesManager context, TdApi.Message msg, TdApi.MessageForumTopicEdited forumTopicEdited) { super(context, msg); - setUnsupportedTextCreator(); + if (forumTopicEdited.name != null && !forumTopicEdited.name.isEmpty()) { + String newName = forumTopicEdited.name; + setTextCreator(() -> + getText(R.string.TopicWasRenamed, new BoldArgument(newName)) + ); + } else { + // Icon was edited (no name change) + setTextCreator(() -> + getText(R.string.TopicIconChanged) + ); + } } public TGMessageService (MessagesManager context, TdApi.Message msg, TdApi.MessageForumTopicIsClosedToggled forumTopicIsClosedToggled) { super(context, msg); - setUnsupportedTextCreator(); + setTextCreator(() -> + getText(forumTopicIsClosedToggled.isClosed ? R.string.TopicWasClosed : R.string.TopicWasReopened) + ); } public TGMessageService (MessagesManager context, TdApi.Message msg, TdApi.MessageForumTopicIsHiddenToggled forumTopicIsHiddenToggled) { super(context, msg); - setUnsupportedTextCreator(); + setTextCreator(() -> + getText(forumTopicIsHiddenToggled.isHidden ? R.string.GeneralTopicWasHidden : R.string.GeneralTopicWasShown) + ); } private void setUnsupportedTextCreator () { diff --git a/app/src/main/java/org/thunderdog/challegram/data/TGReactions.java b/app/src/main/java/org/thunderdog/challegram/data/TGReactions.java index 4e9d4954ec..6f413c9b77 100644 --- a/app/src/main/java/org/thunderdog/challegram/data/TGReactions.java +++ b/app/src/main/java/org/thunderdog/challegram/data/TGReactions.java @@ -942,6 +942,15 @@ public void drawReactionNonBubble (Canvas c, float x, float cy, float radDp, fin private boolean inAnimation; private void drawReceiver (Canvas c, int l, int t, int r, int b, float alpha) { + // Draw star icon for paid reactions + if (paidReactionDrawable != null) { + paidReactionDrawable.setBounds(l, t, r, b); + paidReactionDrawable.setAlpha((int) (alpha * 255)); + paidReactionDrawable.setColorFilter(Paints.getPorterDuffPaint(Theme.getColor(ColorId.iconActive)).getColorFilter()); + paidReactionDrawable.draw(c); + return; + } + Receiver receiver = inAnimation ? centerAnimationReceiver : staticCenterAnimationReceiver; float scale = inAnimation ? animationScale : staticAnimationFile != null ? staticAnimationFileScale : staticImageFileScale; if (receiver != null) { diff --git a/app/src/main/java/org/thunderdog/challegram/data/ThreadInfo.java b/app/src/main/java/org/thunderdog/challegram/data/ThreadInfo.java index 37a9827ac0..052cdc812e 100644 --- a/app/src/main/java/org/thunderdog/challegram/data/ThreadInfo.java +++ b/app/src/main/java/org/thunderdog/challegram/data/ThreadInfo.java @@ -478,7 +478,7 @@ public void updateReadInbox (long lastReadInboxMessageId, int unreadMessageCount notifyMessageThreadReadInbox(); } - private void updateReadOutbox (long lastReadOutboxMessageId) { + public void updateReadOutbox (long lastReadOutboxMessageId) { TdApi.MessageReplyInfo replyInfo = threadInfo.replyInfo; if (replyInfo == null || replyInfo.lastReadOutboxMessageId >= lastReadOutboxMessageId) return; diff --git a/app/src/main/java/org/thunderdog/challegram/navigation/ViewController.java b/app/src/main/java/org/thunderdog/challegram/navigation/ViewController.java index 4bd04e8896..0b1c335bde 100644 --- a/app/src/main/java/org/thunderdog/challegram/navigation/ViewController.java +++ b/app/src/main/java/org/thunderdog/challegram/navigation/ViewController.java @@ -2159,7 +2159,8 @@ public boolean onTouchEvent (MotionEvent event) { } Views.updateMediumTypeface(button, text); - Views.setClickable(button); + // Don't use Views.setClickable() - it disables focusable states needed for ripple effect + button.setClickable(true); footerView.addView(button); } } diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/ChatListener.java b/app/src/main/java/org/thunderdog/challegram/telegram/ChatListener.java index e4e962871e..f8ee33c6e3 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/ChatListener.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/ChatListener.java @@ -50,6 +50,7 @@ default void onChatMessageTtlSettingChanged (long chatId, int messageAutoDeleteT default void onChatActiveStoriesChanged (@NonNull TdApi.ChatActiveStories activeStories) { } default void onChatVideoChatChanged (long chatId, TdApi.VideoChat videoChat) { } default void onChatViewAsTopics (long chatId, boolean viewAsTopics) { } + default void onForumUnreadTopicCountChanged (long chatId, int unreadTopicCount) { } default void onChatPendingJoinRequestsChanged (long chatId, TdApi.ChatJoinRequestsInfo pendingJoinRequests) { } default void onChatReplyMarkupChanged (long chatId, long replyMarkupMessageId) { } default void onChatDraftMessageChanged (long chatId, @Nullable TdApi.DraftMessage draftMessage) { } diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/ForumTopicInfoListener.java b/app/src/main/java/org/thunderdog/challegram/telegram/ForumTopicInfoListener.java index cfa6f1bc42..635ae2e66c 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/ForumTopicInfoListener.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/ForumTopicInfoListener.java @@ -1,8 +1,19 @@ package org.thunderdog.challegram.telegram; +import androidx.annotation.Nullable; + import org.drinkless.tdlib.TdApi; public interface ForumTopicInfoListener { default void onForumTopicInfoChanged (TdApi.ForumTopicInfo info) { } - default void onForumTopicUpdated (long chatId, long messageThreadId, boolean isPinned, long lastReadInboxMessageId, long lastReadOutboxMessageId, TdApi.ChatNotificationSettings notificationSettings) { } + /** + * Called when a forum topic's read/pin/notification/draft state changes. + * Note: unreadCount is not provided by TDLib's UpdateForumTopic - it needs to be + * fetched separately via GetForumTopic when lastReadInboxMessageId changes. + */ + default void onForumTopicUpdated (long chatId, long messageThreadId, boolean isPinned, long lastReadInboxMessageId, long lastReadOutboxMessageId, int unreadMentionCount, int unreadReactionCount, TdApi.ChatNotificationSettings notificationSettings, @Nullable TdApi.DraftMessage draftMessage) { } + /** + * Called when the full forum topic data is refreshed (includes unreadCount). + */ + default void onForumTopicFullyUpdated (long chatId, TdApi.ForumTopic topic) { } } diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/Tdlib.java b/app/src/main/java/org/thunderdog/challegram/telegram/Tdlib.java index 4e93807a39..23e09374ff 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/Tdlib.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/Tdlib.java @@ -437,6 +437,8 @@ public long timeWasted () { private final SparseIntArray storyListChatCount = new SparseIntArray(); private final SparseArrayCompat storyLists = new SparseArrayCompat<>(); private final HashMap forumTopicInfos = new HashMap<>(); + private final HashMap forumUnreadTopicCounts = new HashMap<>(); + private final HashMap> forumTopicsCache = new HashMap<>(); private final HashMap chatLists = new HashMap<>(); private final StickerSet animatedTgxEmoji = new StickerSet(AnimatedEmojiListener.TYPE_TGX, "AnimatedTgxEmojies", false), @@ -2632,6 +2634,35 @@ public void privateChat (long userId, RunnableData callback) { } } + /** + * Get a ForumTopic by chat ID and topic ID from cache. + */ + public @Nullable TdApi.ForumTopic forumTopic (long chatId, long topicId) { + synchronized (dataLock) { + List topics = forumTopicsCache.get(chatId); + if (topics != null) { + for (TdApi.ForumTopic topic : topics) { + if (topic.info.forumTopicId == topicId) { + return topic; + } + } + } + } + return null; + } + + /** + * Check if a forum topic is muted. + * Returns true if the topic has notification settings with muteFor > 0. + */ + public boolean isForumTopicMuted (long chatId, long topicId) { + TdApi.ForumTopic topic = forumTopic(chatId, topicId); + if (topic != null && topic.notificationSettings != null) { + return topic.notificationSettings.muteFor > 0; + } + return false; + } + public @Nullable TdApi.Chat chat (long chatId) { if (chatId == 0) { return null; @@ -3335,6 +3366,114 @@ public boolean isForum (long chatId) { return supergroup != null && supergroup.isForum; } + /** + * Returns the cached unread topic count for a forum chat. + * @return unread topic count, or -1 if not yet loaded + */ + public int forumUnreadTopicCount (long chatId) { + synchronized (dataLock) { + Integer count = forumUnreadTopicCounts.get(chatId); + return count != null ? count : -1; + } + } + + /** + * Fetches forum topics and updates the unread topic count cache. + * @param chatId The forum chat ID + * @param callback Called on UI thread when complete (may be null) + */ + public void fetchForumUnreadTopicCount (long chatId, @Nullable Runnable callback) { + if (!isForum(chatId)) { + if (callback != null) { + ui().post(callback); + } + return; + } + client().send(new TdApi.GetForumTopics(chatId, "", 0, 0, 0, 100), result -> { + if (result.getConstructor() == TdApi.ForumTopics.CONSTRUCTOR) { + TdApi.ForumTopics topics = (TdApi.ForumTopics) result; + int unreadCount = 0; + for (TdApi.ForumTopic topic : topics.topics) { + // Exclude hidden topics (like the hidden General topic) + if (topic.unreadCount > 0 && !topic.info.isHidden) { + unreadCount++; + } + } + synchronized (dataLock) { + forumUnreadTopicCounts.put(chatId, unreadCount); + forumTopicsCache.put(chatId, new java.util.ArrayList<>(java.util.Arrays.asList(topics.topics))); + } + listeners().updateForumUnreadTopicCount(chatId, unreadCount); + } + if (callback != null) { + ui().post(callback); + } + }); + } + + /** + * Updates the cached unread topic count when a topic's unread state changes. + * Called when we get fresh topic data via GetForumTopic. + */ + public void updateForumTopicUnreadCount (long chatId, int topicId, int newUnreadCount) { + synchronized (dataLock) { + List topics = forumTopicsCache.get(chatId); + if (topics != null) { + int oldUnreadTopics = 0; + int newUnreadTopics = 0; + boolean found = false; + for (int i = 0; i < topics.size(); i++) { + TdApi.ForumTopic topic = topics.get(i); + if (topic.info.forumTopicId == topicId) { + found = true; + if (topic.unreadCount > 0) oldUnreadTopics++; + if (newUnreadCount > 0) newUnreadTopics++; + topic.unreadCount = newUnreadCount; + } else { + if (topic.unreadCount > 0) { + oldUnreadTopics++; + newUnreadTopics++; + } + } + } + if (found) { + // Recalculate total unread topics (exclude hidden topics) + int totalUnread = 0; + for (TdApi.ForumTopic topic : topics) { + if (topic.unreadCount > 0 && !topic.info.isHidden) { + totalUnread++; + } + } + Integer oldCount = forumUnreadTopicCounts.get(chatId); + if (oldCount == null || oldCount != totalUnread) { + forumUnreadTopicCounts.put(chatId, totalUnread); + listeners().updateForumUnreadTopicCount(chatId, totalUnread); + } + } + } + } + } + + /** + * Returns cached forum topics for the given chat, or null if not cached. + * Use this to show topics instantly while refreshing from network. + */ + public @Nullable List getCachedForumTopics (long chatId) { + synchronized (dataLock) { + List cached = forumTopicsCache.get(chatId); + return cached != null ? new java.util.ArrayList<>(cached) : null; + } + } + + /** + * Updates the forum topics cache with fresh data. + */ + public void updateForumTopicsCache (long chatId, List topics) { + synchronized (dataLock) { + forumTopicsCache.put(chatId, new java.util.ArrayList<>(topics)); + } + } + public @Nullable TdApi.BlockList chatBlockList (TdApi.Chat chat) { return chat != null ? chatBlockList(chat.id) : null; } @@ -3626,6 +3765,17 @@ public boolean chatNeedsMuteIcon (TdApi.Chat chat) { return chat != null && TD.needMuteIcon(chat.notificationSettings, scopeNotificationSettings(chat.id)); } + public boolean forumTopicNeedsMuteIcon (long chatId, TdApi.ForumTopic topic) { + if (topic == null || topic.notificationSettings == null) { + return chatNeedsMuteIcon(chatId); + } + if (topic.notificationSettings.useDefaultMuteFor) { + // Topic inherits from parent chat notification settings + return chatNeedsMuteIcon(chatId); + } + return topic.notificationSettings.muteFor > 0; + } + public boolean chatNotificationsEnabled (long chatId) { return chatNotificationsEnabled(chat(chatId)); } diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibListeners.java b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibListeners.java index 98af477cd8..94fd2b50ef 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibListeners.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibListeners.java @@ -1289,10 +1289,18 @@ void updateForumTopicInfo (TdApi.UpdateForumTopicInfo update) { void updateForumTopic (TdApi.UpdateForumTopic update) { runForumUpdate(update.chatId, update.forumTopicId, listener -> - listener.onForumTopicUpdated(update.chatId, update.forumTopicId, update.isPinned, update.lastReadInboxMessageId, update.lastReadOutboxMessageId, update.notificationSettings) + listener.onForumTopicUpdated(update.chatId, update.forumTopicId, update.isPinned, update.lastReadInboxMessageId, update.lastReadOutboxMessageId, update.unreadMentionCount, update.unreadReactionCount, update.notificationSettings, update.draftMessage) ); } + // updateForumUnreadTopicCount (custom, not from TDLib) + + void updateForumUnreadTopicCount (long chatId, int unreadTopicCount) { + runChatUpdate(chatId, listener -> { + listener.onForumUnreadTopicCountChanged(chatId, unreadTopicCount); + }); + } + // updateChatViewAsTopics void updateChatViewAsTopics (TdApi.UpdateChatViewAsTopics update) { diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotification.java b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotification.java index 9fc0773b19..5eae8acccb 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotification.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotification.java @@ -156,6 +156,31 @@ public TdApi.Message findMessage () { return notification.type.getConstructor() == TdApi.NotificationTypeNewMessage.CONSTRUCTOR ? ((TdApi.NotificationTypeNewMessage) notification.type).message : null; } + public long findForumTopicId () { + TdApi.Message message = findMessage(); + if (message != null && message.topicId != null) { + if (message.topicId.getConstructor() == TdApi.MessageTopicForum.CONSTRUCTOR) { + return ((TdApi.MessageTopicForum) message.topicId).forumTopicId; + } + } + return 0; + } + + /** + * Check if this notification is from a muted forum topic. + */ + public boolean isFromMutedForumTopic (Tdlib tdlib) { + TdApi.Message message = findMessage(); + if (message == null) { + return false; + } + long topicId = findForumTopicId(); + if (topicId == 0) { + return false; + } + return tdlib.isForumTopicMuted(message.chatId, topicId); + } + public boolean canMergeWith (@Nullable TdlibNotification n) { if (n != null && notification.type.getConstructor() == TdApi.NotificationTypeNewMessage.CONSTRUCTOR && n.notification.type.getConstructor() == TdApi.NotificationTypeNewMessage.CONSTRUCTOR) { TdApi.Message m = ((TdApi.NotificationTypeNewMessage) notification.type).message; diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationGroup.java b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationGroup.java index 887c14177e..89c4a2c2da 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationGroup.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationGroup.java @@ -131,6 +131,65 @@ public long findTargetMessageId () { return 0; } + public long findForumTopicId () { + for (TdlibNotification notification : this) { + long topicId = notification.findForumTopicId(); + if (topicId != 0) + return topicId; + } + return 0; + } + + /** + * Get all unique forum topic IDs from notifications in this group. + * @return Set of topic IDs, empty if no forum topics found + */ + public Set getAllForumTopicIds () { + Set topicIds = new HashSet<>(); + for (TdlibNotification notification : this) { + long topicId = notification.findForumTopicId(); + if (topicId != 0) { + topicIds.add(topicId); + } + } + return topicIds; + } + + /** + * Get notifications filtered by a specific forum topic ID. + * @param topicId The topic ID to filter by + * @return List of notifications for this topic + */ + public List getNotificationsForTopic (long topicId) { + List result = new ArrayList<>(); + for (TdlibNotification notification : this) { + long notifTopicId = notification.findForumTopicId(); + if (notifTopicId == topicId) { + result.add(notification); + } + } + return result; + } + + /** + * Check if this notification group has notifications from multiple forum topics. + * @return true if there are 2+ different topic IDs + */ + public boolean hasMultipleForumTopics () { + long firstTopicId = 0; + for (TdlibNotification notification : this) { + long topicId = notification.findForumTopicId(); + if (topicId != 0) { + if (firstTopicId == 0) { + firstTopicId = topicId; + } else if (firstTopicId != topicId) { + return true; + } + } + } + return false; + } + public int firstNotificationId () { return !notifications.isEmpty() ? notifications.get(0).getId() : 0; } @@ -440,4 +499,188 @@ public boolean needRemoveDismissedMessages () { } return !Settings.instance().checkNotificationFlag(notificationFlag); } + + /** + * A view of notifications for a specific forum topic within a notification group. + * Used to display separate notifications per topic. + */ + public static class TopicView implements Iterable { + private final TdlibNotificationGroup parent; + private final long topicId; + private final List notifications; + + public TopicView (TdlibNotificationGroup parent, long topicId, List notifications) { + this.parent = parent; + this.topicId = topicId; + this.notifications = notifications; + } + + public TdlibNotificationGroup parent () { + return parent; + } + + public long getTopicId () { + return topicId; + } + + public long getChatId () { + return parent.getChatId(); + } + + public int getId () { + return parent.getId(); + } + + public int getTotalCount () { + return notifications.size(); + } + + public int getCategory () { + return parent.getCategory(); + } + + public boolean isMention () { + return parent.isMention(); + } + + public boolean isEmpty () { + return notifications.isEmpty(); + } + + public int visualSize () { + int count = 0; + for (TdlibNotification notification : notifications) { + if (!notification.isHidden()) { + count++; + } + } + return count; + } + + public TdlibNotification lastNotification () { + return notifications.isEmpty() ? null : notifications.get(notifications.size() - 1); + } + + public long findTargetMessageId () { + if (!parent.isMention()) + return 0; + for (TdlibNotification notification : notifications) { + if (!notification.isHidden()) { + long messageId = notification.findMessageId(); + if (messageId != 0) + return messageId; + } + } + return 0; + } + + public long[] getAllMessageIds () { + LongList ids = new LongList(notifications.size()); + for (TdlibNotification notification : notifications) { + if (!notification.isHidden()) { + long messageId = notification.findMessageId(); + if (messageId != 0) + ids.append(messageId); + } + } + return ids.get(); + } + + public long[] getAllUserIds () { + Set userIds = new HashSet<>(); + for (TdlibNotification notification : notifications) { + if (!notification.isHidden()) { + long chatId = notification.findSenderId(); + if (ChatId.isPrivate(chatId)) { + userIds.add(ChatId.toUserId(chatId)); + } + } + } + if (!userIds.isEmpty()) { + long[] result = new long[userIds.size()]; + int i = 0; + for (Long userId : userIds) { + result[i++] = userId; + } + return result; + } + return null; + } + + public boolean isOnlyPinned () { + boolean first = true; + for (TdlibNotification notification : notifications) { + if (!notification.isHidden()) { + if (!notification.isPinnedMessage()) { + return false; + } + first = false; + } + } + return !first; + } + + public boolean isOnlyScheduled () { + boolean first = true; + for (TdlibNotification notification : notifications) { + if (!notification.isHidden()) { + if (!notification.isScheduled()) { + return false; + } + first = false; + } + } + return !first; + } + + public boolean isOnlyInitiallySilent () { + boolean first = true; + for (TdlibNotification notification : notifications) { + if (!notification.isHidden()) { + if (!notification.isVisuallySilent()) { + return false; + } + first = false; + } + } + return !first; + } + + public long singleSenderId () { + TdlibNotification prevNotification = null; + for (TdlibNotification notification : notifications) { + if (!notification.isHidden()) { + if (prevNotification != null && !prevNotification.isSameSender(notification)) + return 0; + prevNotification = notification; + } + } + return prevNotification != null ? prevNotification.findSenderId() : 0; + } + + @NonNull + @Override + public Iterator iterator () { + return new FilteredIterator<>(notifications, notification -> !notification.isHidden()); + } + } + + /** + * Create TopicView objects for each forum topic in this group. + * @return List of TopicView objects, one per unique topic ID + */ + public List splitByForumTopics () { + Set topicIds = getAllForumTopicIds(); + if (topicIds.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(topicIds.size()); + for (Long topicId : topicIds) { + List topicNotifications = getNotificationsForTopic(topicId); + if (!topicNotifications.isEmpty()) { + result.add(new TopicView(this, topicId, topicNotifications)); + } + } + return result; + } } diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationHelper.java b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationHelper.java index 67558e5271..390afa53ef 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationHelper.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationHelper.java @@ -206,6 +206,22 @@ public void updateGroup (TdApi.UpdateNotificationGroup update) { int visualChangeCount = group.updateGroup(update, addedNotifications, removedNotifications); + // Filter out notifications from muted forum topics and make silent if all are muted + if (addedNotifications != null && !addedNotifications.isEmpty()) { + Iterator iter = addedNotifications.iterator(); + while (iter.hasNext()) { + TdlibNotification notification = iter.next(); + if (notification.isFromMutedForumTopic(tdlib)) { + TDLib.Tag.notifications("Filtering notification from muted forum topic, chatId=%d", update.chatId); + iter.remove(); + visualChangeCount--; + } + } + if (addedNotifications.isEmpty()) { + isSilent = true; + } + } + if (removedNotifications != null && !removedNotifications.isEmpty()) { notifications.removeAll(removedNotifications); } @@ -264,8 +280,23 @@ public void updateGroup (TdApi.UpdateNotificationGroup update) { group = new TdlibNotificationGroup(tdlib, update); if (group.isEmpty()) return; + + // Filter out notifications from muted forum topics for new groups + List groupNotifications = new ArrayList<>(group.notifications()); + Iterator iter = groupNotifications.iterator(); + while (iter.hasNext()) { + TdlibNotification notification = iter.next(); + if (notification.isFromMutedForumTopic(tdlib)) { + TDLib.Tag.notifications("Filtering notification from muted forum topic (new group), chatId=%d", update.chatId); + iter.remove(); + } + } + if (groupNotifications.isEmpty()) { + isSilent = true; + } + groups.put(update.notificationGroupId, group); - notifications.addAll(group.notifications()); + notifications.addAll(groupNotifications); Collections.sort(notifications); } boolean needNotification = !isSilent && context.allowNotificationSound(update.chatId); @@ -328,6 +359,18 @@ public int getNotificationIdForGroup (int groupId) { return baseNotificationId + (/*category_count*/ TdlibNotificationGroup.MAX_CATEGORY + 1) + groupId; } + /** + * Generate a unique notification ID for a specific forum topic within a group. + * Uses a hash-based approach to avoid ID collisions. + */ + public int getNotificationIdForTopicView (int groupId, long topicId) { + // Combine groupId with topicId hash to generate unique ID + // Use a large offset to avoid collisions with regular group IDs + int topicHash = Long.hashCode(topicId) & 0x7FFFFFFF; // Ensure positive + int topicOffset = (topicHash % 100000) + 100000; // Range: 100000-199999 + return baseNotificationId + (TdlibNotificationGroup.MAX_CATEGORY + 1) + groupId * 200000 + topicOffset; + } + public boolean isEmpty () { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { long accountUserId = tdlib.myUserId(true); diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationStyle.java b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationStyle.java index 4b8fc7c6d4..a64bcfc55a 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationStyle.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationStyle.java @@ -235,9 +235,163 @@ private static void styleNotification (Tdlib tdlib, NotificationCompat.Builder b public static final int DISPLAY_STATE_OK = 3; protected final int displayChildNotification (NotificationManagerCompat manager, Context context, @NonNull TdlibNotificationHelper helper, int badgeCount, boolean allowPreview, @NonNull TdlibNotificationGroup group, TdlibNotificationSettings settings, boolean isRebuild) { + // Check if this is a forum chat with multiple topics - display each topic separately + long chatId = group.getChatId(); + if (tdlib.isForum(chatId) && group.hasMultipleForumTopics()) { + List topicViews = group.splitByForumTopics(); + int result = DISPLAY_STATE_HIDDEN; + for (TdlibNotificationGroup.TopicView topicView : topicViews) { + int topicResult = displayTopicNotification(manager, context, helper, badgeCount, allowPreview, topicView, settings, isRebuild); + if (topicResult == DISPLAY_STATE_OK) { + result = DISPLAY_STATE_OK; + } + } + // Cancel the original group notification since we're showing per-topic ones + manager.cancel(helper.getNotificationIdForGroup(group.getId())); + return result; + } return displayChildNotification(manager, context, helper, badgeCount, allowPreview, group, settings, helper.getNotificationIdForGroup(group.getId()), false, isRebuild); } + /** + * Display notification for a specific forum topic. + */ + protected final int displayTopicNotification (NotificationManagerCompat manager, Context context, @NonNull TdlibNotificationHelper helper, int badgeCount, boolean allowPreview, @NonNull TdlibNotificationGroup.TopicView topicView, TdlibNotificationSettings settings, boolean isRebuild) { + if (!allowPreview || topicView.isEmpty()) { + int notificationId = helper.getNotificationIdForTopicView(topicView.getId(), topicView.getTopicId()); + manager.cancel(notificationId); + return DISPLAY_STATE_HIDDEN; + } + + int visualSize = topicView.visualSize(); + if (visualSize == 0) { + int notificationId = helper.getNotificationIdForTopicView(topicView.getId(), topicView.getTopicId()); + manager.cancel(notificationId); + return DISPLAY_STATE_HIDDEN; + } + + if (!tdlib.account().allowNotifications()) { + int notificationId = helper.getNotificationIdForTopicView(topicView.getId(), topicView.getTopicId()); + manager.cancel(notificationId); + return DISPLAY_STATE_POSTPONED; + } + + final long chatId = topicView.getChatId(); + final long topicId = topicView.getTopicId(); + final TdApi.Chat chat = tdlib.chatSync(chatId, CHAT_MAX_DELAY); + if (chat == null) { + return DISPLAY_STATE_FAIL; + } + + // Get topic name + TdApi.ForumTopicInfo topicInfo = tdlib.forumTopicInfo(chatId, topicId); + String topicName = topicInfo != null ? topicInfo.name : "Topic"; + final String chatTitle = tdlib.chatTitle(chat); + + final int category = topicView.getCategory(); + int notificationId = helper.getNotificationIdForTopicView(topicView.getId(), topicId); + + String channelId = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + android.app.NotificationChannel channel; + try { + channel = (android.app.NotificationChannel) tdlib.notifications().getSystemChannel(topicView.parent()); + } catch (TdlibNotificationChannelGroup.ChannelCreationFailureException e) { + channel = null; + } + if (channel == null) { + return DISPLAY_STATE_FAIL; + } + channelId = channel.getId(); + } + + final TdlibNotification singleNotification = visualSize == 1 ? topicView.lastNotification() : null; + final TdlibNotification lastNotification = topicView.lastNotification(); + if (lastNotification == null) { + return DISPLAY_STATE_FAIL; + } + + final boolean onlyPinned = topicView.isOnlyPinned(); + final boolean onlyScheduled = topicView.isOnlyScheduled(); + final boolean onlySilent = topicView.isOnlyInitiallySilent(); + final boolean isChannel = tdlib.isChannelChat(chat); + + // Build title with topic name: "Chat Name β€Ί Topic Name" + CharSequence visualChatTitle = chatTitle + " β€Ί " + topicName; + if (topicView.getTotalCount() > 1) { + visualChatTitle = Lang.getCharSequence(R.string.format_notificationTitleShort, visualChatTitle, Lang.plural(topicView.isMention() ? R.string.mentionCount : R.string.messagesCount, topicView.getTotalCount())); + } + + // Build messaging style using the existing helper + boolean needPreview = helper.needPreview(topicView.parent()); + NotificationCompat.MessagingStyle messagingStyle = newMessagingStyle(tdlib.notifications(), chat, topicView.getTotalCount(), topicView.isMention(), onlyPinned, onlyScheduled, onlySilent, false); + // Override conversation title with topic-specific one + messagingStyle.setConversationTitle(visualChatTitle); + messagingStyle.setGroupConversation(true); + + StringBuilder textBuilder = new StringBuilder(); + boolean[] hasCustomText = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? new boolean[1] : null; + boolean usePreview = needPreview && tdlib.notifications().isShowPreviewEnabled(chatId, topicView.isMention()); + + for (TdlibNotification notification : topicView) { + CharSequence preview; + if (usePreview) { + preview = notification.getTextRepresentation(tdlib, topicView.isMention() && onlyPinned, true, hasCustomText); + } else { + preview = Lang.getString(R.string.YouHaveNewMessage); + } + Person person = buildPerson(tdlib.notifications(), chat, notification, onlyScheduled, onlySilent, false); + addMessage(messagingStyle, preview, person, chat, notification, MEDIA_LOAD_TIMEOUT, false, !onlyScheduled && notification.isScheduled(), !onlySilent && notification.isVisuallySilent(), onlyPinned); + if (textBuilder.length() > 0) { + textBuilder.append('\n'); + } + textBuilder.append(preview); + } + + final String textContent = textBuilder.toString(); + final CharSequence tickerText = getTickerText(tdlib, helper, allowPreview, chat, lastNotification, true, topicView.singleSenderId() != 0, hasCustomText); + + final PendingIntent contentIntent = TdlibNotificationUtils.newIntent(tdlib.id(), tdlib.settings().getLocalChatId(chatId), topicView.findTargetMessageId(), topicId); + + NotificationCompat.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder = new NotificationCompat.Builder(UI.getAppContext(), channelId); + boolean needNotification = settings != null; + builder.setOnlyAlertOnce(!needNotification); + builder.setGroupAlertBehavior(needNotification ? NotificationCompat.GROUP_ALERT_CHILDREN : NotificationCompat.GROUP_ALERT_SUMMARY); + } else { + builder = new NotificationCompat.Builder(UI.getAppContext()); + } + + builder + .setContentTitle(visualChatTitle) + .setSmallIcon(R.mipmap.app_notification) + .setContentText(textContent) + .setTicker(tickerText) + .setAutoCancel(Config.NOTIFICATION_AUTO_CANCEL) + .setSortKey(makeSortKey(lastNotification, false)) + .setWhen(TimeUnit.SECONDS.toMillis(lastNotification.getDate())) + .setStyle(messagingStyle) + .setContentIntent(contentIntent); + + builder.setGroup(makeGroupKey(tdlib, category) + "_forum_" + chatId); + builder.setGroupSummary(false); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + builder.setCategory(NotificationCompat.CATEGORY_MESSAGE); + builder.setColor(tdlib.accountColor(chatId)); + } + + try { + Notification notification = builder.build(); + manager.notify(notificationId, notification); + return DISPLAY_STATE_OK; + } catch (Throwable t) { + Log.e("Unable to display topic notification", t); + return DISPLAY_STATE_FAIL; + } + } + private static final long CHAT_MAX_DELAY = 200; @SuppressWarnings("deprecation") @@ -580,7 +734,7 @@ protected final int displayChildNotification (NotificationManagerCompat manager, final String textContent = textBuilder.toString(); final CharSequence tickerText = getTickerText(tdlib, helper, allowPreview, chat, lastNotification, true, group.singleSenderId() != 0, hasCustomText); - final PendingIntent contentIntent = TdlibNotificationUtils.newIntent(tdlib.id(), tdlib.settings().getLocalChatId(chatId), group.findTargetMessageId()); + final PendingIntent contentIntent = TdlibNotificationUtils.newIntent(tdlib.id(), tdlib.settings().getLocalChatId(chatId), group.findTargetMessageId(), group.findForumTopicId()); boolean needGroupLogic = true; // !isSummary || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && settings == null); @@ -1123,6 +1277,24 @@ private NotificationCompat.Builder buildCommonNotification (Context context, @No if (contentTitle == null) { contentTitle = chat.title; } + // Add forum topic name to title if all notifications are from same topic + long commonTopicId = 0; + boolean sameTopicId = true; + for (TdlibNotification notification : notifications) { + long topicId = notification.findForumTopicId(); + if (commonTopicId == 0) { + commonTopicId = topicId; + } else if (commonTopicId != topicId) { + sameTopicId = false; + break; + } + } + if (sameTopicId && commonTopicId != 0) { + TdApi.ForumTopicInfo topicInfo = tdlib.forumTopicInfo(chat.id, commonTopicId); + if (topicInfo != null && !StringUtils.isEmpty(topicInfo.name)) { + contentTitle = contentTitle + " > " + topicInfo.name; + } + } } else { contentTitle = BuildConfig.PROJECT_NAME; } diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationUtils.java b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationUtils.java index fc7dae21f0..67d56d0ca3 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationUtils.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibNotificationUtils.java @@ -230,11 +230,19 @@ public static Bitmap buildLargeIcon (Tdlib tdlib, TdApi.File rawFile, TdlibAccen } static PendingIntent newIntent (int accountId, long forLocalChatId, long specificMessageId) { - return PendingIntent.getActivity(UI.getContext(), 0, forLocalChatId != 0 ? Intents.valueOfLocalChatId(accountId, forLocalChatId, specificMessageId) : Intents.valueOfMain(accountId), PendingIntent.FLAG_ONE_SHOT | Intents.mutabilityFlags(true)); + return newIntent(accountId, forLocalChatId, specificMessageId, 0); + } + + static PendingIntent newIntent (int accountId, long forLocalChatId, long specificMessageId, long messageThreadId) { + return PendingIntent.getActivity(UI.getContext(), 0, forLocalChatId != 0 ? Intents.valueOfLocalChatId(accountId, forLocalChatId, specificMessageId, messageThreadId) : Intents.valueOfMain(accountId), PendingIntent.FLAG_ONE_SHOT | Intents.mutabilityFlags(true)); } static Intent newCoreIntent (int accountId, long forLocalChatId, long specificMessageId) { - return forLocalChatId != 0 ? Intents.valueOfLocalChatId(accountId, forLocalChatId, specificMessageId) : Intents.valueOfMain(accountId); + return newCoreIntent(accountId, forLocalChatId, specificMessageId, 0); + } + + static Intent newCoreIntent (int accountId, long forLocalChatId, long specificMessageId, long messageThreadId) { + return forLocalChatId != 0 ? Intents.valueOfLocalChatId(accountId, forLocalChatId, specificMessageId, messageThreadId) : Intents.valueOfMain(accountId); } public static class NotificationInitializationFailedError extends RuntimeException { diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibSettingsManager.java b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibSettingsManager.java index ba920829ea..43b83c7a8c 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibSettingsManager.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibSettingsManager.java @@ -69,6 +69,7 @@ public class TdlibSettingsManager implements CleanupStartupDelegate { private static final String DISMISS_MESSAGE_PREFIX = "dismiss_pinned_"; private static final String DISMISS_REQUESTS_PREFIX = "dismiss_requests_"; + private static final String FORUM_VIEW_PREFERENCE_PREFIX = "forum_view_"; private static final String NOTIFICATION_GROUP_DATA_PREFIX = "notification_gdata_"; private static final String NOTIFICATION_DATA_PREFIX = "notification_data_"; @@ -175,6 +176,7 @@ public void onPerformUserCleanup () { Settings.instance().removeScrollPositions(accountId, editor); String dismissPrefix = key(DISMISS_MESSAGE_PREFIX, accountId); String dismissReqPrefix = key(DISMISS_REQUESTS_PREFIX, accountId); + String forumViewPrefix = key(FORUM_VIEW_PREFERENCE_PREFIX, accountId); String notificationGroupDataPrefix = key(NOTIFICATION_GROUP_DATA_PREFIX, accountId); String notificationDataPrefix = key(NOTIFICATION_DATA_PREFIX, accountId); String conversionPrefix = key(CONVERSION_PREFIX, accountId); @@ -183,6 +185,7 @@ public void onPerformUserCleanup () { Settings.instance().removeByAnyPrefix(new String[] { dismissPrefix, dismissReqPrefix, + forumViewPrefix, notificationGroupDataPrefix, notificationDataPrefix, conversionPrefix, @@ -288,6 +291,23 @@ public boolean isRequestsDismissed (long chatId, TdApi.ChatJoinRequestsInfo pend return pendingInfo == null || Arrays.equals(Settings.instance().getLongArray(key(DISMISS_REQUESTS_PREFIX, tdlib.id()) + chatId), pendingInfo.userIds); } + // Forum view preference: true = tabs, false = topics list + // This is used when forum has hasForumTabs enabled to remember user's choice + public static final int FORUM_VIEW_TABS = 1; + public static final int FORUM_VIEW_TOPICS = 2; + + public void setForumViewPreference (long chatId, int viewPreference) { + Settings.instance().putInt(key(FORUM_VIEW_PREFERENCE_PREFIX, tdlib.id()) + chatId, viewPreference); + } + + public int getForumViewPreference (long chatId) { + return Settings.instance().getInt(key(FORUM_VIEW_PREFERENCE_PREFIX, tdlib.id()) + chatId, 0); + } + + public boolean hasForumViewPreference (long chatId) { + return getForumViewPreference(chatId) != 0; + } + public boolean forcePlainModeInChannels () { if (_forcePlainModeInChannels == null) _forcePlainModeInChannels = Settings.instance().getBoolean(key(PLAIN_CHANNEL_KEY, tdlib.id()), true); diff --git a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibUi.java b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibUi.java index 409b06d35a..d3fed44584 100644 --- a/app/src/main/java/org/thunderdog/challegram/telegram/TdlibUi.java +++ b/app/src/main/java/org/thunderdog/challegram/telegram/TdlibUi.java @@ -101,6 +101,8 @@ import org.thunderdog.challegram.ui.EditProxyController; import org.thunderdog.challegram.ui.EditRightsController; import org.thunderdog.challegram.ui.EditUsernameController; +import org.thunderdog.challegram.ui.ForumTopicTabsController; +import org.thunderdog.challegram.ui.ForumTopicsController; import org.thunderdog.challegram.ui.InstantViewController; import org.thunderdog.challegram.ui.ListItem; import org.thunderdog.challegram.ui.MainController; @@ -1817,6 +1819,11 @@ public ChatOpenParameters searchFilter (TdApi.SearchMessagesFilter filter) { return this; } + public ChatOpenParameters messageTopic (TdApi.MessageTopic topicId) { + this.messageTopicId = topicId; + return this; + } + public ChatOpenParameters passcodeUnlocked () { this.options |= CHAT_OPTION_PASSCODE_UNLOCKED; return this; @@ -1887,6 +1894,10 @@ public ChatOpenParameters highlightMessage (TdApi.Message message) { public ChatOpenParameters foundMessage (String query, TdApi.Message message) { this.foundMessage = new MessageId(message.chatId, message.id); this.searchQuery = query; + // If message is in a forum topic, set the topic ID + if (message.topicId != null && message.topicId.getConstructor() == TdApi.MessageTopicForum.CONSTRUCTOR) { + this.messageTopicId = message.topicId; + } return highlightMessage(foundMessage); } @@ -2113,6 +2124,50 @@ public void openChat (final TdlibDelegate context, final @NonNull TdApi.Chat cha return; } + // Check if this is a forum chat that should be viewed as topics + if (tdlib.isForum(chat.id) && messageThread == null && messageTopicId == null) { + // Check if forum has tabs layout enabled + long supergroupId = ChatId.toSupergroupId(chat.id); + TdApi.Supergroup supergroup = supergroupId != 0 ? tdlib.cache().supergroup(supergroupId) : null; + boolean hasForumTabs = supergroup != null && supergroup.hasForumTabs; + + // Show topics view if viewAsTopics is true (default for forums) + // Forums with tabs should always open in tabs view by default + // Users can use "View as chat" option to switch to unified view (sets viewAsTopics to false) + if (chat.viewAsTopics || hasForumTabs) { + ViewController forumController; + // Check user's saved preference for forum view (tabs vs topics list) + int viewPreference = tdlib.settings().getForumViewPreference(chat.id); + boolean preferTabs = hasForumTabs && viewPreference != TdlibSettingsManager.FORUM_VIEW_TOPICS; + boolean preferTopics = !hasForumTabs || viewPreference == TdlibSettingsManager.FORUM_VIEW_TOPICS; + + if (preferTabs) { + ForumTopicTabsController tabsController = new ForumTopicTabsController(context.context(), context.tdlib()); + tabsController.setArguments(new ForumTopicTabsController.Arguments(chat)); + forumController = tabsController; + } else { + ForumTopicsController listController = new ForumTopicsController(context.context(), context.tdlib()); + listController.setArguments(new ForumTopicsController.Arguments(chat)); + forumController = listController; + } + + NavigationController nav = context.context().navigation(); + if (nav.isEmpty()) { + nav.initController(forumController); + MainController c = new MainController(context.context(), context.tdlib()); + c.getValue(); + nav.getStack().insert(c, 0); + } else { + nav.navigateTo(forumController); + } + if (params != null) { + params.onDone(); + } + return; + } + // If not hasForumTabs and not viewAsTopics, fall through to open as unified chat + } + if ((options & CHAT_OPTION_OPEN_DIRECT_MESSAGES_CHAT) != 0) { openDirectMessagesChat(context, chat, messageThread, urlOpenParameters, params::onDone); return; @@ -2493,8 +2548,15 @@ public void openMessage (final TdlibDelegate context, final TdApi.MessageLinkInf openMessage(context, messageThread.getChatId(), messageId, messageThread, openParameters); } }); + } else if (messageLink.topicId != null && messageLink.topicId.getConstructor() == TdApi.MessageTopicForum.CONSTRUCTOR) { + // Handle forum topic links - open the message within the specific topic + openChat(context, messageLink.chatId, new ChatOpenParameters() + .keepStack() + .highlightMessage(messageId) + .ensureHighlightAvailable() + .messageTopic(messageLink.topicId) + .urlOpenParameters(openParameters)); } else { - // TODO: properly handle messageLink.topicId openMessage(context, messageLink.chatId, messageId, openParameters); } } else { diff --git a/app/src/main/java/org/thunderdog/challegram/tool/Intents.java b/app/src/main/java/org/thunderdog/challegram/tool/Intents.java index d7ab7e6ace..bcb4c7ea05 100644 --- a/app/src/main/java/org/thunderdog/challegram/tool/Intents.java +++ b/app/src/main/java/org/thunderdog/challegram/tool/Intents.java @@ -692,12 +692,19 @@ public static Intent valueOfLocationReceiver (String action) { } public static Intent valueOfLocalChatId (int accountId, long localChatId, long specificMessageId) { + return valueOfLocalChatId(accountId, localChatId, specificMessageId, 0); + } + + public static Intent valueOfLocalChatId (int accountId, long localChatId, long specificMessageId, long messageThreadId) { Intent intent = new Intent(UI.getContext(), MainActivity.class); secureIntent(intent, true); intent.setAction(Intents.ACTION_OPEN_CHAT + "." + accountId + "." + localChatId + "." + Math.random()); intent.putExtra("account_id", accountId); intent.putExtra("local_id", localChatId); intent.putExtra("message_id", specificMessageId); + if (messageThreadId != 0) { + intent.putExtra("message_thread_id", messageThreadId); + } // intent.setFlags(32768); return intent; } diff --git a/app/src/main/java/org/thunderdog/challegram/ui/ChatsController.java b/app/src/main/java/org/thunderdog/challegram/ui/ChatsController.java index 7d944dad67..bfe03de54f 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/ChatsController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/ChatsController.java @@ -2823,6 +2823,15 @@ public void onChatUnreadMentionCount(final long chatId, final int unreadMentionC }); } + @Override + public void onForumUnreadTopicCountChanged (long chatId, int unreadTopicCount) { + runOnUiThreadOptional(() -> { + if (chatsView != null) { + chatsView.updateForumUnreadTopicCount(chatId); + } + }); + } + @Override public void onChatHasScheduledMessagesChanged (long chatId, boolean hasScheduledMessages) { runOnUiThreadOptional(() -> { diff --git a/app/src/main/java/org/thunderdog/challegram/ui/ForumTopicTabsController.java b/app/src/main/java/org/thunderdog/challegram/ui/ForumTopicTabsController.java new file mode 100644 index 0000000000..85bec21103 --- /dev/null +++ b/app/src/main/java/org/thunderdog/challegram/ui/ForumTopicTabsController.java @@ -0,0 +1,735 @@ +/* + * This file is a part of Telegram X + * Copyright Β© 2014 (tgx-android@pm.me) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * File created for forum topics tabs support + */ +package org.thunderdog.challegram.ui; + +import android.content.Context; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import org.drinkless.tdlib.TdApi; +import android.widget.LinearLayout; + +import org.thunderdog.challegram.R; +import org.thunderdog.challegram.core.Lang; +import org.thunderdog.challegram.navigation.BackHeaderButton; +import org.thunderdog.challegram.navigation.HeaderView; +import org.thunderdog.challegram.navigation.Menu; +import org.thunderdog.challegram.navigation.MoreDelegate; +import org.thunderdog.challegram.navigation.ViewController; +import org.thunderdog.challegram.navigation.ViewPagerController; +import org.thunderdog.challegram.navigation.ViewPagerTopView; +import tgx.td.ChatId; +import org.thunderdog.challegram.telegram.Tdlib; +import org.thunderdog.challegram.telegram.TdlibCache; +import org.thunderdog.challegram.telegram.TdlibSettingsManager; +import org.thunderdog.challegram.telegram.TdlibUi; +import org.thunderdog.challegram.tool.Screen; +import org.thunderdog.challegram.tool.UI; +import org.thunderdog.challegram.widget.ProgressComponentView; +import org.thunderdog.challegram.widget.ViewPager; + +import java.util.ArrayList; +import java.util.List; + +import me.vkryl.android.widget.FrameLayoutFix; +import me.vkryl.core.collection.IntList; + +import org.thunderdog.challegram.util.StringList; + +public class ForumTopicTabsController extends ViewPagerController implements TdlibCache.SupergroupDataChangeListener, Menu, MoreDelegate { + + public static class Arguments { + public final TdApi.Chat chat; + + public Arguments (TdApi.Chat chat) { + this.chat = chat; + } + } + + private TdApi.Chat chat; + private long chatId; + private final List topics = new ArrayList<>(); + private boolean isLoading; + private boolean hasMore; + + public ForumTopicTabsController (Context context, Tdlib tdlib) { + super(context, tdlib); + } + + @Override + public void setArguments (Arguments args) { + super.setArguments(args); + this.chat = args.chat; + this.chatId = args.chat.id; + } + + @Override + public int getId () { + return R.id.controller_forumTopicTabs; + } + + @Override + public long getChatId () { + return chatId; + } + + @Override + public CharSequence getName () { + return chat != null ? chat.title : ""; + } + + @Override + protected int getBackButton () { + return BackHeaderButton.TYPE_BACK; + } + + @Override + protected int getTitleStyle () { + return TITLE_STYLE_COMPACT; + } + + @Override + protected int getMenuButtonsWidth () { + // Width for the more button (3 dots) + return Screen.dp(56f); + } + + @Override + public boolean supportsBottomInset () { + return true; + } + + @Override + protected void onCreateView (Context context, FrameLayoutFix contentView, ViewPager pager) { + loadTopics(); + } + + private void loadTopics () { + if (isLoading) return; + isLoading = true; + + // First, try to show cached topics immediately for instant display + java.util.List cachedTopics = tdlib.getCachedForumTopics(chatId); + if (cachedTopics != null && !cachedTopics.isEmpty()) { + topics.clear(); + topics.addAll(cachedTopics); + // Rebuild the pager with cached topics immediately + notifyPagerItemPositionsChanged(); + } + + // Then refresh from network in background + tdlib.client().send(new TdApi.GetForumTopics(chatId, "", 0, 0L, 0, 100), result -> { + if (result.getConstructor() == TdApi.ForumTopics.CONSTRUCTOR) { + TdApi.ForumTopics forumTopics = (TdApi.ForumTopics) result; + tdlib.ui().post(() -> { + topics.clear(); + for (TdApi.ForumTopic topic : forumTopics.topics) { + topics.add(topic); + } + hasMore = forumTopics.nextOffsetForumTopicId != 0; + // Update cache + tdlib.updateForumTopicsCache(chatId, topics); + isLoading = false; + + // Rebuild the pager with topics + if (!topics.isEmpty()) { + notifyPagerItemPositionsChanged(); + } + }); + } else { + tdlib.ui().post(() -> isLoading = false); + } + }); + } + + @Override + protected int getPagerItemCount () { + return topics.isEmpty() ? 1 : topics.size(); + } + + @Override + protected long getPagerItemId (int position) { + if (position < topics.size()) { + return topics.get(position).info.forumTopicId; + } + return position; + } + + @Override + protected @Nullable List getPagerSectionItems () { + List items = new ArrayList<>(); + if (topics.isEmpty()) { + // Return a "Loading..." placeholder to ensure tabs are created + items.add(new ViewPagerTopView.Item(Lang.getString(R.string.LoadingTopics))); + } else { + for (TdApi.ForumTopic topic : topics) { + items.add(new ViewPagerTopView.Item(topic.info.name)); + } + } + return items; + } + + @Override + protected String[] getPagerSections () { + // Using getPagerSectionItems instead + return null; + } + + @Override + protected ViewController onCreatePagerItemForPosition (Context context, int position) { + if (topics.isEmpty()) { + // Show empty loading placeholder + return createEmptyController(context); + } + + TdApi.ForumTopic topic = topics.get(position); + MessagesController controller = new MessagesController(context, tdlib); + + MessagesController.Arguments args = new MessagesController.Arguments( + tdlib, + null, // chatList + chat, + null, // threadInfo + new TdApi.MessageTopicForum(topic.info.forumTopicId), + null // filter + ); + args.setForumTopic(topic); + controller.setArguments(args); + + return controller; + } + + private ViewController createEmptyController (Context context) { + // Return a loading controller with centered progress spinner while topics load + return new LoadingController(context, tdlib); + } + + @Override + public View getViewForApplyingOffsets () { + ViewController c = getCachedControllerForPosition(getCurrentPagerItemPosition()); + return c != null ? c.getViewForApplyingOffsets() : null; + } + + @Override + public void onPageScrolled (int position, int actualPosition, float positionOffset, int positionOffsetPixels) { + // Load more topics when scrolling near the end + if (hasMore && !isLoading && actualPosition >= topics.size() - 3) { + loadMoreTopics(); + } + } + + private void loadMoreTopics () { + if (isLoading || !hasMore) return; + isLoading = true; + + int lastTopicId = topics.isEmpty() ? 0 : topics.get(topics.size() - 1).info.forumTopicId; + + tdlib.client().send(new TdApi.GetForumTopics(chatId, "", 0, 0L, lastTopicId, 50), result -> { + if (result.getConstructor() == TdApi.ForumTopics.CONSTRUCTOR) { + TdApi.ForumTopics forumTopics = (TdApi.ForumTopics) result; + tdlib.ui().post(() -> { + for (TdApi.ForumTopic topic : forumTopics.topics) { + topics.add(topic); + } + hasMore = forumTopics.nextOffsetForumTopicId != 0; + isLoading = false; + + // Update the pager + notifyPagerItemPositionsChanged(); + }); + } else { + tdlib.ui().post(() -> isLoading = false); + } + }); + } + + // TdlibCache.SupergroupDataChangeListener implementation + @Override + public void onSupergroupUpdated (TdApi.Supergroup supergroup) { + tdlib.ui().post(() -> { + if (ChatId.toSupergroupId(chatId) == supergroup.id) { + // Check if forum mode was disabled externally or tabs layout changed + if (!supergroup.isForum && chat != null) { + // Forum mode was disabled - navigate to regular chat view + navigateBack(); + tdlib.ui().post(() -> { + if (!isDestroyed()) { + tdlib.ui().openChat(this, chat.id, new TdlibUi.ChatOpenParameters().keepStack()); + } + }); + } else if (supergroup.isForum && !supergroup.hasForumTabs && chat != null) { + // Tabs layout was disabled - switch to list controller + navigateBack(); + tdlib.ui().post(() -> { + if (!isDestroyed()) { + tdlib.ui().openChat(this, chat.id, new TdlibUi.ChatOpenParameters().keepStack()); + } + }); + } + } + }); + } + + @Override + public void onSupergroupFullUpdated (long supergroupId, TdApi.SupergroupFullInfo newSupergroupFull) { + // Not used + } + + // Menu implementation + @Override + public int getMenuId () { + return R.id.menu_search; + } + + @Override + protected int getSearchMenuId () { + return R.id.menu_clear; + } + + @Override + public void fillMenuItems (int id, HeaderView header, LinearLayout menu) { + if (id == R.id.menu_search) { + header.addMoreButton(menu, this); + } else if (id == R.id.menu_clear) { + header.addClearButton(menu, this); + } + } + + @Nullable + private TdApi.ForumTopic getCurrentTopic () { + int position = getCurrentPagerItemPosition(); + if (position >= 0 && position < topics.size()) { + return topics.get(position); + } + return null; + } + + @Override + public void onMenuItemPressed (int id, View view) { + if (id == R.id.menu_btn_clear) { + clearSearchInput(); + } else if (id == R.id.menu_btn_more) { + TdApi.ForumTopic topic = getCurrentTopic(); + + IntList ids = new IntList(8); + IntList icons = new IntList(8); + StringList strings = new StringList(8); + + // Search option + ids.append(R.id.menu_btn_search); + icons.append(R.drawable.baseline_search_24); + strings.append(R.string.Search); + + boolean canManage = canManageTopics(); + + // Topic-specific options (only if we have a valid topic) + if (topic != null) { + // Notifications (always available for members) + boolean isMuted = topic.notificationSettings != null && topic.notificationSettings.muteFor > 0; + ids.append(R.id.btn_notifications); + icons.append(isMuted ? R.drawable.baseline_notifications_off_24 : R.drawable.baseline_notifications_24); + strings.append(isMuted ? R.string.Unmute : R.string.Mute); + + // Admin-only actions + if (canManage) { + // Close/Reopen + if (topic.info.isClosed) { + ids.append(R.id.btn_reopenTopic); + icons.append(R.drawable.baseline_lock_24); + strings.append(R.string.ReopenTopic); + } else { + ids.append(R.id.btn_closeTopic); + icons.append(R.drawable.baseline_lock_24); + strings.append(R.string.CloseTopic); + } + + // Pin/Unpin + if (topic.isPinned) { + ids.append(R.id.btn_unpinTopic); + icons.append(R.drawable.deproko_baseline_pin_undo_24); + strings.append(R.string.UnpinTopic); + } else { + ids.append(R.id.btn_pinTopic); + icons.append(R.drawable.deproko_baseline_pin_24); + strings.append(R.string.PinTopic); + } + + // Edit + ids.append(R.id.btn_editTopic); + icons.append(R.drawable.baseline_edit_24); + strings.append(R.string.EditTopic); + } + } + + // Create topic option (only if user has permission) + if (canCreateTopics()) { + ids.append(R.id.btn_createTopic); + icons.append(R.drawable.baseline_add_24); + strings.append(R.string.NewTopic); + } + + // Group info option (admin only) + if (canManage) { + ids.append(R.id.btn_chatSettings); + icons.append(R.drawable.baseline_info_24); + strings.append(R.string.TabInfo); + } + + // View as topics list option (switch to ForumTopicsController) + ids.append(R.id.btn_viewAsTopics); + icons.append(R.drawable.baseline_format_list_bulleted_type_24); + strings.append(R.string.ViewAsTopics); + + // View as chat option (always available) + ids.append(R.id.btn_viewAsChat); + icons.append(R.drawable.baseline_chat_bubble_24); + strings.append(R.string.ViewAsChat); + + showMore(ids.get(), strings.get(), icons.get(), 0); + } + } + + @Override + public void onMoreItemPressed (int id) { + TdApi.ForumTopic topic = getCurrentTopic(); + + if (id == R.id.menu_btn_search) { + // Switch to ForumTopicsController which has full search functionality + ForumTopicsController listController = new ForumTopicsController(context, tdlib); + listController.setArguments(new ForumTopicsController.Arguments(chat)); + navigateTo(listController); + } else if (id == R.id.btn_createTopic) { + showCreateTopicDialog(); + } else if (id == R.id.btn_chatSettings) { + // Open chat profile/settings + ProfileController profileController = new ProfileController(context, tdlib); + profileController.setArguments(new ProfileController.Args(chat, null, false)); + navigateTo(profileController); + } else if (id == R.id.btn_viewAsTopics) { + // Save preference for topics list view + tdlib.settings().setForumViewPreference(chatId, TdlibSettingsManager.FORUM_VIEW_TOPICS); + // Switch to topics list view (ForumTopicsController) + ForumTopicsController listController = new ForumTopicsController(context, tdlib); + listController.setArguments(new ForumTopicsController.Arguments(chat)); + // Navigate and remove this controller from stack + listController.addOneShotFocusListener(() -> { + listController.destroyStackItemAt(listController.stackSize() - 2); + }); + navigateTo(listController); + } else if (id == R.id.btn_viewAsChat) { + // Set viewAsTopics to false and open as unified chat + tdlib.client().send(new TdApi.ToggleChatViewAsTopics(chatId, false), result -> { + if (result.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + tdlib.ui().post(() -> { + // Create the messages controller and replace current controller + MessagesController c = new MessagesController(context, tdlib); + c.setArguments(new MessagesController.Arguments(tdlib, null, chat, null, null, null)); + + // Navigate to messages controller and remove this controller from stack after navigation + c.addOneShotFocusListener(() -> { + // Destroy the ForumTopicTabsController from the stack (it's now at position stackSize - 2) + c.destroyStackItemAt(c.stackSize() - 2); + }); + navigateTo(c); + }); + } + }); + } else if (topic != null) { + if (id == R.id.btn_notifications) { + showTopicMuteOptions(topic); + } else if (id == R.id.btn_closeTopic) { + toggleTopicClosed(topic, true); + } else if (id == R.id.btn_reopenTopic) { + toggleTopicClosed(topic, false); + } else if (id == R.id.btn_pinTopic) { + toggleTopicPinned(topic, true); + } else if (id == R.id.btn_unpinTopic) { + toggleTopicPinned(topic, false); + } else if (id == R.id.btn_editTopic) { + editTopic(topic); + } + } + } + + private void toggleTopicPinned (TdApi.ForumTopic topic, boolean pinned) { + tdlib.client().send(new TdApi.ToggleForumTopicIsPinned(topic.info.chatId, topic.info.forumTopicId, pinned), result -> { + if (result.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + // Update local state + topic.isPinned = pinned; + } else if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.post(() -> UI.showError(result)); + } + }); + } + + private void toggleTopicClosed (TdApi.ForumTopic topic, boolean closed) { + tdlib.client().send(new TdApi.ToggleForumTopicIsClosed(topic.info.chatId, topic.info.forumTopicId, closed), result -> { + if (result.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + // Update local state + topic.info.isClosed = closed; + } else if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.post(() -> UI.showError(result)); + } + }); + } + + private void editTopic (TdApi.ForumTopic topic) { + openInputAlert( + Lang.getString(R.string.EditTopic), + Lang.getString(R.string.TopicNameHint), + R.string.Done, + R.string.Cancel, + topic.info.name, + (inputView, result) -> { + String newName = result.trim(); + if (newName.isEmpty()) { + inputView.setInErrorState(true); + return false; + } + if (newName.length() > 128) { + inputView.setInErrorState(true); + return false; + } + // Edit the topic with new name, keep existing icon + tdlib.client().send(new TdApi.EditForumTopic( + topic.info.chatId, + topic.info.forumTopicId, + newName, + false, // editIconCustomEmoji + 0 // iconCustomEmojiId (not changing) + ), result1 -> { + UI.post(() -> { + if (result1.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + // Update local topic name and refresh tabs + topic.info.name = newName; + notifyPagerItemPositionsChanged(); + } else if (result1.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.showError(result1); + } + }); + }); + return true; + }, + true + ); + } + + private void showTopicMuteOptions (TdApi.ForumTopic topic) { + boolean isMuted = topic.notificationSettings != null && topic.notificationSettings.muteFor > 0; + + IntList ids = new IntList(5); + IntList icons = new IntList(5); + ArrayList strings = new ArrayList<>(); + + if (isMuted) { + // Currently muted - show unmute option + ids.append(R.id.btn_menu_enable); + icons.append(R.drawable.baseline_notifications_24); + strings.add(Lang.getString(R.string.EnableNotifications)); + } else { + // Currently unmuted - show mute options + ids.append(R.id.btn_menu_1hour); + icons.append(R.drawable.baseline_notifications_paused_24); + strings.add(Lang.plural(R.string.MuteForXHours, 1)); + + ids.append(R.id.btn_menu_8hours); + icons.append(R.drawable.baseline_notifications_paused_24); + strings.add(Lang.plural(R.string.MuteForXHours, 8)); + + ids.append(R.id.btn_menu_2days); + icons.append(R.drawable.baseline_notifications_paused_24); + strings.add(Lang.plural(R.string.MuteForXDays, 2)); + + ids.append(R.id.btn_menu_disable); + icons.append(R.drawable.baseline_notifications_off_24); + strings.add(Lang.getString(R.string.MuteForever)); + } + + showOptions(topic.info.name, ids.get(), strings.toArray(new String[0]), null, icons.get(), (itemView, itemId) -> { + int muteFor = 0; + if (itemId == R.id.btn_menu_enable) { + muteFor = 0; // Unmute + } else if (itemId == R.id.btn_menu_1hour) { + muteFor = (int) java.util.concurrent.TimeUnit.HOURS.toSeconds(1); + } else if (itemId == R.id.btn_menu_8hours) { + muteFor = (int) java.util.concurrent.TimeUnit.HOURS.toSeconds(8); + } else if (itemId == R.id.btn_menu_2days) { + muteFor = (int) java.util.concurrent.TimeUnit.DAYS.toSeconds(2); + } else if (itemId == R.id.btn_menu_disable) { + muteFor = Integer.MAX_VALUE; // Mute forever + } + + setForumTopicMuteFor(topic, muteFor); + return true; + }); + } + + private void setForumTopicMuteFor (TdApi.ForumTopic topic, int muteFor) { + TdApi.ChatNotificationSettings settings = new TdApi.ChatNotificationSettings(); + settings.useDefaultMuteFor = (muteFor == 0); + settings.muteFor = muteFor; + settings.useDefaultSound = true; + settings.useDefaultShowPreview = true; + settings.useDefaultMuteStories = true; + settings.useDefaultStorySound = true; + settings.useDefaultDisablePinnedMessageNotifications = true; + settings.useDefaultDisableMentionNotifications = true; + + tdlib.client().send(new TdApi.SetForumTopicNotificationSettings( + topic.info.chatId, + topic.info.forumTopicId, + settings + ), result -> { + UI.post(() -> { + if (result.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + // Update local state + if (topic.notificationSettings == null) { + topic.notificationSettings = new TdApi.ChatNotificationSettings(); + } + topic.notificationSettings.muteFor = muteFor; + topic.notificationSettings.useDefaultMuteFor = (muteFor == 0); + } else if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.showError(result); + } + }); + }); + } + + private void showCreateTopicDialog () { + openInputAlert( + Lang.getString(R.string.NewTopic), + Lang.getString(R.string.TopicNameHint), + R.string.Done, + R.string.Cancel, + null, + (inputView, result) -> { + String name = result.trim(); + if (name.isEmpty()) { + inputView.setInErrorState(true); + return false; + } + if (name.length() > 128) { + inputView.setInErrorState(true); + return false; + } + createTopic(name); + return true; + }, + true + ); + } + + private void createTopic (String name) { + // Standard topic colors from Telegram + int[] topicColors = { + 0x6FB9F0, // Blue + 0xFFD67E, // Yellow + 0xCB86DB, // Purple + 0x8EEE98, // Green + 0xFF93B2, // Pink + 0xFB6F5F // Red + }; + // Pick a random color + int color = topicColors[(int) (Math.random() * topicColors.length)]; + + TdApi.ForumTopicIcon icon = new TdApi.ForumTopicIcon(color, 0); + + tdlib.client().send(new TdApi.CreateForumTopic(chatId, name, false, icon), result -> { + UI.post(() -> { + if (result.getConstructor() == TdApi.ForumTopicInfo.CONSTRUCTOR) { + // Reload topics to show the new one + loadTopics(); + } else if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.showError(result); + } + }); + }); + } + + @Override + public void destroy () { + super.destroy(); + } + + // Permission checks for topic actions + private boolean canCreateTopics () { + TdApi.ChatMemberStatus status = tdlib.chatStatus(chatId); + if (status == null) return false; + + switch (status.getConstructor()) { + case TdApi.ChatMemberStatusCreator.CONSTRUCTOR: + return true; + case TdApi.ChatMemberStatusAdministrator.CONSTRUCTOR: + return ((TdApi.ChatMemberStatusAdministrator) status).rights.canManageTopics; + case TdApi.ChatMemberStatusMember.CONSTRUCTOR: + case TdApi.ChatMemberStatusRestricted.CONSTRUCTOR: + // Check chat-level permissions + return chat != null && chat.permissions != null && chat.permissions.canCreateTopics; + default: + return false; + } + } + + private boolean canManageTopics () { + TdApi.ChatMemberStatus status = tdlib.chatStatus(chatId); + if (status == null) return false; + + switch (status.getConstructor()) { + case TdApi.ChatMemberStatusCreator.CONSTRUCTOR: + return true; + case TdApi.ChatMemberStatusAdministrator.CONSTRUCTOR: + return ((TdApi.ChatMemberStatusAdministrator) status).rights.canManageTopics; + default: + return false; + } + } + + // Loading placeholder controller shown while topics are being loaded + private static class LoadingController extends ViewController { + public LoadingController (Context context, Tdlib tdlib) { + super(context, tdlib); + } + + @Override + public int getId () { + return 0; + } + + @Override + protected View onCreateView (Context context) { + // Create a centered progress spinner + FrameLayoutFix container = new FrameLayoutFix(context); + container.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + + ProgressComponentView progressView = new ProgressComponentView(context); + progressView.initLarge(1f); + FrameLayoutFix.LayoutParams lp = new FrameLayoutFix.LayoutParams( + Screen.dp(48f), + Screen.dp(48f), + Gravity.CENTER + ); + progressView.setLayoutParams(lp); + + container.addView(progressView); + return container; + } + } +} diff --git a/app/src/main/java/org/thunderdog/challegram/ui/ForumTopicView.java b/app/src/main/java/org/thunderdog/challegram/ui/ForumTopicView.java new file mode 100644 index 0000000000..f551377597 --- /dev/null +++ b/app/src/main/java/org/thunderdog/challegram/ui/ForumTopicView.java @@ -0,0 +1,829 @@ +/* + * This file is a part of Telegram X + * Copyright Β© 2014 (tgx-android@pm.me) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * File created for forum topics support + */ +package org.thunderdog.challegram.ui; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.text.TextPaint; +import android.text.TextUtils; +import android.view.Gravity; + +import androidx.annotation.Nullable; + +import org.drinkless.tdlib.TdApi; +import org.thunderdog.challegram.R; +import org.thunderdog.challegram.config.Config; +import org.thunderdog.challegram.core.Lang; +import org.thunderdog.challegram.data.ContentPreview; +import org.thunderdog.challegram.data.TD; +import org.thunderdog.challegram.loader.ComplexReceiver; +import org.thunderdog.challegram.loader.DoubleImageReceiver; +import org.thunderdog.challegram.loader.ImageFile; +import org.thunderdog.challegram.loader.ImageReceiver; +import org.thunderdog.challegram.loader.Receiver; +import org.thunderdog.challegram.loader.gif.GifFile; +import org.thunderdog.challegram.loader.gif.GifReceiver; +import org.thunderdog.challegram.support.RippleSupport; +import org.thunderdog.challegram.telegram.Tdlib; +import org.thunderdog.challegram.telegram.TdlibEmojiManager; +import org.thunderdog.challegram.telegram.TdlibStatusManager; +import org.thunderdog.challegram.tool.DrawAlgorithms; +import org.thunderdog.challegram.theme.ColorId; +import org.thunderdog.challegram.theme.Theme; +import org.thunderdog.challegram.theme.ThemeManager; +import org.thunderdog.challegram.tool.Drawables; +import org.thunderdog.challegram.tool.Fonts; +import org.thunderdog.challegram.tool.Icons; +import org.thunderdog.challegram.tool.Paints; +import org.thunderdog.challegram.tool.Screen; +import org.thunderdog.challegram.util.text.Counter; +import org.thunderdog.challegram.util.text.Highlight; +import org.thunderdog.challegram.util.text.Text; +import org.thunderdog.challegram.util.text.TextColorSets; +import org.thunderdog.challegram.util.text.TextMedia; +import org.thunderdog.challegram.widget.BaseView; + +import me.vkryl.core.StringUtils; +import tgx.td.Td; + +public class ForumTopicView extends BaseView implements TdlibEmojiManager.Watcher, TdlibStatusManager.HelperTarget, Text.TextMediaListener { + private static TextPaint titlePaint; + private static TextPaint senderPaint; + private static TextPaint previewPaint; + private static TextPaint timePaint; + private static Paint iconPaint; + + private Tdlib tdlib; + private TdApi.ForumTopic topic; + + private String titleText; + private String senderText; + private String previewText; + private String timeText; + private Counter unreadCounter; + private Counter reactionsCounter; + private boolean isMuted; + private String highlightQuery; + + // Message status for outgoing messages + private boolean isSending; + private boolean isOutgoing; + private boolean isMessageUnread; + private boolean showingDraft; + + // Icon loading + private long customEmojiId; + private TdlibEmojiManager.Entry customEmoji; + private ImageFile thumbnail; + private ImageFile imageFile; + private GifFile gifFile; + private final ComplexReceiver iconReceiver; + + // Text media (custom emoji in preview) + private final ComplexReceiver textMediaReceiver; + + // Typing status + private TdlibStatusManager.Helper statusHelper; + private boolean isAttached; + + private static final int PADDING_LEFT = 72; + private static final int PADDING_RIGHT = 16; + private static final int ICON_SIZE = 44; + private static final int ICON_LEFT = 14; + + public ForumTopicView (Context context) { + super(context, null); + setWillNotDraw(false); + RippleSupport.setTransparentSelector(this); + initPaints(); + iconReceiver = new ComplexReceiver(this, Config.MAX_ANIMATED_EMOJI_REFRESH_RATE); + textMediaReceiver = new ComplexReceiver(this, Config.MAX_ANIMATED_EMOJI_REFRESH_RATE); + } + + private static void initPaints () { + if (titlePaint == null) { + titlePaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); + titlePaint.setTextSize(Screen.dp(16f)); + titlePaint.setTypeface(Fonts.getRobotoMedium()); + titlePaint.setColor(Theme.textAccentColor()); + ThemeManager.addThemeListener(titlePaint, ColorId.text); + + senderPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); + senderPaint.setTextSize(Screen.dp(15f)); + senderPaint.setTypeface(Fonts.getRobotoRegular()); + senderPaint.setColor(Theme.textAccentColor()); + ThemeManager.addThemeListener(senderPaint, ColorId.text); + + previewPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); + previewPaint.setTextSize(Screen.dp(15f)); + previewPaint.setTypeface(Fonts.getRobotoRegular()); + previewPaint.setColor(Theme.textDecentColor()); + ThemeManager.addThemeListener(previewPaint, ColorId.textLight); + + timePaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); + timePaint.setTextSize(Screen.dp(12f)); + timePaint.setTypeface(Fonts.getRobotoRegular()); + timePaint.setColor(Theme.textDecentColor()); + ThemeManager.addThemeListener(timePaint, ColorId.textLight); + + iconPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + } + } + + public void attach () { + iconReceiver.attach(); + textMediaReceiver.attach(); + isAttached = true; + if (statusHelper != null && topic != null) { + statusHelper.attachToChat(topic.info.chatId, new TdApi.MessageTopicForum(topic.info.forumTopicId)); + } + } + + public void detach () { + iconReceiver.detach(); + textMediaReceiver.detach(); + isAttached = false; + if (statusHelper != null) { + statusHelper.detachFromAnyChat(); + } + } + + public void destroy () { + iconReceiver.performDestroy(); + textMediaReceiver.performDestroy(); + if (customEmojiId != 0 && customEmoji == null && tdlib != null) { + tdlib.emoji().forgetWatcher(customEmojiId, this); + } + if (statusHelper != null) { + statusHelper.detachFromAnyChat(); + } + } + + // TdlibStatusManager.HelperTarget implementation + + @Override + public void layoutChatAction () { + // Typing text layout is handled in onDraw + invalidate(); + } + + @Override + public void invalidateTypingPart (boolean onlyIcon) { + invalidate(); + } + + @Override + public boolean canLoop () { + return isAttached; + } + + @Override + public boolean canAnimate () { + return isAttached; + } + + public void setTopic (Tdlib tdlib, TdApi.ForumTopic topic) { + setTopic(tdlib, topic, null); + } + + public void setTopic (Tdlib tdlib, TdApi.ForumTopic topic, @Nullable String highlightQuery) { + this.tdlib = tdlib; + this.topic = topic; + this.highlightQuery = highlightQuery; + + // Initialize status helper for typing status + if (statusHelper == null) { + statusHelper = new TdlibStatusManager.Helper(context(), tdlib, this, null); + } + // Attach to this topic for typing status + if (isAttached) { + statusHelper.attachToChat(topic.info.chatId, new TdApi.MessageTopicForum(topic.info.forumTopicId)); + } + + // Build title (no emoji prefixes - icons are drawn separately) + this.titleText = topic.info.name; + + // Check if we should show draft (draft exists with text input) + boolean hasDraft = topic.draftMessage != null && + topic.draftMessage.inputMessageText != null && + topic.draftMessage.inputMessageText.getConstructor() == TdApi.InputMessageText.CONSTRUCTOR; + + if (hasDraft) { + // Show draft preview + this.showingDraft = true; + TdApi.InputMessageText inputText = (TdApi.InputMessageText) topic.draftMessage.inputMessageText; + String draftText = inputText.text != null && !StringUtils.isEmpty(inputText.text.text) ? + inputText.text.text : ""; + this.senderText = Lang.getString(R.string.Draft); + this.previewText = draftText; + this.timeText = Lang.timeOrDateShort(topic.draftMessage.date, java.util.concurrent.TimeUnit.SECONDS); + this.isOutgoing = false; + this.isSending = false; + this.isMessageUnread = false; + } else if (topic.lastMessage != null) { + // Build preview text from last message + this.showingDraft = false; + ContentPreview preview = ContentPreview.getChatListPreview(tdlib, topic.info.chatId, topic.lastMessage, true); + String messageText = preview != null ? preview.buildText(false) : ""; + + // Sender name on separate line (like 3-line chat list mode) + if (topic.lastMessage.isOutgoing) { + this.senderText = Lang.getString(R.string.FromYou); + } else { + String senderName = tdlib.senderName(topic.lastMessage, false, false); + this.senderText = !StringUtils.isEmpty(senderName) ? senderName : ""; + } + this.previewText = messageText; + this.timeText = Lang.timeOrDateShort(topic.lastMessage.date, java.util.concurrent.TimeUnit.SECONDS); + + // Calculate message status for outgoing messages + this.isOutgoing = topic.lastMessage.isOutgoing; + this.isSending = tdlib.messageSending(topic.lastMessage); + // Message is unread if message ID > relevant last read message ID + // For outgoing: compare with lastReadOutboxMessageId (whether recipient read it) + // For incoming: compare with lastReadInboxMessageId (whether we read it) + long relevantReadId = this.isOutgoing ? topic.lastReadOutboxMessageId : topic.lastReadInboxMessageId; + this.isMessageUnread = topic.lastMessage.id > relevantReadId; + } else { + this.showingDraft = false; + this.senderText = ""; + this.previewText = ""; + this.timeText = ""; + this.isOutgoing = false; + this.isSending = false; + this.isMessageUnread = false; + } + + // Check muted state (respects useDefaultMuteFor and parent chat settings) + this.isMuted = tdlib.forumTopicNeedsMuteIcon(topic.info.chatId, topic); + + // Unread counter - pass muted state for proper badge coloring + if (topic.unreadCount > 0) { + if (unreadCounter == null) { + unreadCounter = new Counter.Builder().callback(this).build(); + } + unreadCounter.setCount(topic.unreadCount, isMuted, false); + } else { + unreadCounter = null; + } + + // Reactions counter - show if there are unread reactions + // Using same pattern as TGChat: baseline_favorite_14 icon at 16f size + if (topic.unreadReactionCount > 0) { + if (reactionsCounter == null) { + reactionsCounter = new Counter.Builder() + .drawable(R.drawable.baseline_favorite_14, 16f, 0f, Gravity.CENTER) + .callback(this) + .build(); + } + reactionsCounter.setCount(topic.unreadReactionCount, isMuted, false); + } else { + reactionsCounter = null; + } + + // Load topic icon + loadTopicIcon(); + + invalidate(); + } + + private int lastMeasuredWidth; + + @Override + protected void onMeasure (int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + int width = getMeasuredWidth(); + if (lastMeasuredWidth != width) { + lastMeasuredWidth = width; + buildTextLayouts(); + } + } + + private void buildTextLayouts () { + int width = getMeasuredWidth(); + if (width <= 0 || topic == null) { + displayTitle = null; + displaySender = null; + displayPreview = null; + return; + } + + int textLeft = Screen.dp(PADDING_LEFT); + int textRight = width - Screen.dp(PADDING_RIGHT); + + // Calculate reserved right width to prevent text overlap with time/status/counters + int reservedRightWidth = Screen.dp(12f); // Base padding + if (!StringUtils.isEmpty(timeText)) { + reservedRightWidth += (int) timePaint.measureText(timeText); + } + if (isOutgoing) { + reservedRightWidth += Screen.dp(22f); // Status icon width + } + if (unreadCounter != null) { + reservedRightWidth += (int) unreadCounter.getWidth() + Screen.dp(4f); + } + if (reactionsCounter != null) { + reservedRightWidth += (int) reactionsCounter.getWidth() + Screen.dp(4f); + } + if (isMuted) { + reservedRightWidth += Screen.dp(18f); // Mute icon space + } + + int availWidth = textRight - textLeft - reservedRightWidth; + + // Build title Text with emoji support (4-parameter constructor for String) + if (!StringUtils.isEmpty(titleText)) { + Highlight highlight = !StringUtils.isEmpty(highlightQuery) ? Highlight.valueOf(titleText, highlightQuery) : null; + displayTitle = new Text.Builder( + titleText, + availWidth, + Paints.robotoStyleProvider(16f), + TextColorSets.Regular.NORMAL + ).singleLine() + .highlight(highlight) + .allBold() + .ignoreNewLines() + .build(); + } else { + displayTitle = null; + } + + // Build sender Text with emoji support (4-parameter constructor for String) + if (!StringUtils.isEmpty(senderText)) { + displaySender = new Text.Builder( + senderText, + availWidth, + Paints.robotoStyleProvider(15f), + showingDraft ? TextColorSets.Regular.NEGATIVE : TextColorSets.Regular.NORMAL + ).singleLine() + .ignoreNewLines() + .build(); + } else { + displaySender = null; + } + + // Build preview Text with custom emoji support + if (previewFormattedText != null && !StringUtils.isEmpty(previewFormattedText.text)) { + displayPreview = new Text.Builder( + tdlib, + previewFormattedText, + null, // urlOpenParameters + availWidth, + Paints.robotoStyleProvider(15f), + TextColorSets.Regular.LIGHT, + this // textMediaListener for custom emoji loading + ).singleLine() + .ignoreNewLines() + .build(); + } else if (!StringUtils.isEmpty(previewText)) { + displayPreview = new Text.Builder( + previewText, + availWidth, + Paints.robotoStyleProvider(15f), + TextColorSets.Regular.LIGHT + ).singleLine() + .ignoreNewLines() + .build(); + } else { + displayPreview = null; + } + + // Request text media for custom emoji + requestTextMedia(); + } + + /** + * Sets the topic view to display a message search result. + */ + public void setMessageSearchResult (Tdlib tdlib, TdApi.ForumTopic topic, TdApi.Message foundMessage, String highlightQuery) { + setTopic(tdlib, topic, highlightQuery); + } + + private void loadTopicIcon () { + TdApi.ForumTopicIcon icon = topic.info.icon; + if (icon == null) { + return; + } + + // Clear previous files + imageFile = null; + gifFile = null; + thumbnail = null; + iconReceiver.clear(); + + if (icon.customEmojiId != 0) { + // Custom emoji icon - request loading + this.customEmojiId = icon.customEmojiId; + this.customEmoji = tdlib.emoji().findOrPostponeRequest(customEmojiId, this); + if (customEmoji != null && !customEmoji.isNotFound()) { + buildCustomEmojiIcon(customEmoji); + } else { + // Trigger loading of postponed emoji requests + tdlib.emoji().performPostponedRequests(); + } + } else { + this.customEmojiId = 0; + this.customEmoji = null; + } + + requestIconFiles(); + } + + private void buildCustomEmojiIcon (TdlibEmojiManager.Entry entry) { + TdApi.Sticker sticker = entry.value; + if (sticker == null) return; + + int size = Screen.dp(ICON_SIZE); + + // Thumbnail + thumbnail = TD.toImageFile(tdlib, sticker.thumbnail); + if (thumbnail != null) { + thumbnail.setSize(size); + thumbnail.setScaleType(ImageFile.FIT_CENTER); + thumbnail.setNoBlur(); + } + + // Main image/animation + switch (sticker.format.getConstructor()) { + case TdApi.StickerFormatTgs.CONSTRUCTOR: + case TdApi.StickerFormatWebm.CONSTRUCTOR: { + this.gifFile = new GifFile(tdlib, sticker); + this.gifFile.setScaleType(GifFile.FIT_CENTER); + this.gifFile.setOptimizationMode(GifFile.OptimizationMode.EMOJI); + this.gifFile.setRequestedSize(size); + break; + } + case TdApi.StickerFormatWebp.CONSTRUCTOR: { + this.imageFile = new ImageFile(tdlib, sticker.sticker); + this.imageFile.setSize(size); + this.imageFile.setScaleType(ImageFile.FIT_CENTER); + this.imageFile.setNoBlur(); + break; + } + } + } + + private void requestIconFiles () { + DoubleImageReceiver preview = iconReceiver.getPreviewReceiver(0); + preview.requestFile(null, thumbnail); + if (imageFile != null) { + iconReceiver.getImageReceiver(0).requestFile(imageFile); + } else if (gifFile != null) { + iconReceiver.getGifReceiver(0).requestFile(gifFile); + } + } + + @Override + public void onCustomEmojiLoaded (TdlibEmojiManager context, TdlibEmojiManager.Entry entry) { + this.customEmoji = entry; + if (!entry.isNotFound()) { + buildCustomEmojiIcon(entry); + } + // Request files on UI thread + if (tdlib != null) { + tdlib.ui().post(() -> { + requestIconFiles(); + invalidate(); + }); + } + } + + @Override + protected void onDraw (Canvas canvas) { + if (topic == null) return; + + int width = getMeasuredWidth(); + int height = getMeasuredHeight(); + + // Draw topic icon + int iconLeft = Screen.dp(ICON_LEFT); + int iconSize = Screen.dp(ICON_SIZE); + int iconCenterX = iconLeft + iconSize / 2; + int iconCenterY = height / 2; + int iconRadius = iconSize / 2; + + TdApi.ForumTopicIcon icon = topic.info.icon; + if (icon != null) { + boolean hasLoadedEmoji = customEmojiId != 0 && customEmoji != null && !customEmoji.isNotFound(); + + // Draw colored circle background only if no custom emoji loaded + if (!hasLoadedEmoji) { + iconPaint.setColor(getTopicColor(icon.color)); + canvas.drawCircle(iconCenterX, iconCenterY, iconRadius, iconPaint); + } + + // Draw custom emoji icon if available + if (hasLoadedEmoji) { + drawCustomEmojiIcon(canvas, iconLeft, iconCenterY - iconRadius, iconLeft + iconSize, iconCenterY + iconRadius); + } else if (customEmojiId == 0) { + // No custom emoji - draw first letter + drawLetterIcon(canvas, iconCenterX, iconCenterY); + } + // If customEmojiId != 0 but not loaded yet, just show colored circle + } + + // Calculate text bounds + int textLeft = Screen.dp(PADDING_LEFT); + int textRight = width - Screen.dp(PADDING_RIGHT); + + // Draw time on the right + float timeWidth = 0; + float statusIconWidth = 0; + if (!StringUtils.isEmpty(timeText)) { + timeWidth = timePaint.measureText(timeText); + canvas.drawText(timeText, textRight - timeWidth, Screen.dp(28f), timePaint); + + // Draw status icon for outgoing messages (to the left of time) + if (isOutgoing) { + float iconY = Screen.dp(28f); + if (isSending) { + // Clock icon for sending messages + int iconX = (int) (textRight - timeWidth - Screen.dp(4f) - Screen.dp(Icons.CLOCK_SHIFT_X) - Screen.dp(10f)); + Drawables.draw(canvas, Icons.getClockIcon(ColorId.iconLight), iconX, iconY - Screen.dp(Icons.CLOCK_SHIFT_Y) - Screen.dp(10f), Paints.getIconLightPorterDuffPaint()); + statusIconWidth = Screen.dp(14f); + } else { + // Single tick for sent, double tick for read + int iconX = (int) (textRight - timeWidth - Screen.dp(4f) - Screen.dp(Icons.TICKS_SHIFT_X) - Screen.dp(14f)); + Drawable tickIcon = isMessageUnread ? Icons.getSingleTick(ColorId.ticks) : Icons.getDoubleTick(ColorId.ticks); + Paint tickPaint = isMessageUnread ? Paints.getTicksPaint() : Paints.getTicksReadPaint(); + Drawables.draw(canvas, tickIcon, iconX, iconY - Screen.dp(Icons.TICKS_SHIFT_Y) - Screen.dp(10f), tickPaint); + statusIconWidth = Screen.dp(18f); + } + } + } + + // Calculate right offset for icons that appear after title + float rightOffset = timeWidth + statusIconWidth + Screen.dp(8f); + if (isMuted) { + rightOffset += Screen.dp(18f); // Space for mute icon + } + + // Draw title with emoji support + int titleRight = (int) (textRight - rightOffset); + if (displayTitle != null) { + int titleY = Screen.dp(12f); + displayTitle.draw(canvas, textLeft, titleY); + + // Draw mute icon right after title text + if (isMuted && displayTitle.getWidth() > 0) { + int muteIconX = textLeft + displayTitle.getWidth() + Screen.dp(4f); + int muteIconY = titleY - Screen.dp(1f); + Drawable muteIcon = Drawables.get(getResources(), R.drawable.deproko_baseline_notifications_off_24); + if (muteIcon != null) { + int muteIconSize = Screen.dp(14f); + muteIcon.setBounds(muteIconX, muteIconY, muteIconX + muteIconSize, muteIconY + muteIconSize); + muteIcon.setColorFilter(Theme.getColor(ColorId.iconLight), android.graphics.PorterDuff.Mode.SRC_IN); + muteIcon.draw(canvas); + } + } + } + + // Draw counters on the right side + int previewRight = textRight; + float counterCenterY = height / 2 + Screen.dp(12f); + + // Draw lock icon if topic is closed and has no unread messages + boolean showLockIcon = topic.info.isClosed && (unreadCounter == null || topic.unreadCount == 0); + if (showLockIcon) { + int lockIconSize = Screen.dp(18f); + int lockIconX = textRight - lockIconSize; + int lockIconY = (int) counterCenterY - lockIconSize / 2; + Drawable lockIcon = Drawables.get(getResources(), R.drawable.deproko_baseline_lock_24); + if (lockIcon != null) { + lockIcon.setBounds(lockIconX, lockIconY, lockIconX + lockIconSize, lockIconY + lockIconSize); + lockIcon.setColorFilter(Theme.getColor(ColorId.iconLight), android.graphics.PorterDuff.Mode.SRC_IN); + lockIcon.draw(canvas); + } + previewRight -= (int) (lockIconSize + Screen.dp(4f)); + } + + // Draw unread counter (rightmost) + if (unreadCounter != null) { + float counterWidth = unreadCounter.getWidth(); + previewRight -= (int) (counterWidth + Screen.dp(8f)); + unreadCounter.draw(canvas, textRight - counterWidth / 2, counterCenterY, Gravity.CENTER, 1f); + textRight -= (int) (counterWidth + Screen.dp(4f)); + } + + // Draw reactions counter (to the left of unread counter) + if (reactionsCounter != null) { + float counterWidth = reactionsCounter.getWidth(); + previewRight -= (int) (counterWidth + Screen.dp(4f)); + int textColorId = isMuted ? ColorId.badgeMutedText : ColorId.badgeText; + reactionsCounter.draw(canvas, textRight - counterWidth / 2, counterCenterY, Gravity.CENTER, 1f, this, textColorId); + textRight -= (int) (counterWidth + Screen.dp(4f)); + } + + // Check if we should show typing status instead of sender/preview text + TdlibStatusManager.ChatState typingState = statusHelper != null ? statusHelper.drawingState() : null; + float senderY = Screen.dp(46f); // Row 2: Sender name + float previewY = Screen.dp(64f); // Row 3: Message preview + + if (typingState != null) { + // Draw typing status on row 2 (sender position) + String typingText = statusHelper.fullText(); + if (!StringUtils.isEmpty(typingText)) { + float textCenterY = senderY - Screen.dp(6f); + // Draw typing animation icon + int iconWidth = DrawAlgorithms.drawStatus(canvas, typingState, textLeft, textCenterY, Theme.getColor(ColorId.textLight), this, ColorId.textLight); + // Draw typing text + String ellipsizedTyping = TextUtils.ellipsize(typingText, senderPaint, previewRight - textLeft - iconWidth, TextUtils.TruncateAt.END).toString(); + canvas.drawText(ellipsizedTyping, textLeft + iconWidth, senderY, senderPaint); + } + } else { + // Row 2: Draw sender name (white/accent color) + if (!StringUtils.isEmpty(senderText)) { + if (showingDraft) { + // Draw "Draft" in red + int savedColor = senderPaint.getColor(); + senderPaint.setColor(Theme.textRedColor()); + String ellipsizedSender = TextUtils.ellipsize(senderText, senderPaint, previewRight - textLeft, TextUtils.TruncateAt.END).toString(); + canvas.drawText(ellipsizedSender, textLeft, senderY, senderPaint); + senderPaint.setColor(savedColor); + } else { + String ellipsizedSender = TextUtils.ellipsize(senderText, senderPaint, previewRight - textLeft, TextUtils.TruncateAt.END).toString(); + canvas.drawText(ellipsizedSender, textLeft, senderY, senderPaint); + } + } + + // Row 3: Draw message preview with custom emoji support + if (displayPreview != null) { + // Convert baseline to top position (previewY is baseline at 64dp, top is ~52dp) + int previewTop = (int) previewY - Screen.dp(12f); + displayPreview.draw(canvas, textLeft, previewTop, null, 1f, textMediaReceiver); + } + } + + // Draw separator line at bottom + canvas.drawLine(textLeft, height - 1, width, height - 1, Paints.strokeSeparatorPaint(ColorId.separator)); + } + + private void drawCustomEmojiIcon (Canvas canvas, int left, int top, int right, int bottom) { + // Check if we need to apply color filter for themed stickers + boolean needRepainting = customEmoji != null && TD.needThemedColorFilter(customEmoji.value); + + Receiver content; + if (imageFile != null) { + ImageReceiver image = iconReceiver.getImageReceiver(0); + image.setBounds(left, top, right, bottom); + content = image; + } else if (gifFile != null) { + GifReceiver gif = iconReceiver.getGifReceiver(0); + gif.setBounds(left, top, right, bottom); + content = gif; + } else { + content = null; + } + + DoubleImageReceiver preview = content == null || content.needPlaceholder() ? iconReceiver.getPreviewReceiver(0) : null; + if (preview != null) { + if (needRepainting) { + preview.setThemedPorterDuffColorId(ColorId.icon); + } else { + preview.disablePorterDuffColorFilter(); + } + preview.setBounds(left, top, right, bottom); + preview.draw(canvas); + } + if (content != null) { + if (needRepainting) { + content.setThemedPorterDuffColorId(ColorId.icon); + } else { + content.disablePorterDuffColorFilter(); + } + content.draw(canvas); + } + } + + private void drawLetterIcon (Canvas canvas, int centerX, int centerY) { + // For General topic (id = 1), show hash symbol "#" instead of letter + // This matches Telegram for Android (TGA) behavior + String displayChar; + if (topic.info.forumTopicId == 1) { + displayChar = "#"; + } else if (!StringUtils.isEmpty(topic.info.name)) { + displayChar = topic.info.name.substring(0, 1).toUpperCase(); + } else { + return; + } + + TextPaint letterPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG); + letterPaint.setColor(0xFFFFFFFF); + letterPaint.setTextSize(Screen.dp(20f)); + letterPaint.setTypeface(Fonts.getRobotoMedium()); + letterPaint.setTextAlign(Paint.Align.CENTER); + Paint.FontMetrics fm = letterPaint.getFontMetrics(); + float textY = centerY - (fm.ascent + fm.descent) / 2; + canvas.drawText(displayChar, centerX, textY, letterPaint); + } + + private int getTopicColor (int colorValue) { + // Telegram topic colors are passed as actual color values like 0x6FB9F0 + // If color is 0 or very small, it's likely a color index (old format) + if (colorValue > 0x00FFFFFF) { + // It's already an actual color value with alpha + return colorValue; + } else if (colorValue >= 0x100000) { + // It's a color without alpha - add full opacity + return 0xFF000000 | colorValue; + } + + // Fallback: treat as color index for backwards compatibility + int[] colors = { + 0xFF6FB9F0, // Blue + 0xFFFFD67E, // Yellow + 0xFFCB86DB, // Purple + 0xFF8EEE98, // Green + 0xFFFF93B2, // Pink + 0xFFFB6F5F // Red + }; + if (colorValue >= 0 && colorValue < colors.length) { + return colors[colorValue]; + } + return colors[0]; + } + + private void drawHighlightedText (Canvas canvas, String text, float x, float y, TextPaint paint, String query) { + if (StringUtils.isEmpty(query)) { + canvas.drawText(text, x, y, paint); + return; + } + + String lowerText = text.toLowerCase(); + String lowerQuery = query.toLowerCase(); + int matchStart = lowerText.indexOf(lowerQuery); + + if (matchStart < 0) { + // No match found - draw normal text + canvas.drawText(text, x, y, paint); + return; + } + + int matchEnd = matchStart + query.length(); + + // Draw text before highlight + if (matchStart > 0) { + String beforeMatch = text.substring(0, matchStart); + canvas.drawText(beforeMatch, x, y, paint); + x += paint.measureText(beforeMatch); + } + + // Draw highlighted part with background + String matchedText = text.substring(matchStart, matchEnd); + float matchWidth = paint.measureText(matchedText); + + // Draw highlight background + Paint.FontMetrics fm = paint.getFontMetrics(); + RectF highlightRect = new RectF( + x - Screen.dp(1f), + y + fm.ascent - Screen.dp(1f), + x + matchWidth + Screen.dp(1f), + y + fm.descent + Screen.dp(1f) + ); + Paint highlightPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + highlightPaint.setColor(Theme.getColor(ColorId.textSearchQueryHighlight)); + canvas.drawRoundRect(highlightRect, Screen.dp(2f), Screen.dp(2f), highlightPaint); + + // Draw highlighted text + canvas.drawText(matchedText, x, y, paint); + x += matchWidth; + + // Draw text after highlight + if (matchEnd < text.length()) { + String afterMatch = text.substring(matchEnd); + canvas.drawText(afterMatch, x, y, paint); + } + } + + // Text.TextMediaListener implementation + @Override + public void onInvalidateTextMedia (Text text, @Nullable TextMedia specificMedia) { + if (text == displayPreview) { + invalidate(); + } + } + + private void requestTextMedia () { + if (displayPreview != null && displayPreview.hasMedia()) { + textMediaReceiver.clear(); + displayPreview.requestMedia(textMediaReceiver); + } else { + textMediaReceiver.clear(); + } + } +} diff --git a/app/src/main/java/org/thunderdog/challegram/ui/ForumTopicsController.java b/app/src/main/java/org/thunderdog/challegram/ui/ForumTopicsController.java new file mode 100644 index 0000000000..64d051dc81 --- /dev/null +++ b/app/src/main/java/org/thunderdog/challegram/ui/ForumTopicsController.java @@ -0,0 +1,1951 @@ +/* + * This file is a part of Telegram X + * Copyright Β© 2014 (tgx-android@pm.me) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * File created for forum topics support + */ +package org.thunderdog.challegram.ui; + +import android.content.Context; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.drinkless.tdlib.TdApi; +import org.thunderdog.challegram.R; +import org.thunderdog.challegram.component.attach.CustomItemAnimator; +import org.thunderdog.challegram.component.chat.ChatHeaderView; +import org.thunderdog.challegram.component.chat.MessagesManager; +import org.thunderdog.challegram.core.Lang; +import org.thunderdog.challegram.navigation.BackHeaderButton; +import org.thunderdog.challegram.navigation.HeaderView; +import org.thunderdog.challegram.navigation.Menu; +import org.thunderdog.challegram.navigation.MoreDelegate; +import org.thunderdog.challegram.navigation.TelegramViewController; +import org.thunderdog.challegram.support.ViewSupport; +import tgx.td.ChatId; +import org.thunderdog.challegram.telegram.ChatListener; +import org.thunderdog.challegram.telegram.MessageListener; +import org.thunderdog.challegram.telegram.Tdlib; +import org.thunderdog.challegram.telegram.TdlibCache; +import org.thunderdog.challegram.telegram.TdlibSettingsManager; +import org.thunderdog.challegram.telegram.TdlibUi; +import org.thunderdog.challegram.theme.ColorId; +import org.thunderdog.challegram.tool.Screen; +import org.thunderdog.challegram.tool.UI; +import org.thunderdog.challegram.v.CustomRecyclerView; +import org.thunderdog.challegram.widget.CircleButton; +import org.thunderdog.challegram.widget.ListInfoView; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import me.vkryl.android.AnimatorUtils; +import me.vkryl.android.widget.FrameLayoutFix; +import me.vkryl.core.StringUtils; +import me.vkryl.core.collection.IntList; +import org.thunderdog.challegram.util.StringList; +import org.thunderdog.challegram.navigation.SettingsWrapBuilder; +import org.thunderdog.challegram.util.TopicIconModifier; +import android.util.SparseIntArray; + +import tgx.td.MessageId; + +public class ForumTopicsController extends TelegramViewController implements + Menu, MoreDelegate, View.OnClickListener, View.OnLongClickListener, + ChatListener, MessageListener, TdlibCache.SupergroupDataChangeListener, ChatHeaderView.Callback { + + public static class Arguments { + public final long chatId; + public final TdApi.Chat chat; + + public Arguments (long chatId, @Nullable TdApi.Chat chat) { + this.chatId = chatId; + this.chat = chat; + } + + public Arguments (TdApi.Chat chat) { + this.chatId = chat.id; + this.chat = chat; + } + } + + public ForumTopicsController (Context context, Tdlib tdlib) { + super(context, tdlib); + } + + private long chatId; + private TdApi.Chat chat; + + private FrameLayoutFix contentView; + private CustomRecyclerView recyclerView; + private ForumTopicsAdapter adapter; + private ListInfoView emptyView; + private CircleButton createTopicButton; + private ChatHeaderView headerCell; + + private List topics; + private List allTopics; // For restoring after search + private boolean isLoading; + private boolean canLoadMore; + private boolean isSubscribedToUpdates; + private String currentSearchQuery; + private boolean searchInMessages = true; // Toggle between topic name search and message search (default: messages) + private List messageSearchResults = new ArrayList<>(); + private List unfilteredMessageResults = new ArrayList<>(); // Store original unfiltered results + private boolean isSearchingMessages = false; + private long lastSearchMessageId = 0; // For message search pagination + private boolean canLoadMoreMessages = false; + private java.util.Set selectedFilterTopicIds = new java.util.HashSet<>(); // Empty = all topics + private CircleButton filterTopicButton; + private boolean navigatedFromSearch = false; // Track if we navigated to a topic from search results + + // Multi-page search loading + private static final int PAGES_PER_LOAD = 10; // Load 10 pages at a time for better topic filtering + private static final int RESULTS_PER_PAGE = 100; + private static final int MAX_AUTO_RETRY = 3; // Max auto-retry when filtered results empty + private int pendingPageLoads = 0; // Track remaining pages to load + private List pendingMessages = new ArrayList<>(); // Collect results from all pages + private String pendingSearchQuery = null; // Query for current batch + private int filterAutoRetryCount = 0; // Track auto-retry attempts + private java.util.Set pendingFilterTopicIds = null; // Filter to apply after loading more + + @Override + protected boolean allowLeavingSearchMode () { + // Prevent leaving search mode if we navigated to a search result and are returning + if (navigatedFromSearch && searchInMessages && !messageSearchResults.isEmpty()) { + return false; + } + return true; + } + + @Override + public void setArguments (Arguments args) { + super.setArguments(args); + this.chatId = args.chatId; + this.chat = args.chat; + } + + @Override + public int getId () { + return R.id.controller_forumTopics; + } + + @Override + public CharSequence getName () { + if (chat != null) { + return chat.title; + } + return Lang.getString(R.string.Topics); + } + + @Override + protected int getBackButton () { + return BackHeaderButton.TYPE_BACK; + } + + @Override + public View getCustomHeaderCell () { + return headerCell; + } + + @Override + protected int getMenuId () { + return R.id.menu_search; + } + + @Override + protected int getSearchMenuId () { + return R.id.menu_clear; + } + + @Override + public void fillMenuItems (int id, HeaderView header, LinearLayout menu) { + if (id == R.id.menu_search) { + header.addMoreButton(menu, this); + header.addSearchButton(menu, this); + } else if (id == R.id.menu_clear) { + // Add toggle button before clear button (left of clear) + // baseline_chat_bubble_24 for messages mode, baseline_forum_24 for topics mode + header.addButton(menu, R.id.btn_searchModeToggle, + searchInMessages ? R.drawable.baseline_chat_bubble_24 : R.drawable.baseline_forum_24, + getHeaderIconColorId(), this, Screen.dp(49f)); + header.addClearButton(menu, this); + } + } + + @Override + public void onMenuItemPressed (int id, View view) { + if (id == R.id.menu_btn_search) { + openSearchMode(); + } else if (id == R.id.menu_btn_clear) { + clearSearchInput(); + } else if (id == R.id.btn_searchModeToggle) { + toggleSearchMode(); + } else if (id == R.id.menu_btn_more) { + IntList ids = new IntList(2); + IntList icons = new IntList(2); + StringList strings = new StringList(2); + + // View as tabs option (switch to ForumTopicTabsController) + ids.append(R.id.btn_viewAsTabs); + icons.append(R.drawable.baseline_swap_horiz_24); + strings.append(R.string.ViewAsTabs); + + ids.append(R.id.btn_viewAsChat); + icons.append(R.drawable.baseline_chat_bubble_24); + strings.append(R.string.ViewAsChat); + + showMore(ids.get(), strings.get(), icons.get(), 0); + } + } + + @Override + protected void onLeaveSearchMode () { + navigatedFromSearch = false; // Reset navigation flag when leaving search + currentSearchQuery = null; + searchInMessages = true; // Reset to default (messages mode) + messageSearchResults.clear(); + unfilteredMessageResults.clear(); + selectedFilterTopicIds.clear(); + updateFilterFabVisibility(); + if (allTopics != null) { + topics.clear(); + topics.addAll(allTopics); + adapter.setTopics(topics, null); + updateEmptyView(); + } + } + + @Override + protected void onSearchInputChanged (String query) { + super.onSearchInputChanged(query); + String cleanQuery = query != null ? query.trim() : ""; + if (StringUtils.equalsOrBothEmpty(cleanQuery, currentSearchQuery)) { + return; + } + currentSearchQuery = cleanQuery; + if (searchInMessages) { + searchMessages(cleanQuery); + } else { + searchTopics(cleanQuery); + } + } + + private void toggleSearchMode () { + searchInMessages = !searchInMessages; + // Update toggle button icon + if (headerView != null) { + headerView.updateButton(R.id.menu_clear, R.id.btn_searchModeToggle, View.VISIBLE, + searchInMessages ? R.drawable.baseline_chat_bubble_24 : R.drawable.baseline_forum_24); + } + + // Reset filter when switching modes + selectedFilterTopicIds.clear(); + unfilteredMessageResults.clear(); + + // Re-search with the same query + if (!StringUtils.isEmpty(currentSearchQuery)) { + if (searchInMessages) { + searchMessages(currentSearchQuery); + } else { + searchTopics(currentSearchQuery); + } + } else { + // No query - restore original list if in topic mode, or show empty in message mode + if (!searchInMessages && allTopics != null) { + topics.clear(); + topics.addAll(allTopics); + adapter.setTopics(topics, null); + updateEmptyView(); + } else if (searchInMessages) { + messageSearchResults.clear(); + adapter.setMessageSearchResults(messageSearchResults, null); + updateEmptyView(); + } + } + + // Update filter FAB visibility based on new mode + updateFilterFabVisibility(); + } + + private void searchTopics (String query) { + if (StringUtils.isEmpty(query)) { + // Empty query - restore all topics + if (allTopics != null) { + topics.clear(); + topics.addAll(allTopics); + adapter.setTopics(topics, null); + updateEmptyView(); + } + return; + } + + // Save current topics before search if not already saved + if (allTopics == null || allTopics.isEmpty()) { + allTopics = new ArrayList<>(topics); + } + + // Client-side filtering by topic name (case-insensitive) + String lowerQuery = query.toLowerCase(); + List filteredTopics = new ArrayList<>(); + for (TdApi.ForumTopic topic : allTopics) { + if (topic.info.name.toLowerCase().contains(lowerQuery)) { + filteredTopics.add(topic); + } + } + + topics.clear(); + topics.addAll(filteredTopics); + adapter.setTopics(topics, query); + updateEmptyView(); + } + + private void searchMessages (String query) { + if (StringUtils.isEmpty(query)) { + messageSearchResults.clear(); + lastSearchMessageId = 0; + canLoadMoreMessages = false; + adapter.setMessageSearchResults(messageSearchResults, null); + updateEmptyView(); + return; + } + + if (isSearchingMessages) { + return; // Already searching + } + isSearchingMessages = true; + + // Reset pagination for new search + lastSearchMessageId = 0; + canLoadMoreMessages = false; + messageSearchResults.clear(); + + // Reset multi-page loading state + pendingMessages.clear(); + pendingPageLoads = PAGES_PER_LOAD; + pendingSearchQuery = query; + + // Show loading state + emptyView.setVisibility(View.VISIBLE); + emptyView.showInfo(Lang.getString(R.string.LoadingTopics)); + setClearButtonSearchInProgress(true); + + // Start loading first page (will chain-load remaining pages) + loadMessagePage(query, 0, false); + } + + private void loadMessagePage (String query, long fromMessageId, boolean isAppending) { + tdlib.client().send(new TdApi.SearchChatMessages( + chatId, + null, // topicId - null to search all topics + query, + null, // senderId + fromMessageId, // fromMessageId for pagination + 0, // offset + RESULTS_PER_PAGE, // limit + null // filter + ), result -> { + if (result.getConstructor() == TdApi.FoundChatMessages.CONSTRUCTOR) { + TdApi.FoundChatMessages foundMessages = (TdApi.FoundChatMessages) result; + + // Collect messages from this page + synchronized (pendingMessages) { + java.util.Collections.addAll(pendingMessages, foundMessages.messages); + } + + pendingPageLoads--; + + // Store pagination cursor for later "load more" + lastSearchMessageId = foundMessages.nextFromMessageId; + + // Continue loading more pages if available and within limit + if (foundMessages.nextFromMessageId != 0 && pendingPageLoads > 0) { + loadMessagePage(query, foundMessages.nextFromMessageId, isAppending); + } else { + // All pages loaded (or no more results) + canLoadMoreMessages = foundMessages.nextFromMessageId != 0; + finalizeBatchSearch(query, isAppending); + } + } else { + // Error - finalize with what we have + canLoadMoreMessages = false; + finalizeBatchSearch(query, isAppending); + } + }); + } + + private void finalizeBatchSearch (String query, boolean isAppending) { + // Convert collected messages to array and process + TdApi.Message[] messages; + synchronized (pendingMessages) { + messages = pendingMessages.toArray(new TdApi.Message[0]); + pendingMessages.clear(); + } + + // Check if there's a pending filter to apply after processing + final java.util.Set filterToApply = pendingFilterTopicIds; + + processMessageSearchResults(messages, query, isAppending); + + // Re-apply pending filter if any (for auto-retry scenario) + if (filterToApply != null && !filterToApply.isEmpty()) { + UI.post(() -> applyTopicFilter(filterToApply, true)); + } + } + + private void loadMoreMessages () { + if (isSearchingMessages || !canLoadMoreMessages || StringUtils.isEmpty(currentSearchQuery)) { + return; + } + isSearchingMessages = true; + + // Reset multi-page loading state for batch load + pendingMessages.clear(); + pendingPageLoads = PAGES_PER_LOAD; + pendingSearchQuery = currentSearchQuery; + + // Start loading from last position (will chain-load remaining pages) + loadMessagePage(currentSearchQuery, lastSearchMessageId, true); + } + + private void processMessageSearchResults (TdApi.Message[] messages, String query, boolean append) { + // Flat list: show ALL messages, not grouped by topic + if (messages.length == 0) { + UI.post(() -> { + isSearchingMessages = false; + setClearButtonSearchInProgress(false); + if (!append) { + messageSearchResults.clear(); + adapter.setMessageSearchResults(messageSearchResults, query); + } + updateEmptyViewForMessageSearch(); + }); + return; + } + + // Collect unique topic IDs to fetch + Map topicCache = new HashMap<>(); + java.util.Set topicIdsToFetch = new java.util.HashSet<>(); + + // First pass: check cache and collect IDs to fetch + for (TdApi.Message message : messages) { + long topicId = 0; + if (message.topicId != null && message.topicId instanceof TdApi.MessageTopicForum) { + topicId = ((TdApi.MessageTopicForum) message.topicId).forumTopicId; + } + if (topicId != 0 && !topicCache.containsKey(topicId)) { + // Check cached allTopics first + TdApi.ForumTopic cachedTopic = null; + if (allTopics != null) { + for (TdApi.ForumTopic t : allTopics) { + if (t.info.forumTopicId == topicId) { + cachedTopic = t; + break; + } + } + } + if (cachedTopic != null) { + topicCache.put(topicId, cachedTopic); + } else { + topicIdsToFetch.add(topicId); + } + } + } + + // If all topics are cached, build results directly + if (topicIdsToFetch.isEmpty()) { + buildFlatMessageResults(messages, topicCache, query, append); + return; + } + + // Fetch missing topics + int[] pending = {topicIdsToFetch.size()}; + boolean finalAppend = append; + for (Long topicId : topicIdsToFetch) { + tdlib.client().send(new TdApi.GetForumTopic(chatId, topicId.intValue()), topicResult -> { + if (topicResult.getConstructor() == TdApi.ForumTopic.CONSTRUCTOR) { + TdApi.ForumTopic topic = (TdApi.ForumTopic) topicResult; + synchronized (topicCache) { + topicCache.put(topicId, topic); + } + } + pending[0]--; + if (pending[0] == 0) { + buildFlatMessageResults(messages, topicCache, query, finalAppend); + } + }); + } + } + + private void buildFlatMessageResults (TdApi.Message[] messages, Map topicCache, String query, boolean append) { + List results = new ArrayList<>(); + for (TdApi.Message message : messages) { + long topicId = 0; + if (message.topicId != null && message.topicId instanceof TdApi.MessageTopicForum) { + topicId = ((TdApi.MessageTopicForum) message.topicId).forumTopicId; + } + TdApi.ForumTopic topic = topicCache.get(topicId); + if (topic != null) { + results.add(new TopicMessageSearchResult(topic, message, query)); + } + } + finalizeMessageSearchResults(results, query, append); + } + + private void finalizeMessageSearchResults (List results, String query, boolean append) { + UI.post(() -> { + isSearchingMessages = false; + setClearButtonSearchInProgress(false); + if (!append) { + // New search - store in unfiltered list and reset filter + unfilteredMessageResults.clear(); + unfilteredMessageResults.addAll(results); + selectedFilterTopicIds.clear(); + messageSearchResults.clear(); + messageSearchResults.addAll(results); + } else { + // Pagination - add to unfiltered list + unfilteredMessageResults.addAll(results); + // If filter is active, only add results that match + if (!selectedFilterTopicIds.isEmpty()) { + for (TopicMessageSearchResult result : results) { + long topicId = result.topic.info.forumTopicId; + if (selectedFilterTopicIds.contains(topicId)) { + messageSearchResults.add(result); + } + } + } else { + messageSearchResults.addAll(results); + } + } + + int startPosition = append ? messageSearchResults.size() - results.size() : 0; + if (append && !results.isEmpty()) { + // Notify only about new items for better performance + int insertedCount = (!selectedFilterTopicIds.isEmpty()) + ? (int) results.stream().filter(r -> selectedFilterTopicIds.contains((long) r.topic.info.forumTopicId)).count() + : results.size(); + if (insertedCount > 0) { + adapter.notifyItemRangeInserted(messageSearchResults.size() - insertedCount, insertedCount); + } + } else { + adapter.setMessageSearchResults(messageSearchResults, query); + } + updateEmptyViewForMessageSearch(); + + // Show filter FAB when there are results + updateFilterFabVisibility(); + }); + } + + private void updateEmptyViewForMessageSearch () { + if (messageSearchResults.isEmpty()) { + emptyView.setVisibility(View.VISIBLE); + emptyView.showInfo(Lang.getString(R.string.NoMessagesFound)); + } else { + emptyView.setVisibility(View.GONE); + } + } + + private void updateFilterFabVisibility () { + if (filterTopicButton == null) return; + + // Show filter FAB only when in message search mode with results + boolean shouldShow = searchInMessages && !unfilteredMessageResults.isEmpty() && inSearchMode(); + filterTopicButton.setVisibility(shouldShow ? View.VISIBLE : View.GONE); + + // Reset FAB color if filter was cleared + if (!shouldShow || selectedFilterTopicIds.isEmpty()) { + filterTopicButton.init(R.drawable.baseline_tune_24, 56f, 4f, ColorId.circleButtonRegular, ColorId.circleButtonRegularIcon); + } + } + + @Override + public boolean performOnBackPressed (boolean fromTop, boolean commit) { + if (inSearchMode()) { + if (commit) { + closeSearchMode(null); + } + return true; + } + return super.performOnBackPressed(fromTop, commit); + } + + @Override + protected View onCreateView (Context context) { + contentView = new FrameLayoutFix(context); + contentView.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + ViewSupport.setThemedBackground(contentView, ColorId.filling); + + // Create header view for clickable chat header + headerCell = new ChatHeaderView(context, tdlib, this); + headerCell.setCallback(this); + // Set inner margins to prevent title overlap with menu buttons + // Right margin: 48dp (more button) + 48dp (search button) + 8dp (padding) = 104dp + headerCell.setInnerMargins(Screen.dp(56f), Screen.dp(104f)); + if (chat != null) { + headerCell.setChat(tdlib, chat, null, null); + } + + topics = new ArrayList<>(); + adapter = new ForumTopicsAdapter(this); + + recyclerView = new CustomRecyclerView(context); + recyclerView.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + recyclerView.setLayoutManager(new LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)); + recyclerView.setAdapter(adapter); + recyclerView.setItemAnimator(new CustomItemAnimator(AnimatorUtils.DECELERATE_INTERPOLATOR, 180l)); + recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled (@NonNull RecyclerView recyclerView, int dx, int dy) { + LinearLayoutManager manager = (LinearLayoutManager) recyclerView.getLayoutManager(); + if (manager == null) return; + + int lastVisible = manager.findLastVisibleItemPosition(); + + // Handle message search pagination + if (searchInMessages && canLoadMoreMessages && !isSearchingMessages) { + if (lastVisible >= messageSearchResults.size() - 5) { + loadMoreMessages(); + } + } + // Handle topic list pagination + else if (!searchInMessages && canLoadMore && !isLoading) { + if (lastVisible >= topics.size() - 5) { + loadMoreTopics(); + } + } + } + }); + + emptyView = new ListInfoView(context); + emptyView.setLayoutParams(FrameLayoutFix.newParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + emptyView.showInfo(Lang.getString(R.string.LoadingTopics)); + + contentView.addView(emptyView); + contentView.addView(recyclerView); + + // Create topic FAB button + int padding = Screen.dp(4f); + FrameLayoutFix.LayoutParams fabParams = FrameLayoutFix.newParams( + Screen.dp(56f) + padding * 2, + Screen.dp(56f) + padding * 2, + Gravity.RIGHT | Gravity.BOTTOM + ); + fabParams.rightMargin = fabParams.bottomMargin = Screen.dp(16f) - padding; + + createTopicButton = new CircleButton(context); + createTopicButton.setId(R.id.btn_createTopic); + createTopicButton.setOnClickListener(this); + createTopicButton.init(R.drawable.baseline_add_24, 56f, 4f, ColorId.circleButtonRegular, ColorId.circleButtonRegularIcon); + createTopicButton.setLayoutParams(fabParams); + addThemeInvalidateListener(createTopicButton); + contentView.addView(createTopicButton); + // Hide FAB if user can't create topics + createTopicButton.setVisibility(canCreateTopics() ? View.VISIBLE : View.GONE); + + // Filter topic FAB button (positioned above create button) + FrameLayoutFix.LayoutParams filterFabParams = FrameLayoutFix.newParams( + Screen.dp(56f) + padding * 2, + Screen.dp(56f) + padding * 2, + Gravity.RIGHT | Gravity.BOTTOM + ); + filterFabParams.rightMargin = Screen.dp(16f) - padding; + filterFabParams.bottomMargin = Screen.dp(80f) - padding; // Above create button + + filterTopicButton = new CircleButton(context); + filterTopicButton.setId(R.id.btn_filterTopic); + filterTopicButton.setOnClickListener(this); + filterTopicButton.init(R.drawable.baseline_tune_24, 56f, 4f, ColorId.circleButtonRegular, ColorId.circleButtonRegularIcon); + filterTopicButton.setLayoutParams(filterFabParams); + addThemeInvalidateListener(filterTopicButton); + contentView.addView(filterTopicButton); + // Initially hidden - show only during message search mode + filterTopicButton.setVisibility(View.GONE); + + loadTopics(); + + return contentView; + } + + @Override + public void destroy () { + super.destroy(); + if (isSubscribedToUpdates) { + tdlib.listeners().unsubscribeFromChatUpdates(chatId, this); + tdlib.listeners().unsubscribeFromMessageUpdates(chatId, this); + isSubscribedToUpdates = false; + } + } + + @Override + public void onMoreItemPressed (int id) { + if (id == R.id.btn_viewAsTabs) { + // Save preference for tabs view + tdlib.settings().setForumViewPreference(chatId, TdlibSettingsManager.FORUM_VIEW_TABS); + // Switch to tabs view (ForumTopicTabsController) + ForumTopicTabsController tabsController = new ForumTopicTabsController(context, tdlib); + tabsController.setArguments(new ForumTopicTabsController.Arguments(chat)); + // Navigate and remove this controller from stack + tabsController.addOneShotFocusListener(() -> { + tabsController.destroyStackItemAt(tabsController.stackSize() - 2); + }); + navigateTo(tabsController); + } else if (id == R.id.btn_viewAsChat) { + // Set viewAsTopics to false and open as unified chat + tdlib.client().send(new TdApi.ToggleChatViewAsTopics(chatId, false), result -> { + if (result.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + tdlib.ui().post(() -> { + // Create MessagesController directly to prevent re-opening in tabs mode + MessagesController messagesController = new MessagesController(context, tdlib); + messagesController.setArguments(new MessagesController.Arguments(null, chat, null, null, null, 0, null)); + // Remove this controller from stack after transition + messagesController.addOneShotFocusListener(() -> { + messagesController.destroyStackItemAt(messagesController.stackSize() - 2); + }); + navigateTo(messagesController); + }); + } + }); + } + } + + @Override + public void onChatHeaderClick () { + if (chat != null) { + ProfileController controller = new ProfileController(context, tdlib); + controller.setShareCustomHeaderView(true); + controller.setArguments(new ProfileController.Args(chat, null, false)); + navigateTo(controller); + } + } + + private void loadTopics () { + if (isLoading) return; + isLoading = true; + + if (!isSubscribedToUpdates) { + tdlib.listeners().subscribeToChatUpdates(chatId, this); + tdlib.listeners().subscribeToMessageUpdates(chatId, this); + isSubscribedToUpdates = true; + } + + // First, try to show cached topics immediately for instant display + List cachedTopics = tdlib.getCachedForumTopics(chatId); + if (cachedTopics != null && !cachedTopics.isEmpty()) { + topics.clear(); + topics.addAll(cachedTopics); + allTopics = new ArrayList<>(topics); + adapter.setTopics(topics, null); + updateEmptyView(); + } + + // Then refresh from network in background + tdlib.client().send(new TdApi.GetForumTopics(chatId, "", 0, 0, 0, 100), result -> { + if (result.getConstructor() == TdApi.ForumTopics.CONSTRUCTOR) { + TdApi.ForumTopics forumTopics = (TdApi.ForumTopics) result; + UI.post(() -> { + topics.clear(); + for (TdApi.ForumTopic topic : forumTopics.topics) { + topics.add(topic); + } + // Save all topics for search restore + allTopics = new ArrayList<>(topics); + canLoadMore = forumTopics.topics.length > 0 && + forumTopics.nextOffsetMessageId != 0; + // Update cache + tdlib.updateForumTopicsCache(chatId, topics); + adapter.setTopics(topics, null); + isLoading = false; + updateEmptyView(); + }); + } else { + UI.post(() -> { + isLoading = false; + updateEmptyView(); + }); + } + }); + } + + private void loadMoreTopics () { + if (isLoading || !canLoadMore || topics.isEmpty()) return; + isLoading = true; + + TdApi.ForumTopic lastTopic = topics.get(topics.size() - 1); + long lastMessageId = lastTopic.lastMessage != null ? lastTopic.lastMessage.id : 0; + int lastDate = lastTopic.lastMessage != null ? lastTopic.lastMessage.date : 0; + + tdlib.client().send(new TdApi.GetForumTopics(chatId, "", lastDate, lastMessageId, lastTopic.info.forumTopicId, 100), result -> { + if (result.getConstructor() == TdApi.ForumTopics.CONSTRUCTOR) { + TdApi.ForumTopics forumTopics = (TdApi.ForumTopics) result; + UI.post(() -> { + for (TdApi.ForumTopic topic : forumTopics.topics) { + topics.add(topic); + } + canLoadMore = forumTopics.topics.length > 0 && + forumTopics.nextOffsetMessageId != 0; + adapter.setTopics(topics, null); + isLoading = false; + }); + } else { + UI.post(() -> { + isLoading = false; + }); + } + }); + } + + private void updateEmptyView () { + if (topics.isEmpty()) { + emptyView.setVisibility(View.VISIBLE); + emptyView.showInfo(Lang.getString(R.string.NoTopics)); + } else { + emptyView.setVisibility(View.GONE); + } + } + + @Override + public void onClick (View v) { + int id = v.getId(); + if (id == R.id.btn_createTopic) { + showCreateTopicDialog(); + return; + } + if (id == R.id.btn_filterTopic) { + showTopicFilterOptions(); + return; + } + Object tag = v.getTag(); + if (tag instanceof TopicMessageSearchResult) { + TopicMessageSearchResult result = (TopicMessageSearchResult) tag; + openTopicAtMessage(result.topic, result.foundMessage); + } else if (tag instanceof TdApi.ForumTopic) { + TdApi.ForumTopic topic = (TdApi.ForumTopic) tag; + openTopic(topic); + } + } + + @Override + public boolean onLongClick (View v) { + Object tag = v.getTag(); + if (tag instanceof TdApi.ForumTopic) { + TdApi.ForumTopic topic = (TdApi.ForumTopic) tag; + showTopicOptions(topic); + return true; + } + return false; + } + + private void openTopic (TdApi.ForumTopic topic) { + MessagesController controller = new MessagesController(context, tdlib); + + // Calculate highlight position based on topic's last read message + MessageId highlightMessageId = null; + int highlightMode = MessagesManager.HIGHLIGHT_MODE_NONE; + + if (topic.unreadCount > 0 && topic.lastReadInboxMessageId != 0) { + // There are unread messages - scroll to first unread + highlightMessageId = new MessageId(chatId, topic.lastReadInboxMessageId); + highlightMode = MessagesManager.HIGHLIGHT_MODE_UNREAD; + } else if (topic.lastReadInboxMessageId == 0 && topic.unreadCount > 0) { + // No messages have been read yet - scroll to beginning + highlightMessageId = new MessageId(chatId, MessageId.MIN_VALID_ID); + highlightMode = MessagesManager.HIGHLIGHT_MODE_UNREAD; + } + // If all messages are read (unreadCount == 0), highlightMessageId stays null + // which will open at the bottom (most recent messages) + + MessagesController.Arguments args = new MessagesController.Arguments( + null, // chatList + chat, + null, // threadInfo - will use topicId instead + new TdApi.MessageTopicForum(topic.info.forumTopicId), + highlightMessageId, + highlightMode, + null // filter + ); + args.setForumTopic(topic); + controller.setArguments(args); + navigateTo(controller); + } + + private void openTopicAtMessage (TdApi.ForumTopic topic, TdApi.Message message) { + MessagesController controller = new MessagesController(context, tdlib); + + // Navigate to the specific found message with highlight + MessageId highlightMessageId = new MessageId(chatId, message.id); + int highlightMode = MessagesManager.HIGHLIGHT_MODE_NORMAL; + + MessagesController.Arguments args = new MessagesController.Arguments( + null, // chatList + chat, + null, // threadInfo - will use topicId instead + new TdApi.MessageTopicForum(topic.info.forumTopicId), + highlightMessageId, + highlightMode, + null // filter + ); + args.setForumTopic(topic); + controller.setArguments(args); + navigatedFromSearch = true; // Mark that we're navigating from search results + navigateTo(controller); + } + + private void showTopicOptions (TdApi.ForumTopic topic) { + IntList ids = new IntList(5); + IntList icons = new IntList(5); + IntList colors = new IntList(5); + ArrayList strings = new ArrayList<>(); + + boolean canManage = canManageTopics(); + + // Admin-only: Pin/Unpin + if (canManage) { + if (topic.isPinned) { + ids.append(R.id.btn_unpinTopic); + icons.append(R.drawable.deproko_baseline_pin_undo_24); + colors.append(OptionColor.NORMAL); + strings.add(Lang.getString(R.string.UnpinTopic)); + } else { + ids.append(R.id.btn_pinTopic); + icons.append(R.drawable.deproko_baseline_pin_24); + colors.append(OptionColor.NORMAL); + strings.add(Lang.getString(R.string.PinTopic)); + } + + // Admin-only: Close/Reopen + if (topic.info.isClosed) { + ids.append(R.id.btn_reopenTopic); + icons.append(R.drawable.baseline_lock_24); + colors.append(OptionColor.NORMAL); + strings.add(Lang.getString(R.string.ReopenTopic)); + } else { + ids.append(R.id.btn_closeTopic); + icons.append(R.drawable.baseline_lock_24); + colors.append(OptionColor.NORMAL); + strings.add(Lang.getString(R.string.CloseTopic)); + } + } + + // Notifications (always available for members) + boolean isMuted = topic.notificationSettings != null && topic.notificationSettings.muteFor > 0; + ids.append(R.id.btn_notifications); + icons.append(isMuted ? R.drawable.baseline_notifications_off_24 : R.drawable.baseline_notifications_24); + colors.append(OptionColor.NORMAL); + strings.add(Lang.getString(isMuted ? R.string.Unmute : R.string.Mute)); + + // Admin-only: Edit + if (canManage) { + ids.append(R.id.btn_editTopic); + icons.append(R.drawable.baseline_edit_24); + colors.append(OptionColor.NORMAL); + strings.add(Lang.getString(R.string.EditTopic)); + + // Admin-only: Change Icon (not for General topic) + if (!topic.info.isGeneral) { + ids.append(R.id.btn_editTopicIcon); + icons.append(R.drawable.baseline_palette_24); + colors.append(OptionColor.NORMAL); + strings.add(Lang.getString(R.string.ChangeTopicIcon)); + } + + // Admin-only: Delete + if (!topic.info.isGeneral) { + ids.append(R.id.btn_deleteTopic); + icons.append(R.drawable.baseline_delete_24); + colors.append(OptionColor.RED); + strings.add(Lang.getString(R.string.DeleteTopic)); + } + } + + showOptions(topic.info.name, ids.get(), strings.toArray(new String[0]), colors.get(), icons.get(), (itemView, id) -> { + if (id == R.id.btn_pinTopic) { + toggleTopicPinned(topic, true); + } else if (id == R.id.btn_unpinTopic) { + toggleTopicPinned(topic, false); + } else if (id == R.id.btn_closeTopic) { + toggleTopicClosed(topic, true); + } else if (id == R.id.btn_reopenTopic) { + toggleTopicClosed(topic, false); + } else if (id == R.id.btn_notifications) { + showTopicMuteOptions(topic); + } else if (id == R.id.btn_editTopic) { + editTopic(topic); + } else if (id == R.id.btn_editTopicIcon) { + showTopicIconPicker(topic); + } else if (id == R.id.btn_deleteTopic) { + deleteTopic(topic); + } + return true; + }); + } + + private void toggleTopicPinned (TdApi.ForumTopic topic, boolean pinned) { + tdlib.client().send(new TdApi.ToggleForumTopicIsPinned(topic.info.chatId, topic.info.forumTopicId, pinned), result -> { + if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.post(() -> UI.showError(result)); + } + }); + } + + private void toggleTopicClosed (TdApi.ForumTopic topic, boolean closed) { + tdlib.client().send(new TdApi.ToggleForumTopicIsClosed(topic.info.chatId, topic.info.forumTopicId, closed), result -> { + if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.post(() -> UI.showError(result)); + } + }); + } + + private void editTopic (TdApi.ForumTopic topic) { + openInputAlert( + Lang.getString(R.string.EditTopic), + Lang.getString(R.string.TopicNameHint), + R.string.Done, + R.string.Cancel, + topic.info.name, + (inputView, result) -> { + String newName = result.trim(); + if (newName.isEmpty()) { + inputView.setInErrorState(true); + return false; + } + if (newName.length() > 128) { + inputView.setInErrorState(true); + return false; + } + // Edit the topic with new name, keep existing icon + tdlib.client().send(new TdApi.EditForumTopic( + topic.info.chatId, + topic.info.forumTopicId, + newName, + false, // editIconCustomEmoji + 0 // iconCustomEmojiId (not changing) + ), result1 -> { + UI.post(() -> { + if (result1.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + // Topic edited successfully, will be updated via listener + } else if (result1.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.showError(result1); + } + }); + }); + return true; + }, + true + ); + } + + private void showTopicIconPicker (TdApi.ForumTopic topic) { + // First, load available default topic icons from TDLib + tdlib.client().send(new TdApi.GetForumTopicDefaultIcons(), result -> { + UI.post(() -> { + if (result.getConstructor() == TdApi.Stickers.CONSTRUCTOR) { + TdApi.Stickers stickers = (TdApi.Stickers) result; + showTopicIconPickerWithStickers(topic, stickers.stickers); + } else if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.showError(result); + } + }); + }); + } + + private void showTopicIconPickerWithStickers (TdApi.ForumTopic topic, TdApi.Sticker[] stickers) { + // Build options list + // First option: Reset to colored circle (if topic has custom emoji) + boolean hasCustomEmoji = topic.info.icon != null && topic.info.icon.customEmojiId != 0; + + IntList ids = new IntList(15); + ArrayList strings = new ArrayList<>(); + IntList iconsList = new IntList(15); + + if (hasCustomEmoji) { + ids.append(R.id.btn_resetIcon); + strings.add(Lang.getString(R.string.ResetTopicIcon)); + iconsList.append(R.drawable.baseline_undo_24); + } + + // Add sticker options (limit to reasonable number) + int maxStickers = Math.min(stickers.length, 12); + for (int i = 0; i < maxStickers; i++) { + ids.append(R.id.btn_stickerIcon0 + i); + // Use emoji as label if available + strings.add(stickers[i].emoji != null ? stickers[i].emoji : "Icon " + (i + 1)); + iconsList.append(0); // No drawable icon, uses text + } + + showOptions( + Lang.getString(R.string.ChangeTopicIcon), + ids.get(), + strings.toArray(new String[0]), + null, + iconsList.get(), + (itemView, id) -> { + if (id == R.id.btn_resetIcon) { + // Reset to colored circle + setTopicIcon(topic, 0); + } else { + // Set custom emoji icon + int stickerIndex = id - R.id.btn_stickerIcon0; + if (stickerIndex >= 0 && stickerIndex < stickers.length) { + // The sticker id is the custom emoji identifier for custom emoji stickers + setTopicIcon(topic, stickers[stickerIndex].id); + } + } + return true; + } + ); + } + + private void setTopicIcon (TdApi.ForumTopic topic, long customEmojiId) { + tdlib.client().send(new TdApi.EditForumTopic( + topic.info.chatId, + topic.info.forumTopicId, + topic.info.name, + true, // editIconCustomEmoji + customEmojiId // 0 = reset to colored circle, non-zero = custom emoji + ), result -> { + UI.post(() -> { + if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.showError(result); + } + }); + }); + } + + private void deleteTopic (TdApi.ForumTopic topic) { + showConfirm(Lang.getStringBold(R.string.DeleteTopicConfirm, topic.info.name), Lang.getString(R.string.Delete), R.drawable.baseline_delete_24, OptionColor.RED, () -> { + tdlib.client().send(new TdApi.DeleteForumTopic(topic.info.chatId, topic.info.forumTopicId), result -> { + if (result.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + // Remove from local list immediately + UI.post(() -> { + for (int i = 0; i < topics.size(); i++) { + if (topics.get(i).info.forumTopicId == topic.info.forumTopicId) { + topics.remove(i); + adapter.notifyItemRemoved(i); + updateEmptyView(); + break; + } + } + }); + } else if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.post(() -> UI.showError(result)); + } + }); + }); + } + + private void showTopicFilterOptions () { + // Use all topics from allTopics list + if (allTopics == null || allTopics.isEmpty()) { + return; // No topics to filter by + } + + List availableTopics = allTopics; + + // Track current selections - start with all topics if no filter, or current filter + final java.util.Set currentSelections = new java.util.HashSet<>(); + if (selectedFilterTopicIds.isEmpty()) { + // No filter = all topics selected + for (TdApi.ForumTopic topic : availableTopics) { + long topicId = topic.info.forumTopicId; + currentSelections.add(topicId); + } + } else { + currentSelections.addAll(selectedFilterTopicIds); + } + + // Build checkbox items for multi-select + List items = new ArrayList<>(availableTopics.size() + 2); + items.add(new ListItem(ListItem.TYPE_PADDING).setHeight(Screen.dp(12f)).setBoolValue(true)); + + // Add each topic as a checkbox option with topic icon + for (TdApi.ForumTopic topic : availableTopics) { + long topicId = topic.info.forumTopicId; + boolean isSelected = currentSelections.contains(topicId); + // Use TopicIconModifier to draw the actual topic icon (colored circle or custom emoji) + TopicIconModifier iconModifier = new TopicIconModifier(tdlib, topic.info.icon); + // Icon is drawn on the left by the modifier, add padding for icon space + String displayName = " " + topic.info.name; + items.add(new ListItem( + ListItem.TYPE_CHECKBOX_OPTION, + (int) topicId, // id + 0, // icon + displayName, // string with space prefix for icon + (int) topicId, // checkId (same as id for multi-select) + isSelected + ).setLongValue(topicId).setDrawModifier(iconModifier)); + } + + items.add(new ListItem(ListItem.TYPE_PADDING).setHeight(Screen.dp(12f)).setBoolValue(true)); + + final int totalTopicCount = availableTopics.size(); + + SettingsWrapBuilder b = new SettingsWrapBuilder(R.id.btn_filterTopic) + .addHeaderItem(Lang.getString(R.string.FilterByTopic)) + .setRawItems(items) + .setSaveStr(Lang.getString(R.string.Done)) + .setNeedSeparators(false) + .setSettingProcessor((item, view, isUpdate) -> { + // Apply the DrawModifier to render topic icons + view.setDrawModifier(item.getDrawModifier()); + }) + .setOnSettingItemClick((view, settingsId, item, doneButton, settingsAdapter, window) -> { + // Update our tracked selections when checkbox is toggled + if (item.getViewType() == ListItem.TYPE_CHECKBOX_OPTION) { + long topicId = item.getLongValue(); + if (currentSelections.contains(topicId)) { + currentSelections.remove(topicId); + } else { + currentSelections.add(topicId); + } + } + }) + .setIntDelegate((id, result) -> { + // Use our tracked selections instead of the result array + if (currentSelections.size() == totalTopicCount) { + // All selected = no filter + applyTopicFilter(new java.util.HashSet<>()); + } else { + applyTopicFilter(new java.util.HashSet<>(currentSelections)); + } + }); + + showSettings(b); + } + + // Map topic color to a Unicode circle emoji for display in filter dialog + private String getTopicColorEmoji (int colorValue) { + // Telegram topic default colors mapped to emoji circles + // Blue 0x6FB9F0, Yellow 0xFFD67E, Purple 0xCB86DB, Green 0x8EEE98, Pink 0xFF93B2, Red 0xFB6F5F + + // Normalize color (remove alpha if present) + int color = colorValue & 0x00FFFFFF; + + // Check for custom emoji (iconCustomEmojiId != 0) - use white circle as default + if (colorValue == 0) { + return "\u26AA"; // White circle + } + + // Map based on hue - determine closest match + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + + // Simple color matching based on dominant channel + if (b > r && b > g) { + return "\uD83D\uDD35"; // Blue circle + } else if (r > g && r > b && g > b * 0.8) { + // Yellow/Orange (high red and green, low blue) + return "\uD83D\uDFE1"; // Yellow circle + } else if (r > b && g > b && Math.abs(r - g) < 50) { + // Could be yellow or green - check green dominance + if (g > r) { + return "\uD83D\uDFE2"; // Green circle + } + return "\uD83D\uDFE1"; // Yellow circle + } else if (g > r && g > b) { + return "\uD83D\uDFE2"; // Green circle + } else if (r > g && b > g * 0.5) { + // Purple/Pink (high red and blue) + if (b > r * 0.7) { + return "\uD83D\uDFE3"; // Purple circle + } + return "\uD83D\uDD34"; // Red circle (for pink) + } else if (r > g && r > b) { + return "\uD83D\uDD34"; // Red circle + } + + return "\u26AA"; // White circle as fallback + } + + private void applyTopicFilter (java.util.Set topicIds) { + applyTopicFilter(topicIds, false); + } + + private void applyTopicFilter (java.util.Set topicIds, boolean isAutoRetry) { + selectedFilterTopicIds = topicIds; + + // Reset retry counter on manual filter change + if (!isAutoRetry) { + filterAutoRetryCount = 0; + } + + // Update FAB appearance based on filter state + if (filterTopicButton != null) { + if (!topicIds.isEmpty()) { + // Filter is active - use accent color + filterTopicButton.init(R.drawable.baseline_tune_24, 56f, 4f, ColorId.circleButtonActive, ColorId.circleButtonActiveIcon); + } else { + // No filter - regular color + filterTopicButton.init(R.drawable.baseline_tune_24, 56f, 4f, ColorId.circleButtonRegular, ColorId.circleButtonRegularIcon); + } + } + + if (topicIds.isEmpty()) { + // Show all results + messageSearchResults.clear(); + messageSearchResults.addAll(unfilteredMessageResults); + } else { + // Filter to only show messages from selected topics + messageSearchResults.clear(); + for (TopicMessageSearchResult result : unfilteredMessageResults) { + long topicId = result.topic.info.forumTopicId; // Cast int to long for Set contains check + if (topicIds.contains(topicId)) { + messageSearchResults.add(result); + } + } + } + + // Auto-retry: if filtered results are empty but more messages available, load more + if (messageSearchResults.isEmpty() && !topicIds.isEmpty() && canLoadMoreMessages && filterAutoRetryCount < MAX_AUTO_RETRY) { + filterAutoRetryCount++; + pendingFilterTopicIds = new java.util.HashSet<>(topicIds); + + // Show "searching deeper" message + emptyView.setVisibility(View.VISIBLE); + emptyView.showInfo(Lang.getString(R.string.LoadingTopics) + "..."); + + // Load more pages + loadMoreMessages(); + return; + } + + // Clear pending filter + pendingFilterTopicIds = null; + + adapter.setMessageSearchResults(messageSearchResults, currentSearchQuery); + updateEmptyViewForMessageSearch(); + } + + private void showTopicMuteOptions (TdApi.ForumTopic topic) { + boolean isMuted = topic.notificationSettings != null && topic.notificationSettings.muteFor > 0; + + IntList ids = new IntList(5); + IntList icons = new IntList(5); + ArrayList strings = new ArrayList<>(); + + if (isMuted) { + // Currently muted - show unmute option + ids.append(R.id.btn_menu_enable); + icons.append(R.drawable.baseline_notifications_24); + strings.add(Lang.getString(R.string.EnableNotifications)); + } else { + // Currently unmuted - show mute options + ids.append(R.id.btn_menu_1hour); + icons.append(R.drawable.baseline_notifications_paused_24); + strings.add(Lang.plural(R.string.MuteForXHours, 1)); + + ids.append(R.id.btn_menu_8hours); + icons.append(R.drawable.baseline_notifications_paused_24); + strings.add(Lang.plural(R.string.MuteForXHours, 8)); + + ids.append(R.id.btn_menu_2days); + icons.append(R.drawable.baseline_notifications_paused_24); + strings.add(Lang.plural(R.string.MuteForXDays, 2)); + + ids.append(R.id.btn_menu_disable); + icons.append(R.drawable.baseline_notifications_off_24); + strings.add(Lang.getString(R.string.MuteForever)); + } + + showOptions(topic.info.name, ids.get(), strings.toArray(new String[0]), null, icons.get(), (itemView, id) -> { + int muteFor = 0; + if (id == R.id.btn_menu_enable) { + muteFor = 0; // Unmute + } else if (id == R.id.btn_menu_1hour) { + muteFor = (int) java.util.concurrent.TimeUnit.HOURS.toSeconds(1); + } else if (id == R.id.btn_menu_8hours) { + muteFor = (int) java.util.concurrent.TimeUnit.HOURS.toSeconds(8); + } else if (id == R.id.btn_menu_2days) { + muteFor = (int) java.util.concurrent.TimeUnit.DAYS.toSeconds(2); + } else if (id == R.id.btn_menu_disable) { + muteFor = Integer.MAX_VALUE; // Mute forever + } + setForumTopicMuteFor(topic, muteFor); + return true; + }); + } + + private void setForumTopicMuteFor (TdApi.ForumTopic topic, int muteFor) { + TdApi.ChatNotificationSettings settings = new TdApi.ChatNotificationSettings(); + settings.useDefaultMuteFor = (muteFor == 0); + settings.muteFor = muteFor; + settings.useDefaultSound = true; + settings.useDefaultShowPreview = true; + settings.useDefaultMuteStories = true; + settings.useDefaultStorySound = true; + settings.useDefaultDisablePinnedMessageNotifications = true; + settings.useDefaultDisableMentionNotifications = true; + + tdlib.client().send(new TdApi.SetForumTopicNotificationSettings( + topic.info.chatId, + topic.info.forumTopicId, + settings + ), result -> { + UI.post(() -> { + if (result.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + // Update local state and refresh UI + if (topic.notificationSettings == null) { + topic.notificationSettings = new TdApi.ChatNotificationSettings(); + } + topic.notificationSettings.muteFor = muteFor; + topic.notificationSettings.useDefaultMuteFor = (muteFor == 0); + // Refresh the topic in the list + for (int i = 0; i < topics.size(); i++) { + if (topics.get(i).info.forumTopicId == topic.info.forumTopicId) { + adapter.notifyItemChanged(i); + break; + } + } + } else if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.showError(result); + } + }); + }); + } + + private void showCreateTopicDialog () { + openInputAlert( + Lang.getString(R.string.NewTopic), + Lang.getString(R.string.TopicNameHint), + R.string.Done, + R.string.Cancel, + null, + (inputView, result) -> { + String name = result.trim(); + if (name.isEmpty()) { + inputView.setInErrorState(true); + return false; + } + if (name.length() > 128) { + inputView.setInErrorState(true); + return false; + } + createTopic(name); + return true; + }, + true + ); + } + + private void createTopic (String name) { + // Standard topic colors from Telegram + int[] topicColors = { + 0x6FB9F0, // Blue + 0xFFD67E, // Yellow + 0xCB86DB, // Purple + 0x8EEE98, // Green + 0xFF93B2, // Pink + 0xFB6F5F // Red + }; + // Pick a random color + int color = topicColors[(int) (Math.random() * topicColors.length)]; + + TdApi.ForumTopicIcon icon = new TdApi.ForumTopicIcon(color, 0); + + tdlib.client().send(new TdApi.CreateForumTopic(chatId, name, false, icon), result -> { + UI.post(() -> { + if (result.getConstructor() == TdApi.ForumTopicInfo.CONSTRUCTOR) { + TdApi.ForumTopicInfo info = (TdApi.ForumTopicInfo) result; + // Reload topics to show the new one + loadTopics(); + } else if (result.getConstructor() == TdApi.Error.CONSTRUCTOR) { + UI.showError(result); + } + }); + }); + } + + // ChatListener (ForumTopicInfoListener) implementation + @Override + public void onForumTopicInfoChanged (TdApi.ForumTopicInfo info) { + if (info.chatId != chatId) return; + UI.post(() -> { + for (int i = 0; i < topics.size(); i++) { + if (topics.get(i).info.forumTopicId == info.forumTopicId) { + topics.get(i).info = info; + adapter.notifyItemChanged(i); + break; + } + } + }); + } + + @Override + public void onForumTopicUpdated (long chatId, long messageThreadId, boolean isPinned, long lastReadInboxMessageId, long lastReadOutboxMessageId, int unreadMentionCount, int unreadReactionCount, TdApi.ChatNotificationSettings notificationSettings, TdApi.DraftMessage draftMessage) { + if (chatId != this.chatId || topics == null) return; + + // Find if read state changed - if so, we need to fetch fresh unread count + boolean needFetchUnreadCount = false; + int topicIndex = -1; + for (int i = 0; i < topics.size(); i++) { + TdApi.ForumTopic topic = topics.get(i); + if (topic.info.forumTopicId == messageThreadId) { + topicIndex = i; + if (topic.lastReadInboxMessageId != lastReadInboxMessageId) { + needFetchUnreadCount = true; + } + break; + } + } + + if (topicIndex < 0) return; // Topic not found in list + + final int foundIndex = topicIndex; + // Capture the draft from callback - it may be more recent than server data + final TdApi.DraftMessage callbackDraft = draftMessage; + + if (needFetchUnreadCount) { + // Fetch fresh topic info to get accurate unread count + tdlib.client().send(new TdApi.GetForumTopic(chatId, (int) messageThreadId), result -> { + if (result.getConstructor() == TdApi.ForumTopic.CONSTRUCTOR) { + TdApi.ForumTopic freshTopic = (TdApi.ForumTopic) result; + // Always use draft from callback - it's more recent than fetched server data + // (callbackDraft can be null if draft was cleared, which we should respect) + freshTopic.draftMessage = callbackDraft; + // Update Tdlib cache so chat list badge updates correctly + tdlib.updateForumTopicUnreadCount(chatId, (int) messageThreadId, freshTopic.unreadCount); + UI.post(() -> { + if (topics != null && foundIndex < topics.size() && + topics.get(foundIndex).info.forumTopicId == messageThreadId) { + topics.set(foundIndex, freshTopic); + // Also update in allTopics if present + if (allTopics != null) { + for (int i = 0; i < allTopics.size(); i++) { + if (allTopics.get(i).info.forumTopicId == messageThreadId) { + allTopics.set(i, freshTopic); + break; + } + } + } + if (adapter != null) { + adapter.notifyItemChanged(foundIndex); + } + } + }); + } + }); + } else { + // Just update local state without fetching + UI.post(() -> { + if (topics == null || foundIndex >= topics.size()) return; + TdApi.ForumTopic topic = topics.get(foundIndex); + if (topic.info.forumTopicId != messageThreadId) return; + + topic.isPinned = isPinned; + topic.lastReadInboxMessageId = lastReadInboxMessageId; + topic.lastReadOutboxMessageId = lastReadOutboxMessageId; + topic.unreadMentionCount = unreadMentionCount; + topic.unreadReactionCount = unreadReactionCount; + if (notificationSettings != null) { + topic.notificationSettings = notificationSettings; + } + // Update draft message - this is topic-scoped + topic.draftMessage = draftMessage; + + // Also update in allTopics if present + if (allTopics != null) { + for (TdApi.ForumTopic allTopic : allTopics) { + if (allTopic.info.forumTopicId == messageThreadId) { + allTopic.isPinned = isPinned; + allTopic.lastReadInboxMessageId = lastReadInboxMessageId; + allTopic.lastReadOutboxMessageId = lastReadOutboxMessageId; + allTopic.unreadMentionCount = unreadMentionCount; + allTopic.unreadReactionCount = unreadReactionCount; + if (notificationSettings != null) { + allTopic.notificationSettings = notificationSettings; + } + allTopic.draftMessage = draftMessage; + break; + } + } + } + + if (adapter != null) { + adapter.notifyItemChanged(foundIndex); + } + }); + } + } + + @Override + public void onForumTopicFullyUpdated (long chatId, TdApi.ForumTopic freshTopic) { + if (chatId != this.chatId || topics == null || freshTopic == null) return; + + UI.post(() -> { + for (int i = 0; i < topics.size(); i++) { + if (topics.get(i).info.forumTopicId == freshTopic.info.forumTopicId) { + topics.set(i, freshTopic); + if (adapter != null) { + adapter.notifyItemChanged(i); + } + break; + } + } + // Also update in allTopics if present + if (allTopics != null) { + for (int i = 0; i < allTopics.size(); i++) { + if (allTopics.get(i).info.forumTopicId == freshTopic.info.forumTopicId) { + allTopics.set(i, freshTopic); + break; + } + } + } + }); + } + + // Permission checks for topic actions + private boolean canCreateTopics () { + TdApi.ChatMemberStatus status = tdlib.chatStatus(chatId); + if (status == null) return false; + + switch (status.getConstructor()) { + case TdApi.ChatMemberStatusCreator.CONSTRUCTOR: + return true; + case TdApi.ChatMemberStatusAdministrator.CONSTRUCTOR: + return ((TdApi.ChatMemberStatusAdministrator) status).rights.canManageTopics; + case TdApi.ChatMemberStatusMember.CONSTRUCTOR: + case TdApi.ChatMemberStatusRestricted.CONSTRUCTOR: + // Check chat-level permissions + return chat != null && chat.permissions != null && chat.permissions.canCreateTopics; + default: + return false; + } + } + + private boolean canManageTopics () { + TdApi.ChatMemberStatus status = tdlib.chatStatus(chatId); + if (status == null) return false; + + switch (status.getConstructor()) { + case TdApi.ChatMemberStatusCreator.CONSTRUCTOR: + return true; + case TdApi.ChatMemberStatusAdministrator.CONSTRUCTOR: + return ((TdApi.ChatMemberStatusAdministrator) status).rights.canManageTopics; + default: + return false; + } + } + + // TdlibCache.SupergroupDataChangeListener implementation + @Override + public void onSupergroupUpdated (TdApi.Supergroup supergroup) { + tdlib.ui().post(() -> { + if (ChatId.toSupergroupId(chatId) == supergroup.id) { + // Check if forum mode was disabled externally or tabs layout changed + if (!supergroup.isForum && chat != null) { + // Forum mode was disabled - navigate to regular chat view + navigateBack(); + tdlib.ui().post(() -> { + if (!isDestroyed()) { + tdlib.ui().openChat(this, chat.id, new TdlibUi.ChatOpenParameters().keepStack()); + } + }); + } else if (supergroup.isForum && supergroup.hasForumTabs && chat != null) { + // Tabs layout was enabled - switch to tabs controller + navigateBack(); + tdlib.ui().post(() -> { + if (!isDestroyed()) { + tdlib.ui().openChat(this, chat.id, new TdlibUi.ChatOpenParameters().keepStack()); + } + }); + } + } + }); + } + + @Override + public void onSupergroupFullUpdated (long supergroupId, TdApi.SupergroupFullInfo newSupergroupFull) { + // Not used + } + + // MessageListener implementation + @Override + public void onNewMessage (TdApi.Message message) { + if (message.chatId != chatId) return; + + // Get topic ID from message + int topicId = 0; + if (message.topicId != null && message.topicId instanceof TdApi.MessageTopicForum) { + topicId = ((TdApi.MessageTopicForum) message.topicId).forumTopicId; + } + if (topicId == 0) return; + + final int finalTopicId = topicId; + + UI.post(() -> { + if (topics == null) return; + + // Find the topic + int foundIndex = -1; + TdApi.ForumTopic foundTopic = null; + for (int i = 0; i < topics.size(); i++) { + if (topics.get(i).info.forumTopicId == finalTopicId) { + foundIndex = i; + foundTopic = topics.get(i); + break; + } + } + + if (foundTopic == null) { + // Topic not in list - might be a new topic, refresh + isLoading = false; // Reset loading flag to allow refresh + loadTopics(); + return; + } + + // Update lastMessage + foundTopic.lastMessage = message; + + // If message is unread, increment unread count + if (!message.isOutgoing && message.id > foundTopic.lastReadInboxMessageId) { + foundTopic.unreadCount++; + } + + // Also update in allTopics + if (allTopics != null) { + for (TdApi.ForumTopic topic : allTopics) { + if (topic.info.forumTopicId == finalTopicId) { + topic.lastMessage = message; + if (!message.isOutgoing && message.id > topic.lastReadInboxMessageId) { + topic.unreadCount++; + } + break; + } + } + } + + // Resort topics: pinned first, then by lastMessage date + resortTopics(); + + // Update cache + tdlib.updateForumTopicsCache(chatId, topics); + + // Find new position and notify adapter + int newIndex = -1; + for (int i = 0; i < topics.size(); i++) { + if (topics.get(i).info.forumTopicId == finalTopicId) { + newIndex = i; + break; + } + } + + if (adapter != null) { + if (foundIndex == newIndex) { + adapter.notifyItemChanged(newIndex); + } else { + adapter.notifyDataSetChanged(); + } + } + }); + } + + @Override + public void onMessageSendSucceeded (TdApi.Message message, long oldMessageId) { + // When a message send succeeds, the message now has topicId populated + // Re-route to onNewMessage to update the topic list + onNewMessage(message); + } + + @Override + public void onMessageContentChanged (long chatId, long messageId, TdApi.MessageContent newContent) { + if (chatId != this.chatId) return; + + UI.post(() -> { + if (topics == null) return; + + // Find topic with this message as lastMessage + for (int i = 0; i < topics.size(); i++) { + TdApi.ForumTopic topic = topics.get(i); + if (topic.lastMessage != null && topic.lastMessage.id == messageId) { + topic.lastMessage.content = newContent; + if (adapter != null) { + adapter.notifyItemChanged(i); + } + break; + } + } + }); + } + + @Override + public void onMessagesDeleted (long chatId, long[] messageIds) { + if (chatId != this.chatId) return; + + UI.post(() -> { + if (topics == null) return; + + // Check if any topic's lastMessage was deleted + for (int i = 0; i < topics.size(); i++) { + TdApi.ForumTopic topic = topics.get(i); + if (topic.lastMessage != null) { + for (long deletedId : messageIds) { + if (topic.lastMessage.id == deletedId) { + // Last message deleted - need to fetch fresh topic data + final int topicIdInt = topic.info.forumTopicId; + tdlib.client().send(new TdApi.GetForumTopic(chatId, topicIdInt), result -> { + if (result.getConstructor() == TdApi.ForumTopic.CONSTRUCTOR) { + TdApi.ForumTopic freshTopic = (TdApi.ForumTopic) result; + UI.post(() -> { + for (int j = 0; j < topics.size(); j++) { + if (topics.get(j).info.forumTopicId == topicIdInt) { + topics.set(j, freshTopic); + if (adapter != null) { + adapter.notifyItemChanged(j); + } + break; + } + } + }); + } + }); + break; + } + } + } + } + }); + } + + private void resortTopics () { + if (topics == null || topics.size() < 2) return; + + // Sort: pinned first, then by lastMessage date (newest first) + java.util.Collections.sort(topics, (a, b) -> { + // Pinned topics first + if (a.isPinned != b.isPinned) { + return a.isPinned ? -1 : 1; + } + + // Within same pinned status, sort by lastMessage date + int dateA = a.lastMessage != null ? a.lastMessage.date : 0; + int dateB = b.lastMessage != null ? b.lastMessage.date : 0; + return Integer.compare(dateB, dateA); // Descending (newest first) + }); + } + + // Message search result data class + public static class TopicMessageSearchResult { + public final TdApi.ForumTopic topic; + public final TdApi.Message foundMessage; + public final String highlightQuery; + + public TopicMessageSearchResult (TdApi.ForumTopic topic, TdApi.Message foundMessage, String query) { + this.topic = topic; + this.foundMessage = foundMessage; + this.highlightQuery = query; + } + } + + // Inner adapter class + private static class ForumTopicsAdapter extends RecyclerView.Adapter { + private final ForumTopicsController controller; + private List topics = new ArrayList<>(); + private List messageSearchResults = new ArrayList<>(); + private boolean isMessageSearchMode = false; + private String highlightQuery; + + ForumTopicsAdapter (ForumTopicsController controller) { + this.controller = controller; + } + + void setTopics (List topics, @Nullable String highlightQuery) { + this.topics = topics; + this.highlightQuery = highlightQuery; + this.isMessageSearchMode = false; + notifyDataSetChanged(); + } + + void setMessageSearchResults (List results, @Nullable String highlightQuery) { + this.messageSearchResults = results != null ? results : new ArrayList<>(); + this.highlightQuery = highlightQuery; + this.isMessageSearchMode = true; + notifyDataSetChanged(); + } + + @NonNull + @Override + public ForumTopicViewHolder onCreateViewHolder (@NonNull ViewGroup parent, int viewType) { + ForumTopicView view = new ForumTopicView(parent.getContext()); + view.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, Screen.dp(78f))); + view.setOnClickListener(controller); + view.setOnLongClickListener(controller); + return new ForumTopicViewHolder(view); + } + + @Override + public void onBindViewHolder (@NonNull ForumTopicViewHolder holder, int position) { + if (isMessageSearchMode) { + TopicMessageSearchResult result = messageSearchResults.get(position); + holder.bindMessageSearchResult(controller.tdlib, result); + } else { + TdApi.ForumTopic topic = topics.get(position); + holder.bind(controller.tdlib, topic, highlightQuery); + } + } + + @Override + public void onViewAttachedToWindow (@NonNull ForumTopicViewHolder holder) { + if (holder.itemView instanceof ForumTopicView) { + ((ForumTopicView) holder.itemView).attach(); + } + } + + @Override + public void onViewDetachedFromWindow (@NonNull ForumTopicViewHolder holder) { + if (holder.itemView instanceof ForumTopicView) { + ((ForumTopicView) holder.itemView).detach(); + } + } + + @Override + public void onViewRecycled (@NonNull ForumTopicViewHolder holder) { + if (holder.itemView instanceof ForumTopicView) { + ((ForumTopicView) holder.itemView).destroy(); + } + } + + @Override + public int getItemCount () { + return isMessageSearchMode ? messageSearchResults.size() : topics.size(); + } + } + + private static class ForumTopicViewHolder extends RecyclerView.ViewHolder { + ForumTopicViewHolder (@NonNull View itemView) { + super(itemView); + } + + void bind (Tdlib tdlib, TdApi.ForumTopic topic, @Nullable String highlightQuery) { + if (itemView instanceof ForumTopicView) { + ((ForumTopicView) itemView).setTopic(tdlib, topic, highlightQuery); + itemView.setTag(topic); + } + } + + void bindMessageSearchResult (Tdlib tdlib, TopicMessageSearchResult result) { + if (itemView instanceof ForumTopicView) { + ((ForumTopicView) itemView).setMessageSearchResult(tdlib, result.topic, result.foundMessage, result.highlightQuery); + itemView.setTag(result); + } + } + } +} diff --git a/app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java b/app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java index d5b1c08590..82a313b306 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/MessagesController.java @@ -2227,6 +2227,35 @@ public void onMoreItemPressed (int id) { openLinkedChat(false); } else if (id == R.id.btn_openDirectMessages) { openLinkedChat(true); + } else if (id == R.id.btn_viewAsTopics) { + // Set viewAsTopics to true and open forum topics view + tdlib.client().send(new TdApi.ToggleChatViewAsTopics(chat.id, true), result -> { + if (result.getConstructor() == TdApi.Ok.CONSTRUCTOR) { + tdlib.ui().post(() -> { + // Navigate directly to forum controller (don't use openChat - chat object not updated yet) + long supergroupId = ChatId.toSupergroupId(chat.id); + TdApi.Supergroup supergroup = supergroupId != 0 ? tdlib.cache().supergroup(supergroupId) : null; + boolean hasForumTabs = supergroup != null && supergroup.hasForumTabs; + + ViewController forumController; + if (hasForumTabs) { + ForumTopicTabsController tabsController = new ForumTopicTabsController(context, tdlib); + tabsController.setArguments(new ForumTopicTabsController.Arguments(chat)); + forumController = tabsController; + } else { + ForumTopicsController listController = new ForumTopicsController(context, tdlib); + listController.setArguments(new ForumTopicsController.Arguments(chat)); + forumController = listController; + } + + // Navigate and destroy current controller + forumController.addOneShotFocusListener(() -> { + forumController.destroyStackItemAt(forumController.stackSize() - 2); + }); + navigateTo(forumController); + }); + } + }); } else if (id == R.id.btn_manageGroup) { manageGroup(); } else if (id == R.id.btn_deleteThread) { @@ -2414,6 +2443,7 @@ public static class Arguments { public final @Nullable ThreadInfo messageThread; public final @Nullable TdApi.MessageTopic messageTopicId; + public @Nullable TdApi.ForumTopic forumTopic; public Referrer referrer; public TdApi.InternalLinkTypeVideoChat videoChatOrLiveStreamInvitation; @@ -2531,6 +2561,11 @@ public Arguments referrer (Referrer referrer) { return this; } + public Arguments setForumTopic (TdApi.ForumTopic forumTopic) { + this.forumTopic = forumTopic; + return this; + } + public Arguments setScheduled (boolean areScheduled) { if (areScheduled && messageThread != null) { throw new IllegalArgumentException(); @@ -2586,6 +2621,7 @@ public boolean areScheduledOnly () { private TdApi.SearchMessagesFilter previewSearchFilter; private ThreadInfo messageThread; private TdApi.MessageTopic messageTopicId; + private TdApi.ForumTopic forumTopic; private boolean areScheduled; private Referrer referrer; private TdApi.InternalLinkTypeVideoChat voiceChatInvitation; @@ -2618,6 +2654,7 @@ public void setArguments (Arguments args) { this.customCaptionPlaceholder = null; this.messageThread = args.messageThread; this.messageTopicId = args.messageTopicId; + this.forumTopic = args.forumTopic; this.openedFromChatList = args.chatList; this.linkedChatId = 0; this.areScheduled = args.areScheduled; @@ -2781,8 +2818,28 @@ private void updateView () { updateForcedSubtitle(); // must be called before calling headerCell.setChat headerCell.setCallback(areScheduled ? null : this); } + + // Load forum topic if we have a messageTopicId but no forumTopic + // This happens when opening a message via link in a forum + if (forumTopic == null && messageTopicId != null && + messageTopicId.getConstructor() == TdApi.MessageTopicForum.CONSTRUCTOR) { + long forumTopicId = ((TdApi.MessageTopicForum) messageTopicId).messageThreadId; + tdlib.client().send(new TdApi.GetForumTopic(chat.id, (int) forumTopicId), result -> { + if (result.getConstructor() == TdApi.ForumTopic.CONSTRUCTOR) { + runOnUiThreadOptional(() -> { + forumTopic = (TdApi.ForumTopic) result; + // Update header with loaded topic + TdApi.Chat hChat = messageThread != null ? tdlib.chatSync(messageThread.getContextChatId()) : null; + headerCell.setChat(tdlib, hChat != null ? hChat : chat, messageThread, forumTopic); + // Update unread counts + updateCounters(true); + }); + } + }); + } + TdApi.Chat headerChat = messageThread != null ? tdlib.chatSync(messageThread.getContextChatId()) : null; - headerCell.setChat(tdlib, headerChat != null ? headerChat : chat, messageThread); + headerCell.setChat(tdlib, headerChat != null ? headerChat : chat, messageThread, forumTopic); if (inPreviewMode) { switch (previewMode) { @@ -2960,6 +3017,11 @@ private void updateCounters (boolean animated) { setUnreadCountBadge(unreadCount, animated); setMentionCountBadge(0); setReactionCountBadge(0); + } else if (forumTopic != null) { + // Use forum topic's unread count instead of chat's total unread count + setUnreadCountBadge(forumTopic.unreadCount, animated); + setMentionCountBadge(forumTopic.unreadMentionCount); + setReactionCountBadge(forumTopic.unreadReactionCount); } else { setUnreadCountBadge(chat.unreadCount, true); setMentionCountBadge(chat.unreadMentionCount); @@ -3136,6 +3198,7 @@ private boolean isReplyRequired () { private void updateBottomBar (boolean isUpdate) { setInputBlockFlag(FLAG_INPUT_TEXT_DISABLED, !tdlib.canSendBasicMessage(chat)); + setInputBlockFlag(FLAG_INPUT_TOPIC_CLOSED, isTopicClosedForUser()); if (sendButton != null) { sendButton.getSlowModeCounterController(tdlib).updateSlowModeTimer(isUpdate); } @@ -4513,6 +4576,12 @@ public void showMore () { strings.append(R.string.DirectMessages); } + // Add "View as topics" option for forum chats viewed as unified chat + if (tdlib.isForum(chat.id) && messageThread == null && forumTopic == null && !chat.viewAsTopics) { + ids.append(R.id.btn_viewAsTopics); + strings.append(R.string.ViewAsTopics); + } + if (BuildConfig.DEBUG) { if (TD.isSecretChat(chat.type)) { ids.append(R.id.btn_sendScreenshotNotification); @@ -7198,9 +7267,37 @@ public boolean inSimpleSendMode () { private static final int FLAG_INPUT_OFFSCREEN = 1 << 1; private static final int FLAG_INPUT_RECORDING = 1 << 2; private static final int FLAG_INPUT_TEXT_DISABLED = 1 << 3; + private static final int FLAG_INPUT_TOPIC_CLOSED = 1 << 4; private int inputBlockFlags; + /** + * Check if the current user can manage forum topics. + */ + private boolean canManageTopics () { + if (chat == null) return false; + TdApi.ChatMemberStatus status = tdlib.chatStatus(chat.id); + if (status == null) return false; + switch (status.getConstructor()) { + case TdApi.ChatMemberStatusCreator.CONSTRUCTOR: + return true; + case TdApi.ChatMemberStatusAdministrator.CONSTRUCTOR: + return ((TdApi.ChatMemberStatusAdministrator) status).rights.canManageTopics; + default: + return false; + } + } + + /** + * Check if the topic is closed and the user cannot send messages to it. + */ + private boolean isTopicClosedForUser () { + if (forumTopic == null || forumTopic.info == null) return false; + if (!forumTopic.info.isClosed) return false; + // Admins with canManageTopics permission can still send to closed topics + return !canManageTopics(); + } + private boolean setInputBlockFlags (int flags) { if (this.inputBlockFlags != flags) { boolean prevIsBlocked = this.inputBlockFlags != 0; @@ -7216,10 +7313,11 @@ private boolean setInputBlockFlags (int flags) { private void setInputBlockFlag (int flag, boolean active) { if (setInputBlockFlags(BitwiseUtils.setFlag(inputBlockFlags, flag, active))) { - if ((flag == FLAG_INPUT_OFFSCREEN || flag == FLAG_INPUT_TEXT_DISABLED) && inputView != null) { + if ((flag == FLAG_INPUT_OFFSCREEN || flag == FLAG_INPUT_TEXT_DISABLED || flag == FLAG_INPUT_TOPIC_CLOSED) && inputView != null) { inputView.setEnabled( !BitwiseUtils.hasFlag(inputBlockFlags, FLAG_INPUT_OFFSCREEN) && - !BitwiseUtils.hasFlag(inputBlockFlags, FLAG_INPUT_TEXT_DISABLED) + !BitwiseUtils.hasFlag(inputBlockFlags, FLAG_INPUT_TEXT_DISABLED) && + !BitwiseUtils.hasFlag(inputBlockFlags, FLAG_INPUT_TOPIC_CLOSED) ); } } @@ -10853,6 +10951,47 @@ public void onChatUnreadReactionCount (long chatId, int unreadReactionCount, boo }); } + @Override + public void onForumTopicUpdated (long chatId, long messageThreadId, boolean isPinned, long lastReadInboxMessageId, long lastReadOutboxMessageId, int unreadMentionCount, int unreadReactionCount, TdApi.ChatNotificationSettings notificationSettings, TdApi.DraftMessage draftMessage) { + tdlib.ui().post(() -> { + if (forumTopic == null || getChatId() != chatId || forumTopic.info.forumTopicId != messageThreadId) { + return; + } + // Update the forumTopic fields from the update + long oldLastReadInboxMessageId = forumTopic.lastReadInboxMessageId; + forumTopic.isPinned = isPinned; + forumTopic.lastReadInboxMessageId = lastReadInboxMessageId; + forumTopic.lastReadOutboxMessageId = lastReadOutboxMessageId; + forumTopic.unreadMentionCount = unreadMentionCount; + forumTopic.unreadReactionCount = unreadReactionCount; + if (notificationSettings != null) { + forumTopic.notificationSettings = notificationSettings; + } + // Update draft message (topic-scoped) + forumTopic.draftMessage = draftMessage; + // Also update the messageThread if present so TGMessage.isUnread() works correctly + if (messageThread != null) { + messageThread.updateReadInbox(lastReadInboxMessageId); + messageThread.updateReadOutbox(lastReadOutboxMessageId); + // Update message views to show read receipts (double ticks) + manager.updateChatReadOutbox(lastReadOutboxMessageId); + } + // Calculate new unread count + if (lastReadInboxMessageId > oldLastReadInboxMessageId) { + // Messages were read + if (forumTopic.lastMessage != null && lastReadInboxMessageId >= forumTopic.lastMessage.id) { + // All messages read + forumTopic.unreadCount = 0; + } else if (forumTopic.unreadCount > 0) { + // Estimate: decrease unread count (may not be exact) + // A more accurate approach would be to count visible messages between old and new read position + forumTopic.unreadCount = Math.max(0, forumTopic.unreadCount - 1); + } + } + updateCounters(true); + }); + } + @Override public void onUnreadSingleReactionUpdate (long chatId, @Nullable TdApi.UnreadReaction unreadReaction) { UI.execute(() -> { @@ -11026,6 +11165,17 @@ public void onSupergroupUpdated (final TdApi.Supergroup supergroup) { tdlib.ui().post(() -> { if (ChatId.toSupergroupId(getChatId()) == supergroup.id) { updateBottomBar(true); + // Check if forum mode was enabled externally (by another admin) + // Only redirect if we're viewing unified chat (not a specific topic) + if (supergroup.isForum && forumTopic == null && messageThread == null && chat != null) { + // Forum mode was enabled - navigate to topics view + navigateBack(); + tdlib.ui().post(() -> { + if (!isDestroyed()) { + tdlib.ui().openChat(this, chat.id, new TdlibUi.ChatOpenParameters().keepStack()); + } + }); + } } }); } @@ -11298,7 +11448,7 @@ public void handleLanguagePackEvent (int event, int arg1) { if (isFocused() && !inPreviewMode()) { manager.rebuildLayouts(); if (headerCell != null) { - headerCell.setChat(tdlib, chat, messageThread); + headerCell.setChat(tdlib, chat, messageThread, forumTopic); if (messageThread != null) { updateMessageThreadSubtitle(); } diff --git a/app/src/main/java/org/thunderdog/challegram/ui/ProfileController.java b/app/src/main/java/org/thunderdog/challegram/ui/ProfileController.java index 39c3010b42..df304bc8d9 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/ProfileController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/ProfileController.java @@ -1942,7 +1942,9 @@ protected void setValuedSetting (ListItem item, SettingView view, boolean isUpda itemId == R.id.btn_toggleProtection || itemId == R.id.btn_toggleJoinByRequest || itemId == R.id.btn_toggleAggressiveAntiSpam || - itemId == R.id.btn_toggleHideMembers) { + itemId == R.id.btn_toggleHideMembers || + itemId == R.id.btn_toggleForum || + itemId == R.id.btn_toggleForumTabs) { view.getToggler().setRadioEnabled(item.isSelected(), isUpdate); } if (item.getViewType() == ListItem.TYPE_RADIO_SETTING) { @@ -3317,6 +3319,65 @@ private void toggleHideMembers (View v) { } } + private void toggleForum (View v) { + // Only owner can toggle forum mode + if (supergroup != null && TD.isCreator(supergroup.status)) { + boolean newValue = baseAdapter.toggleView(v); + toggleForumItem.setSelected(newValue); + // Dynamically show/hide tabs toggle + if (newValue) { + // Forum enabled - show tabs toggle + int insertIndex = baseAdapter.indexOfView(toggleForumItem); + if (insertIndex != -1 && toggleForumTabsItem != null) { + baseAdapter.addItems(insertIndex + 1, + new ListItem(ListItem.TYPE_SEPARATOR_FULL), + toggleForumTabsItem + ); + // Update description to show tabs layout info + updateForumDescription(true); + } + } else { + // Forum disabled - hide tabs toggle + int deleteIndex = baseAdapter.indexOfView(toggleForumTabsItem); + if (deleteIndex != -1) { + baseAdapter.removeRange(deleteIndex - 1, 2); + // Reset tabs toggle + if (toggleForumTabsItem != null) { + toggleForumTabsItem.setSelected(false); + } + // Update description to show forum disabled info + updateForumDescription(false); + } + } + checkDoneButton(); + } + } + + private void toggleForumTabs (View v) { + // Only owner can toggle forum tabs mode + if (supergroup != null && TD.isCreator(supergroup.status) && toggleForumTabsItem != null) { + boolean newValue = baseAdapter.toggleView(v); + toggleForumTabsItem.setSelected(newValue); + checkDoneButton(); + } + } + + private void updateForumDescription (boolean isForumEnabled) { + // Find the description item and update its text + int count = baseAdapter.getItemCount(); + for (int i = 0; i < count; i++) { + ListItem item = baseAdapter.getItem(i); + if (item != null && item.getViewType() == ListItem.TYPE_DESCRIPTION) { + int stringId = item.getStringResource(); + if (stringId == R.string.EnableTopicsDesc || stringId == R.string.TopicsLayoutTabsDesc) { + item.setString(isForumEnabled ? R.string.TopicsLayoutTabsDesc : R.string.EnableTopicsDesc); + baseAdapter.updateValuedSettingByPosition(i); + break; + } + } + } + } + private void toggleJoinByRequests (View v) { if (tdlib.canToggleJoinByRequest(chat)) { boolean newValue = baseAdapter.toggleView(v); @@ -3640,7 +3701,8 @@ private boolean hasUnsavedChanges () { hasContentProtectionChanges() || hasJoinByRequestChanges() || hasSignMessagesChanges() || - hasShowAuthorsChanges(); + hasShowAuthorsChanges() || + hasForumChanges(); } private boolean hasSlowModeChanges () { @@ -3678,6 +3740,26 @@ private boolean hasShowAuthorsChanges () { return toggleShowAuthorsItem != null && originalValue != toggleShowAuthorsItem.isSelected(); } + private boolean hasForumChanges () { + boolean originalValue = supergroup != null && supergroup.isForum; + boolean forumChanged = toggleForumItem != null && originalValue != toggleForumItem.isSelected(); + // Also trigger if tabs layout changed (while forum is enabled) + return forumChanged || hasForumTabsChanges(); + } + + private boolean hasForumTabsChanges () { + if (toggleForumTabsItem == null || supergroup == null || !supergroup.isForum) { + return false; + } + // Only consider changes if forum will be enabled (either already enabled or being enabled) + boolean willBeForumEnabled = toggleForumItem != null ? toggleForumItem.isSelected() : supergroup.isForum; + if (!willBeForumEnabled) { + return false; // Tabs changes don't matter if forum is being disabled + } + boolean originalValue = supergroup.hasForumTabs; + return originalValue != toggleForumTabsItem.isSelected(); + } + private boolean hasTtlChanges () { int originalSlowMode = chat != null ? chat.messageAutoDeleteTime : 0; return ttlItem != null && originalSlowMode != TdConstants.CHAT_TTL_OPTIONS[ttlItem.getSliderValue()]; @@ -3726,8 +3808,9 @@ private void applyChatChanges (boolean force) { boolean hasJoinByRequestChanges = hasJoinByRequestChanges(); boolean hasSignMessagesChanges = hasSignMessagesChanges(); boolean hasShowAuthorsChanges = hasShowAuthorsChanges(); + boolean hasForumChanges = hasForumChanges(); - if (!force && (hasSlowModeChanges || hasAggressiveAntiSpamChanges || hasHideMembersChanges || hasJoinByRequestChanges || hasSignMessagesChanges || hasShowAuthorsChanges) && ChatId.isBasicGroup(chat.id)) { + if (!force && (hasSlowModeChanges || hasAggressiveAntiSpamChanges || hasHideMembersChanges || hasJoinByRequestChanges || hasSignMessagesChanges || hasShowAuthorsChanges || hasForumChanges) && ChatId.isBasicGroup(chat.id)) { showConfirm(Lang.getMarkdownString(this, R.string.UpgradeChatPrompt), Lang.getString(R.string.Proceed), () -> applyChatChanges(true)); return; } @@ -3783,6 +3866,13 @@ private void applyChatChanges (boolean force) { changes.add(new TdApi.ToggleSupergroupHasHiddenMembers(ChatId.toSupergroupId(chat.id), hideMembersItem.isSelected())); } + if (hasForumChanges) { + boolean isForum = toggleForumItem.isSelected(); + // Pass current tabs selection when forum is enabled, false when disabled + boolean hasForumTabs = isForum && toggleForumTabsItem != null && toggleForumTabsItem.isSelected(); + changes.add(new TdApi.ToggleSupergroupIsForum(ChatId.toSupergroupId(chat.id), isForum, hasForumTabs)); + } + if (hasSignMessagesChanges || hasShowAuthorsChanges) { boolean signMessages = toggleSignMessagesItem.isSelected(); boolean showAuthors = signMessages && toggleShowAuthorsItem.isSelected(); @@ -3891,7 +3981,7 @@ private void setInProgress (boolean inProgress) { } private ListItem slowModeItem, slowModeDescItem; - private ListItem aggressiveAntiSpamItem, hideMembersItem, + private ListItem aggressiveAntiSpamItem, hideMembersItem, toggleForumItem, toggleForumTabsItem, toggleJoinByRequestItem, toggleHasProtectionItem, toggleSignMessagesItem, toggleShowAuthorsItem; private ListItem ttlItem, ttlDescItem; @@ -4094,6 +4184,22 @@ private void buildEditCells () { items.add(new ListItem(ListItem.TYPE_DESCRIPTION, 0, 0, R.string.HideMembersDesc)); } + // Forum toggle - only for supergroup owners without linked chat (discussion group) + if (supergroup != null && !supergroup.isChannel && TD.isCreator(supergroup.status) && !supergroup.hasLinkedChat) { + boolean isForum = supergroup.isForum; + boolean hasForumTabs = supergroup.hasForumTabs; + items.add(new ListItem(ListItem.TYPE_SHADOW_TOP)); + items.add(toggleForumItem = new ListItem(ListItem.TYPE_RADIO_SETTING, R.id.btn_toggleForum, 0, R.string.EnableTopics, isForum)); + // Always create the tabs toggle item (for dynamic show/hide), but only add to list if forum is enabled + toggleForumTabsItem = new ListItem(ListItem.TYPE_RADIO_SETTING, R.id.btn_toggleForumTabs, 0, R.string.TopicsLayoutTabs, hasForumTabs); + if (isForum) { + items.add(new ListItem(ListItem.TYPE_SEPARATOR_FULL)); + items.add(toggleForumTabsItem); + } + items.add(new ListItem(ListItem.TYPE_SHADOW_BOTTOM)); + items.add(new ListItem(ListItem.TYPE_DESCRIPTION, 0, 0, isForum ? R.string.TopicsLayoutTabsDesc : R.string.EnableTopicsDesc)); + } + if (tdlib.canEditSlowMode(chat.id)) { int slowModeValue = supergroupFull != null ? supergroupFull.slowModeDelay : 0; items.add(new ListItem(ListItem.TYPE_HEADER, 0, 0, R.string.SlowMode)); @@ -4236,6 +4342,9 @@ private static CharSequence getTtlDescription (int seconds, boolean isChannel) { } private void processEditContentChanged (TdApi.Supergroup updatedSupergroup) { + boolean wasForumChanged = this.supergroup != null && this.supergroup.isForum != updatedSupergroup.isForum; + boolean wasForumTabsChanged = this.supergroup != null && this.supergroup.isForum && updatedSupergroup.isForum + && this.supergroup.hasForumTabs != updatedSupergroup.hasForumTabs; this.supergroup = updatedSupergroup; checkCanToggleJoinByRequest(); baseAdapter.updateValuedSettingById(R.id.btn_toggleProtection); @@ -4254,6 +4363,16 @@ private void processEditContentChanged (TdApi.Supergroup updatedSupergroup) { break; } } + + // When forum mode or tabs layout changes, navigate back and reopen the chat + if ((wasForumChanged || wasForumTabsChanged) && chat != null) { + navigateBack(); + tdlib.ui().post(() -> { + if (!isDestroyed()) { + tdlib.ui().openChat(this, chat.id, new TdlibUi.ChatOpenParameters().keepStack()); + } + }); + } } private ListItem newLinkedChatItem () { @@ -4906,6 +5025,10 @@ public void onClick (View v) { toggleAggressiveAntiSpam(v); } else if (viewId == R.id.btn_toggleHideMembers) { toggleHideMembers(v); + } else if (viewId == R.id.btn_toggleForum) { + toggleForum(v); + } else if (viewId == R.id.btn_toggleForumTabs) { + toggleForumTabs(v); } else if (viewId == R.id.btn_toggleProtection) { toggleContentProtection(v); } else if (viewId == R.id.btn_toggleJoinByRequest) { diff --git a/app/src/main/java/org/thunderdog/challegram/ui/ShareController.java b/app/src/main/java/org/thunderdog/challegram/ui/ShareController.java index 6131675e4c..30b6ded499 100644 --- a/app/src/main/java/org/thunderdog/challegram/ui/ShareController.java +++ b/app/src/main/java/org/thunderdog/challegram/ui/ShareController.java @@ -1808,6 +1808,8 @@ private void checkHeaderPosition () { private final LongSparseArray selectedChats = new LongSparseArray<>(); private final LongList selectedChatIds = new LongList(10); + // Forum topic selection: chatId -> forumTopicId (0 means General topic) + private final LongSparseArray selectedForumTopics = new LongSparseArray<>(); private boolean isChecked (long chatId) { return selectedChats.get(chatId) != null; @@ -2003,9 +2005,15 @@ private boolean toggleCheckedImpl (View view, TGFoundChat chat, @Nullable Runnab selectedChats.put(chatId, chat); selectedChatIds.append(chatId); hasSelectedAnything = true; + // If this is a forum chat, show topic selection popup + if (tdlib.isForum(chatId)) { + showForumTopicPicker(chatId); + } } else { selectedChats.remove(chatId); selectedChatIds.remove(chatId); + // Clean up topic selection when forum is unselected + selectedForumTopics.remove(chatId); } checkAbilityToSend(); updateHeader(); @@ -2044,6 +2052,67 @@ private void updateHeader () { } } + private @Nullable TdApi.MessageTopic getMessageTopicForChat (long chatId) { + Long topicId = selectedForumTopics.get(chatId); + if (topicId != null && topicId != 0) { + return new TdApi.MessageTopicForum(topicId.intValue()); + } + // If it's a forum but no topic selected, default to General topic (id = 1) + if (tdlib.isForum(chatId)) { + return new TdApi.MessageTopicForum(1); + } + return null; + } + + private void showForumTopicPicker (long chatId) { + // Load forum topics and show a picker popup + tdlib.client().send(new TdApi.GetForumTopics(chatId, "", 0, 0, 0, 100), result -> { + if (result.getConstructor() == TdApi.ForumTopics.CONSTRUCTOR) { + TdApi.ForumTopics topics = (TdApi.ForumTopics) result; + runOnUiThreadOptional(() -> { + if (topics.topics.length == 0) { + // No topics (shouldn't happen but handle it) + selectedForumTopics.put(chatId, 1L); // Default to General topic + return; + } + // Build options for topic picker + String[] titles = new String[topics.topics.length]; + int[] ids = new int[topics.topics.length]; + for (int i = 0; i < topics.topics.length; i++) { + TdApi.ForumTopic topic = topics.topics[i]; + titles[i] = topic.info.name; + ids[i] = topic.info.forumTopicId; + } + showOptions(tdlib.chatTitle(chatId), ids, titles, (itemView, id) -> { + selectedForumTopics.put(chatId, (long) id); + return true; + }); + }); + } else { + // Failed to load topics, default to General + runOnUiThreadOptional(() -> selectedForumTopics.put(chatId, 1L)); + } + }); + } + + private String getTopicColorEmoji (int colorValue) { + // Map topic colors to colored circle emojis + if (colorValue == 0) return "\uD83D\uDFE6"; // Default blue + // Normalize color (remove alpha if present) + int color = colorValue & 0x00FFFFFF; + // Map to closest emoji based on RGB values + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + // Simple heuristic based on dominant color + if (r > 200 && g < 150 && b < 150) return "\uD83D\uDD34"; // Red + if (r > 200 && g > 180 && b < 150) return "\uD83D\uDFE1"; // Yellow + if (r > 180 && g < 150 && b > 180) return "\uD83D\uDFE3"; // Purple + if (r < 150 && g > 200 && b < 150) return "\uD83D\uDFE2"; // Green + if (r > 200 && g > 100 && b > 150) return "\uD83D\uDFE0"; // Orange (for pink) + return "\uD83D\uDFE6"; // Blue (default) + } + private static final boolean OPEN_KEYBOARD_WITH_AUTOSCROLL = false; private boolean sendOnKeyboardClose; private TdApi.MessageSendOptions sendOnKeyboardCloseSendOptions; @@ -3392,6 +3461,9 @@ private void sendMessages (boolean forceGoToChat, boolean isSingleTap, @Nullable if (showErrorMessage(null, chatId, true)) return; + // Get message topic for forum chats + final TdApi.MessageTopic messageTopicId = getMessageTopicForChat(chatId); + final TdApi.Chat chat = tdlib.chat(selectedChatIds.get(i)); if (chat == null) { long myUserId = tdlib.myUserId(); @@ -3435,20 +3507,20 @@ private void sendMessages (boolean forceGoToChat, boolean isSingleTap, @Nullable replyTo = new TdApi.InputMessageReplyToMessage(contentfulMediaMessageId != 0 ? contentfulMediaMessageId : args.messages[0].id, null, 0); } } - functions.addAll(TD.sendMessageText(chatId, null, replyTo, sendOptions, new TdApi.InputMessageText(comment, null, false), tdlib.maxMessageTextLength())); + functions.addAll(TD.sendMessageText(chatId, messageTopicId, replyTo, sendOptions, new TdApi.InputMessageText(comment, null, false), tdlib.maxMessageTextLength())); } switch (mode) { case MODE_TEXT: { - functions.addAll(TD.sendMessageText(chatId, null, null, sendOptions, new TdApi.InputMessageText(args.text, null, false), tdlib.maxMessageTextLength())); + functions.addAll(TD.sendMessageText(chatId, messageTopicId, null, sendOptions, new TdApi.InputMessageText(args.text, null, false), tdlib.maxMessageTextLength())); break; } case MODE_MESSAGES: { - if (!messageReplyIncluded && !TD.forwardMessages(chatId, null, args.messages, needHideAuthor, needRemoveCaptions, sendOptions, functions)) + if (!messageReplyIncluded && !TD.forwardMessages(chatId, messageTopicId, args.messages, needHideAuthor, needRemoveCaptions, sendOptions, functions)) return; break; } case MODE_GAME: { - functions.add(new TdApi.SendMessage(chatId, null, null, sendOptions, null, new TdApi.InputMessageForwarded(args.botMessage.chatId, args.botMessage.id, args.withUserScore, false, 0, null))); + functions.add(new TdApi.SendMessage(chatId, messageTopicId, null, sendOptions, null, new TdApi.InputMessageForwarded(args.botMessage.chatId, args.botMessage.id, args.withUserScore, false, 0, null))); break; } case MODE_FILES : { @@ -3459,19 +3531,19 @@ private void sendMessages (boolean forceGoToChat, boolean isSingleTap, @Nullable } TdApi.Function function; if (contents.size() == 1) { - function = new TdApi.SendMessage(chatId, null, null, sendOptions, null, contents.get(0)); + function = new TdApi.SendMessage(chatId, messageTopicId, null, sendOptions, null, contents.get(0)); } else { - function = new TdApi.SendMessageAlbum(chatId, null, null, sendOptions, contents.toArray(new TdApi.InputMessageContent[0])); + function = new TdApi.SendMessageAlbum(chatId, messageTopicId, null, sendOptions, contents.toArray(new TdApi.InputMessageContent[0])); } functions.add(function); break; } case MODE_CONTACT: { - functions.add(new TdApi.SendMessage(chatId, null, null, sendOptions, null, new TdApi.InputMessageContact(new TdApi.Contact(args.contactUser.phoneNumber, args.contactUser.firstName, args.contactUser.lastName, null, args.botUserId)))); + functions.add(new TdApi.SendMessage(chatId, messageTopicId, null, sendOptions, null, new TdApi.InputMessageContact(new TdApi.Contact(args.contactUser.phoneNumber, args.contactUser.firstName, args.contactUser.lastName, null, args.botUserId)))); break; } case MODE_STICKER: { - functions.add(new TdApi.SendMessage(chatId, null, null, sendOptions, null, new TdApi.InputMessageSticker(new TdApi.InputFileId(args.sticker.sticker.id), null, 0, 0, null))); + functions.add(new TdApi.SendMessage(chatId, messageTopicId, null, sendOptions, null, new TdApi.InputMessageSticker(new TdApi.InputFileId(args.sticker.sticker.id), null, 0, 0, null))); break; } case MODE_CUSTOM: { @@ -3479,7 +3551,7 @@ private void sendMessages (boolean forceGoToChat, boolean isSingleTap, @Nullable break; } case MODE_CUSTOM_CONTENT: { - functions.addAll(TD.sendMessageText(chatId, null, null, sendOptions, args.customContent, tdlib.maxMessageTextLength())); + functions.addAll(TD.sendMessageText(chatId, messageTopicId, null, sendOptions, args.customContent, tdlib.maxMessageTextLength())); break; } case MODE_TELEGRAM_FILES: { @@ -3487,14 +3559,14 @@ private void sendMessages (boolean forceGoToChat, boolean isSingleTap, @Nullable TdApi.FormattedText messageCaption = formattedCaption != null && formattedCaption.text.codePointCount(0, formattedCaption.text.length()) <= tdlib.maxCaptionLength() ? formattedCaption : null; boolean showCaptionAboveMedia = false; // TODO? if (formattedCaption != null && messageCaption == null) { - functions.addAll(TD.sendMessageText(chatId, null, null, sendOptions, new TdApi.InputMessageText(formattedCaption, null, false), tdlib.maxMessageTextLength())); + functions.addAll(TD.sendMessageText(chatId, messageTopicId, null, sendOptions, new TdApi.InputMessageText(formattedCaption, null, false), tdlib.maxMessageTextLength())); } for (MediaItem item : args.telegramFiles) { boolean last = item == args.telegramFiles[args.telegramFiles.length - 1]; TdApi.InputMessageContent content = item.createShareContent(last ? messageCaption : null, last && showCaptionAboveMedia); if (content == null) return; - functions.add(new TdApi.SendMessage(chatId, null, null, sendOptions, null, content)); + functions.add(new TdApi.SendMessage(chatId, messageTopicId, null, sendOptions, null, content)); } break; } diff --git a/app/src/main/java/org/thunderdog/challegram/util/TopicIconModifier.java b/app/src/main/java/org/thunderdog/challegram/util/TopicIconModifier.java new file mode 100644 index 0000000000..7c697de914 --- /dev/null +++ b/app/src/main/java/org/thunderdog/challegram/util/TopicIconModifier.java @@ -0,0 +1,234 @@ +/* + * This file is a part of Telegram X + * Copyright Β© 2014 (tgx-android@pm.me) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * File created for forum topics icon display + */ +package org.thunderdog.challegram.util; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.view.View; + +import androidx.annotation.Nullable; + +import org.drinkless.tdlib.TdApi; +import org.thunderdog.challegram.loader.ComplexReceiver; +import org.thunderdog.challegram.loader.DoubleImageReceiver; +import org.thunderdog.challegram.loader.gif.GifFile; +import org.thunderdog.challegram.loader.gif.GifReceiver; +import org.thunderdog.challegram.loader.ImageFile; +import org.thunderdog.challegram.loader.ImageReceiver; +import org.thunderdog.challegram.data.TD; +import org.thunderdog.challegram.telegram.Tdlib; +import org.thunderdog.challegram.telegram.TdlibEmojiManager; +import org.thunderdog.challegram.tool.Screen; + +/** + * DrawModifier that renders a topic icon on the left side of a SettingView. + * Supports both colored circles (for topics without custom emoji) and + * custom emoji icons (stickers) for topics with iconCustomEmojiId. + */ +public class TopicIconModifier implements DrawModifier, TdlibEmojiManager.Watcher { + + private static final float ICON_SIZE_DP = 20f; + private static final float LEFT_OFFSET_DP = 18f; // Left padding area, before text + + private final Tdlib tdlib; + private final int iconColor; + private final long customEmojiId; + + private final Paint circlePaint; + + // Custom emoji support + private @Nullable TdlibEmojiManager.Entry customEmoji; + private @Nullable ImageFile imageFile; + private @Nullable GifFile gifFile; + private @Nullable ImageFile thumbnail; + private @Nullable ComplexReceiver iconReceiver; + private @Nullable View attachedView; + + public TopicIconModifier (Tdlib tdlib, TdApi.ForumTopicIcon icon) { + this.tdlib = tdlib; + this.iconColor = icon != null ? icon.color : 0x6FB9F0; // Default blue + this.customEmojiId = icon != null ? icon.customEmojiId : 0; + + this.circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG); + this.circlePaint.setStyle(Paint.Style.FILL); + this.circlePaint.setColor(iconColor | 0xFF000000); // Ensure full alpha + + if (customEmojiId != 0) { + loadCustomEmoji(); + } + } + + private void loadCustomEmoji () { + this.customEmoji = tdlib.emoji().findOrPostponeRequest(customEmojiId, this); + if (customEmoji != null && !customEmoji.isNotFound()) { + buildCustomEmojiFiles(customEmoji); + } else { + tdlib.emoji().performPostponedRequests(); + } + } + + private void buildCustomEmojiFiles (TdlibEmojiManager.Entry entry) { + TdApi.Sticker sticker = entry.value; + if (sticker == null) return; + + int size = Screen.dp(ICON_SIZE_DP); + + // Thumbnail + thumbnail = TD.toImageFile(tdlib, sticker.thumbnail); + if (thumbnail != null) { + thumbnail.setSize(size); + thumbnail.setScaleType(ImageFile.FIT_CENTER); + thumbnail.setNoBlur(); + } + + // Main image/animation based on format + switch (sticker.format.getConstructor()) { + case TdApi.StickerFormatTgs.CONSTRUCTOR: + case TdApi.StickerFormatWebm.CONSTRUCTOR: { + this.gifFile = new GifFile(tdlib, sticker); + this.gifFile.setScaleType(GifFile.FIT_CENTER); + this.gifFile.setOptimizationMode(GifFile.OptimizationMode.EMOJI); + this.gifFile.setRequestedSize(size); + break; + } + case TdApi.StickerFormatWebp.CONSTRUCTOR: { + this.imageFile = new ImageFile(tdlib, sticker.sticker); + this.imageFile.setSize(size); + this.imageFile.setScaleType(ImageFile.FIT_CENTER); + this.imageFile.setNoBlur(); + break; + } + } + } + + @Override + public void onCustomEmojiLoaded (TdlibEmojiManager context, TdlibEmojiManager.Entry entry) { + this.customEmoji = entry; + if (!entry.isNotFound()) { + buildCustomEmojiFiles(entry); + } + // Request files and invalidate view on UI thread + if (tdlib != null) { + tdlib.ui().post(() -> { + requestIconFiles(); + if (attachedView != null) { + attachedView.invalidate(); + } + }); + } + } + + private void requestIconFiles () { + if (iconReceiver == null) return; + + DoubleImageReceiver preview = iconReceiver.getPreviewReceiver(0); + preview.requestFile(null, thumbnail); + if (imageFile != null) { + iconReceiver.getImageReceiver(0).requestFile(imageFile); + } else if (gifFile != null) { + iconReceiver.getGifReceiver(0).requestFile(gifFile); + } + } + + /** + * Attach the modifier to a view to enable custom emoji rendering. + * Must be called when the view is bound. + */ + public void attachToView (View view) { + this.attachedView = view; + if (customEmojiId != 0 && iconReceiver == null) { + this.iconReceiver = new ComplexReceiver(view); + requestIconFiles(); + } + } + + /** + * Detach from the view and clean up receivers. + */ + public void detach () { + this.attachedView = null; + if (iconReceiver != null) { + iconReceiver.clear(); + iconReceiver = null; + } + } + + @Override + public void beforeDraw (View view, Canvas c) { + // Attach view if not already attached + if (attachedView != view) { + attachToView(view); + } + + int iconSize = Screen.dp(ICON_SIZE_DP); + int leftOffset = Screen.dp(LEFT_OFFSET_DP); + int centerY = view.getHeight() / 2; + + // Try to draw custom emoji first + if (customEmojiId != 0 && iconReceiver != null) { + int left = leftOffset; + int top = centerY - iconSize / 2; + int right = left + iconSize; + int bottom = top + iconSize; + + // Draw from image receiver (static or animated) + ImageReceiver imageRcv = iconReceiver.getImageReceiver(0); + GifReceiver gifRcv = iconReceiver.getGifReceiver(0); + + if (gifFile != null && gifRcv != null) { + gifRcv.setBounds(left, top, right, bottom); + if (gifRcv.needPlaceholder()) { + // Draw thumbnail as placeholder + DoubleImageReceiver preview = iconReceiver.getPreviewReceiver(0); + if (preview != null) { + preview.setBounds(left, top, right, bottom); + preview.draw(c); + } + } + gifRcv.draw(c); + return; + } else if (imageFile != null && imageRcv != null) { + imageRcv.setBounds(left, top, right, bottom); + if (!imageRcv.needPlaceholder()) { + imageRcv.draw(c); + return; + } + // Draw thumbnail as placeholder + DoubleImageReceiver preview = iconReceiver.getPreviewReceiver(0); + if (preview != null) { + preview.setBounds(left, top, right, bottom); + preview.draw(c); + } + if (!imageRcv.needPlaceholder()) { + imageRcv.draw(c); + return; + } + } + } + + // Fallback: draw colored circle + float cx = leftOffset + iconSize / 2f; + float cy = centerY; + float radius = iconSize / 2f; + c.drawCircle(cx, cy, radius, circlePaint); + } + + @Override + public int getWidth () { + // Reserve space for the icon (not needed for left-side icons actually, + // but keep minimal to avoid affecting text layout) + return 0; + } +} diff --git a/app/src/main/java/org/thunderdog/challegram/v/ChatsRecyclerView.java b/app/src/main/java/org/thunderdog/challegram/v/ChatsRecyclerView.java index b28eb66ddb..aa3cdf5543 100644 --- a/app/src/main/java/org/thunderdog/challegram/v/ChatsRecyclerView.java +++ b/app/src/main/java/org/thunderdog/challegram/v/ChatsRecyclerView.java @@ -184,6 +184,13 @@ public void updateChatUnreadMentionCount (long chatId, int unreadMentionCount) { } } + public void updateForumUnreadTopicCount (long chatId) { + int updated = adapter.updateForumUnreadTopicCount(chatId); + if (updated != -1) { + invalidateViewAt(updated); + } + } + public void updateChatHasScheduledMessages (long chatId, boolean hasScheduledMessages) { int updated = adapter.updateChatHasScheduledMessages(chatId, hasScheduledMessages); if (updated != -1) { diff --git a/app/src/main/java/org/thunderdog/challegram/widget/BaseView.java b/app/src/main/java/org/thunderdog/challegram/widget/BaseView.java index 8bc7a6b7c0..9712235acd 100644 --- a/app/src/main/java/org/thunderdog/challegram/widget/BaseView.java +++ b/app/src/main/java/org/thunderdog/challegram/widget/BaseView.java @@ -318,6 +318,10 @@ public boolean onLongPressRequestedAt (View view, float x, float y) { case FORCE_TOUCH_CHAT: { TdApi.Chat chat = tdlib.chat(chatId); if (chat != null) { + // Forums don't support preview mode - skip to normal long-press menu + if (tdlib.isForum(chat.id)) { + break; + } if (threadMessages != null && threadMessages.length > 0) { cancelAsyncPreview(); tdlib.send(new TdApi.GetMessageThread(chatId, threadMessages[0].id), (threadInfo, error) -> { diff --git a/app/src/main/java/org/thunderdog/challegram/widget/BetterChatView.java b/app/src/main/java/org/thunderdog/challegram/widget/BetterChatView.java index 74b266bd86..e43d42c595 100644 --- a/app/src/main/java/org/thunderdog/challegram/widget/BetterChatView.java +++ b/app/src/main/java/org/thunderdog/challegram/widget/BetterChatView.java @@ -622,6 +622,11 @@ public void onChatMarkedAsUnread (long chatId, boolean isMarkedAsUnread) { updateChat(chatId); } + @Override + public void onForumUnreadTopicCountChanged (long chatId, int unreadTopicCount) { + updateChat(chatId); + } + @Override public void onUserUpdated (final TdApi.User user) { tdlib.uiExecute(() -> { diff --git a/app/src/main/java/org/thunderdog/challegram/widget/ClearButton.java b/app/src/main/java/org/thunderdog/challegram/widget/ClearButton.java index 436dbb3a25..1ff2ff5697 100644 --- a/app/src/main/java/org/thunderdog/challegram/widget/ClearButton.java +++ b/app/src/main/java/org/thunderdog/challegram/widget/ClearButton.java @@ -18,6 +18,7 @@ import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; +import android.graphics.RectF; import android.view.MotionEvent; import android.view.ViewGroup; import android.widget.LinearLayout; @@ -44,6 +45,12 @@ public class ClearButton extends HeaderButton { private final int lineRadius = (int) ((float) lineHeight * .5f); private final Paint paint; + private final RectF arcBounds = new RectF(); + + // Progress spinner state + private boolean inProgress; + private float progressAngle; + private ValueAnimator progressAnimator; public ClearButton (Context context) { super(context); @@ -147,7 +154,40 @@ public float getFactor () { } public void setInProgress (boolean inProgress) { - // TODO + if (this.inProgress == inProgress) { + return; + } + this.inProgress = inProgress; + if (inProgress) { + startProgressAnimation(); + } else { + stopProgressAnimation(); + } + invalidate(); + } + + private void startProgressAnimation () { + if (progressAnimator != null) { + progressAnimator.cancel(); + } + progressAnimator = ValueAnimator.ofFloat(0f, 360f); + progressAnimator.setDuration(800L); + progressAnimator.setRepeatCount(ValueAnimator.INFINITE); + progressAnimator.setRepeatMode(ValueAnimator.RESTART); + progressAnimator.setInterpolator(AnimatorUtils.LINEAR_INTERPOLATOR); + progressAnimator.addUpdateListener(animation -> { + progressAngle = (float) animation.getAnimatedValue(); + invalidate(); + }); + progressAnimator.start(); + } + + private void stopProgressAnimation () { + if (progressAnimator != null) { + progressAnimator.cancel(); + progressAnimator = null; + } + progressAngle = 0f; } @SuppressWarnings ("ConstantConditions") @@ -156,27 +196,39 @@ protected void onDraw (Canvas c) { if (colorId != 0) { paint.setColor(Theme.getColor(colorId)); } - if (factor > 0f && totalHeight > 0) { - switch (ANIMATION_MODE) { - case ANIMATION_MODE_SCALE: { - paint.setAlpha((int) (255f * Math.min(1f, factor))); - int d = (int) ((float) lineHeight * .5f * factor); - c.drawLine(cx - d, cy - d, cx + d, cy + d, paint); - c.drawLine(cx + d, cy - d, cx - d, cy + d, paint); - break; - } - case ANIMATION_MODE_CROSS: { - DrawAlgorithms.drawAnimatedCross(c, cx, cy, factor, 0xffffffff, lineHeight); - break; - } - case ANIMATION_MODE_ROTATE: { - c.save(); - c.rotate((Lang.rtl() ? -90 : 90f) * (1f - factor), cx, cy); - int d = (int) ((float) lineHeight * .5f * factor); - c.drawLine(cx - d, cy - d, cx + d, cy + d, paint); - c.drawLine(cx + d, cy - d, cx - d, cy + d, paint); - c.restore(); - break; + if (totalHeight > 0) { + // Draw spinning arc when in progress + if (inProgress) { + int radius = lineRadius; + arcBounds.set(cx - radius, cy - radius, cx + radius, cy + radius); + paint.setStyle(Paint.Style.STROKE); + c.drawArc(arcBounds, progressAngle, 270f, false, paint); + return; + } + + // Draw X button + if (factor > 0f) { + switch (ANIMATION_MODE) { + case ANIMATION_MODE_SCALE: { + paint.setAlpha((int) (255f * Math.min(1f, factor))); + int d = (int) ((float) lineHeight * .5f * factor); + c.drawLine(cx - d, cy - d, cx + d, cy + d, paint); + c.drawLine(cx + d, cy - d, cx - d, cy + d, paint); + break; + } + case ANIMATION_MODE_CROSS: { + DrawAlgorithms.drawAnimatedCross(c, cx, cy, factor, 0xffffffff, lineHeight); + break; + } + case ANIMATION_MODE_ROTATE: { + c.save(); + c.rotate((Lang.rtl() ? -90 : 90f) * (1f - factor), cx, cy); + int d = (int) ((float) lineHeight * .5f * factor); + c.drawLine(cx - d, cy - d, cx + d, cy + d, paint); + c.drawLine(cx + d, cy - d, cx - d, cy + d, paint); + c.restore(); + break; + } } } } diff --git a/app/src/main/java/org/thunderdog/challegram/widget/VerticalChatView.java b/app/src/main/java/org/thunderdog/challegram/widget/VerticalChatView.java index d07d209ee8..1f14888b88 100644 --- a/app/src/main/java/org/thunderdog/challegram/widget/VerticalChatView.java +++ b/app/src/main/java/org/thunderdog/challegram/widget/VerticalChatView.java @@ -243,6 +243,11 @@ public void onChatReadInbox(long chatId, long lastReadInboxMessageId, int unread updateChat(chatId); } + @Override + public void onForumUnreadTopicCountChanged (long chatId, int unreadTopicCount) { + updateChat(chatId); + } + @Override public void onChatDefaultMessageSenderIdChanged (long chatId, TdApi.MessageSender senderId) { updateChat(chatId); diff --git a/app/src/main/res/drawable-v21/bg_btn_header.xml b/app/src/main/res/drawable-v21/bg_btn_header.xml index af7a00ea0a..2f0bd9328d 100644 --- a/app/src/main/res/drawable-v21/bg_btn_header.xml +++ b/app/src/main/res/drawable-v21/bg_btn_header.xml @@ -1,2 +1,8 @@ - \ No newline at end of file + + + + + + + diff --git a/app/src/main/res/drawable/bg_btn_header.xml b/app/src/main/res/drawable/bg_btn_header.xml index c60863e72d..54b66de634 100644 --- a/app/src/main/res/drawable/bg_btn_header.xml +++ b/app/src/main/res/drawable/bg_btn_header.xml @@ -2,17 +2,17 @@ - + - + - + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index f2848aa6e6..1fef5e9110 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -173,6 +173,8 @@ + + @@ -344,6 +346,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1194,6 +1223,8 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eb01efb3b8..6068bc04b4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2280,6 +2280,41 @@ %1$s made topic %2$s visible You made topic %1$s visible + + Topics + No topics yet + Loading topics… + Pin Topic + Unpin Topic + Close Topic + Reopen Topic + Edit Topic + Delete Topic + Are you sure you want to delete topic **%1$s**? All messages in this topic will be deleted. + New Topic + Topic name + Enter topic name + Topics + Enable topics to organize discussions in this group. Only the group owner can toggle this setting. + Show as Tabs + When enabled, topics will be displayed as horizontal tabs at the top of the chat instead of a list. + Group Info + Topic created + Topic renamed to %1$s + Topic icon changed + Topic closed + Topic reopened + General topic hidden + General topic shown + View as chat + View as topics + View as tabs + Filter by topic + Search for messages + No messages found + Change Topic Icon + Reset Topic Icon + %1$s muted new video chat participants %1$s allowed new video chat participants to speak You muted new video chat participants