Skip to content

Commit d9b09b8

Browse files
suenotclaude
andcommitted
feat: add Telegram forum topics support (v0.4.0)
Add full support for Telegram forum-style supergroups with topics: - Detect forum groups via is_forum flag on both Peer::Group and Peer::Channel - New get_forum_topics command using raw TL messages.GetForumTopics API - Topic-aware get_messages (via messages.GetReplies) and send_message (via reply_to) - TopicList component with colored icons, pinned/closed badges, unread counts - MainLayout integration: chat → topic list → topic messages navigation - Forum badge icon in chat list for forum groups - SQLite migration adding is_forum column - i18n translations (EN + RU) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fd4acc9 commit d9b09b8

22 files changed

Lines changed: 543 additions & 36 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "vasyapp",
33
"private": true,
4-
"version": "0.3.0",
4+
"version": "0.4.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "telegram-client"
3-
version = "0.2.0"
3+
version = "0.4.0"
44
description = "A Tauri App"
55
authors = ["you"]
66
edition = "2021"

src-tauri/src/commands/chats.rs

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub struct Chat {
2424
pub chat_type: String, // "user", "group", "channel"
2525
pub last_message: Option<String>,
2626
pub avatar_path: Option<String>,
27+
pub is_forum: bool,
2728
}
2829

2930
#[derive(Debug, Clone, Serialize)]
@@ -61,6 +62,7 @@ pub async fn get_cached_chats(
6162
chat_type: r.chat_type,
6263
last_message: r.last_message,
6364
avatar_path: r.avatar_path,
65+
is_forum: r.is_forum,
6466
})
6567
.collect();
6668

@@ -114,17 +116,24 @@ pub async fn start_loading_chats(
114116
while let Some(dialog) = dialogs.next().await.map_err(|e| format!("Failed to get dialogs: {}", e))? {
115117
let peer = &dialog.peer;
116118

117-
let chat_type = match peer {
118-
Peer::User(_) => "user",
119-
Peer::Group(_) => "group",
120-
Peer::Channel(_) => "channel",
119+
let (chat_type, is_forum) = match peer {
120+
Peer::User(_) => ("user", false),
121+
Peer::Group(g) => {
122+
// Megagroups (supergroups) with forum flag enabled
123+
let forum = match &g.raw {
124+
grammers_tl_types::enums::Chat::Channel(ch) => ch.forum,
125+
_ => false,
126+
};
127+
("group", forum)
128+
},
129+
Peer::Channel(c) => ("channel", c.raw.forum),
121130
};
122131

123132
let title = peer.name().unwrap_or("Unknown").to_string();
124133
let username = match peer {
125134
Peer::User(u) => u.username().map(|s| s.to_string()),
126135
Peer::Channel(c) => c.username().map(|s| s.to_string()),
127-
_ => None,
136+
Peer::Group(g) => g.username().map(|s| s.to_string()),
128137
};
129138

130139
let chat_id = PeerRef::from(peer).id.bot_api_dialog_id();
@@ -164,6 +173,7 @@ pub async fn start_loading_chats(
164173
chat_type: chat_type.to_string(),
165174
last_message: last_message_text.clone(),
166175
avatar_path: cached_avatar.clone(),
176+
is_forum,
167177
};
168178

169179
// EMIT IMMEDIATELY — no waiting for avatar download or DB save
@@ -186,6 +196,7 @@ pub async fn start_loading_chats(
186196
let last_message_text = last_message_text.clone();
187197
let cached_avatar = cached_avatar.clone();
188198
let semaphore = Arc::clone(&avatar_semaphore);
199+
let is_forum = is_forum;
189200

190201
tokio::spawn(async move {
191202
// Download avatar if not cached (rate-limited by semaphore)
@@ -213,6 +224,7 @@ pub async fn start_loading_chats(
213224
avatar_path: avatar_path.clone(),
214225
last_message: last_message_text.clone(),
215226
unread_count: 0,
227+
is_forum,
216228
};
217229
if let Err(e) = storage.save_chat(&record).await {
218230
tracing::warn!(chat_id = chat_id, error = %e, "Failed to save chat to storage");
@@ -259,17 +271,23 @@ pub async fn get_chats(
259271

260272
while let Some(dialog) = dialogs.next().await.map_err(|e| format!("Failed to get dialogs: {}", e))? {
261273
let peer = &dialog.peer;
262-
let chat_type = match peer {
263-
Peer::User(_) => "user",
264-
Peer::Group(_) => "group",
265-
Peer::Channel(_) => "channel",
274+
let (chat_type, is_forum) = match peer {
275+
Peer::User(_) => ("user", false),
276+
Peer::Group(g) => {
277+
let forum = match &g.raw {
278+
grammers_tl_types::enums::Chat::Channel(ch) => ch.forum,
279+
_ => false,
280+
};
281+
("group", forum)
282+
},
283+
Peer::Channel(c) => ("channel", c.raw.forum),
266284
};
267285

268286
let title = peer.name().unwrap_or("Unknown").to_string();
269287
let username = match peer {
270288
Peer::User(u) => u.username().map(|s| s.to_string()),
271289
Peer::Channel(c) => c.username().map(|s| s.to_string()),
272-
_ => None,
290+
Peer::Group(g) => g.username().map(|s| s.to_string()),
273291
};
274292

275293
let chat_id = PeerRef::from(peer).id.bot_api_dialog_id();
@@ -283,6 +301,7 @@ pub async fn get_chats(
283301
chat_type: chat_type.to_string(),
284302
last_message: None,
285303
avatar_path,
304+
is_forum,
286305
});
287306
}
288307

src-tauri/src/commands/messages.rs

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,19 +55,21 @@ fn extract_media_info(msg: &GrammersMessage) -> Option<Vec<MediaInfo>> {
5555
})
5656
}
5757

58-
/// Get messages from a chat
58+
/// Get messages from a chat (or from a specific forum topic when topic_id is provided)
5959
#[tauri::command]
6060
pub async fn get_messages(
6161
account_id: String,
6262
chat_id: i64,
6363
offset_id: Option<i32>,
6464
limit: Option<usize>,
65+
topic_id: Option<i32>,
6566
state: State<'_, Arc<RwLock<AppState>>>,
6667
) -> Result<Vec<Message>, String> {
6768
tracing::info!(
6869
account_id = %account_id,
6970
chat_id = chat_id,
7071
offset_id = ?offset_id,
72+
topic_id = ?topic_id,
7173
"Getting messages"
7274
);
7375

@@ -84,8 +86,66 @@ pub async fn get_messages(
8486
}; // state_guard dropped here
8587

8688
let chat = resolve_peer(&wrapper, chat_id).await?;
87-
8889
let limit = limit.unwrap_or(50);
90+
91+
// For forum topics, use messages.getReplies with msg_id = topic_id
92+
if let Some(tid) = topic_id {
93+
let input_peer: grammers_tl_types::enums::InputPeer = PeerRef::from(&chat).into();
94+
let request = grammers_tl_types::functions::messages::GetReplies {
95+
peer: input_peer,
96+
msg_id: tid,
97+
offset_id: offset_id.unwrap_or(0),
98+
offset_date: 0,
99+
add_offset: 0,
100+
limit: limit as i32,
101+
max_id: 0,
102+
min_id: 0,
103+
hash: 0,
104+
};
105+
106+
let result = wrapper
107+
.client
108+
.invoke(&request)
109+
.await
110+
.map_err(|e| format!("Failed to get topic messages: {}", e))?;
111+
112+
let raw_messages = match result {
113+
grammers_tl_types::enums::messages::Messages::Messages(m) => m.messages,
114+
grammers_tl_types::enums::messages::Messages::Slice(m) => m.messages,
115+
grammers_tl_types::enums::messages::Messages::ChannelMessages(m) => m.messages,
116+
grammers_tl_types::enums::messages::Messages::NotModified(_) => Vec::new(),
117+
};
118+
119+
let messages: Vec<Message> = raw_messages
120+
.into_iter()
121+
.filter_map(|m| {
122+
match m {
123+
grammers_tl_types::enums::Message::Message(msg) => {
124+
let from_user_id = msg.from_id.as_ref().map(|p| match p {
125+
grammers_tl_types::enums::Peer::User(u) => u.user_id,
126+
grammers_tl_types::enums::Peer::Chat(c) => c.chat_id,
127+
grammers_tl_types::enums::Peer::Channel(c) => c.channel_id,
128+
});
129+
Some(Message {
130+
id: msg.id,
131+
chat_id,
132+
from_user_id,
133+
text: if msg.message.is_empty() { None } else { Some(msg.message) },
134+
date: msg.date as i64,
135+
is_outgoing: msg.out,
136+
media: None, // Raw TL media parsing would be complex; keep simple for now
137+
})
138+
}
139+
_ => None,
140+
}
141+
})
142+
.collect();
143+
144+
tracing::info!(count = messages.len(), chat_id = chat_id, topic_id = tid, "Topic messages loaded");
145+
return Ok(messages);
146+
}
147+
148+
// Regular messages (no topic)
89149
let mut messages_iter = wrapper.client.iter_messages(&chat);
90150

91151
if let Some(offset) = offset_id {
@@ -193,15 +253,16 @@ pub async fn search_messages(
193253
Ok(messages)
194254
}
195255

196-
/// Send a message to a chat
256+
/// Send a message to a chat (or to a specific forum topic when topic_id is provided)
197257
#[tauri::command]
198258
pub async fn send_message(
199259
account_id: String,
200260
chat_id: i64,
201261
text: String,
262+
topic_id: Option<i32>,
202263
state: State<'_, Arc<RwLock<AppState>>>,
203264
) -> Result<Message, String> {
204-
tracing::info!(account_id = %account_id, chat_id = chat_id, "Sending message");
265+
tracing::info!(account_id = %account_id, chat_id = chat_id, topic_id = ?topic_id, "Sending message");
205266

206267
if text.trim().is_empty() {
207268
return Err("Message text cannot be empty".to_string());
@@ -221,9 +282,16 @@ pub async fn send_message(
221282

222283
let chat = resolve_peer(&wrapper, chat_id).await?;
223284

285+
// For forum topics, use InputMessage with reply_to set to topic_id
286+
let input_msg = if let Some(tid) = topic_id {
287+
grammers_client::InputMessage::new().text(&text).reply_to(Some(tid))
288+
} else {
289+
grammers_client::InputMessage::new().text(&text)
290+
};
291+
224292
let sent_message = wrapper
225293
.client
226-
.send_message(&chat, text.clone())
294+
.send_message(&chat, input_msg)
227295
.await
228296
.map_err(|e| format!("Failed to send message: {}", e))?;
229297

src-tauri/src/commands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod peer_resolve;
1212
pub mod flood_wait;
1313
pub mod stt;
1414
pub mod folders;
15+
pub mod topics;
1516

1617
pub use auth::*;
1718
pub use settings::*;
@@ -20,3 +21,4 @@ pub use messages::*;
2021
pub use media::*;
2122
pub use stt::*;
2223
pub use folders::*;
24+
pub use topics::*;

src-tauri/src/commands/topics.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//! Forum topics commands
2+
//!
3+
//! Retrieves topics from Telegram forum supergroups using raw TL API calls.
4+
5+
use std::sync::Arc;
6+
use tauri::State;
7+
use tokio::sync::RwLock;
8+
use serde::{Deserialize, Serialize};
9+
use grammers_session::defs::PeerRef;
10+
use grammers_tl_types as tl;
11+
12+
use crate::AppState;
13+
use super::peer_resolve::resolve_peer;
14+
15+
#[derive(Debug, Serialize, Deserialize)]
16+
#[serde(rename_all = "camelCase")]
17+
pub struct ForumTopic {
18+
pub id: i32,
19+
pub title: String,
20+
pub icon_color: i32,
21+
pub icon_emoji_id: Option<i64>,
22+
pub unread_count: i32,
23+
pub top_message: i32,
24+
pub is_pinned: bool,
25+
pub is_closed: bool,
26+
}
27+
28+
/// Get forum topics for a forum supergroup
29+
#[tauri::command]
30+
pub async fn get_forum_topics(
31+
account_id: String,
32+
chat_id: i64,
33+
state: State<'_, Arc<RwLock<AppState>>>,
34+
) -> Result<Vec<ForumTopic>, String> {
35+
tracing::info!(
36+
account_id = %account_id,
37+
chat_id = chat_id,
38+
"Getting forum topics"
39+
);
40+
41+
let wrapper = {
42+
let state_guard = state.read().await;
43+
let client_manager = state_guard
44+
.client_manager
45+
.as_ref()
46+
.ok_or("Client manager not initialized")?;
47+
client_manager
48+
.get_client(&account_id)
49+
.await
50+
.ok_or("Client not found for this account")?
51+
};
52+
53+
let peer = resolve_peer(&wrapper, chat_id).await?;
54+
let input_peer: tl::enums::InputPeer = PeerRef::from(&peer).into();
55+
56+
let request = tl::functions::messages::GetForumTopics {
57+
peer: input_peer,
58+
q: None,
59+
offset_date: 0,
60+
offset_id: 0,
61+
offset_topic: 0,
62+
limit: 100,
63+
};
64+
65+
let result = wrapper
66+
.client
67+
.invoke(&request)
68+
.await
69+
.map_err(|e| format!("Failed to get forum topics: {}", e))?;
70+
71+
let tl::enums::messages::ForumTopics::Topics(forum_topics) = result;
72+
73+
let topics: Vec<ForumTopic> = forum_topics
74+
.topics
75+
.into_iter()
76+
.filter_map(|topic| {
77+
match topic {
78+
tl::enums::ForumTopic::Topic(t) => Some(ForumTopic {
79+
id: t.id,
80+
title: t.title,
81+
icon_color: t.icon_color,
82+
icon_emoji_id: t.icon_emoji_id,
83+
unread_count: t.unread_count,
84+
top_message: t.top_message,
85+
is_pinned: t.pinned,
86+
is_closed: t.closed,
87+
}),
88+
tl::enums::ForumTopic::Deleted(_) => None,
89+
}
90+
})
91+
.collect();
92+
93+
tracing::info!(count = topics.len(), chat_id = chat_id, "Forum topics loaded");
94+
Ok(topics)
95+
}

src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ pub fn run() {
7474
commands::save_tabs,
7575
commands::get_storage_mode,
7676
commands::set_storage_mode,
77+
commands::get_forum_topics,
7778
])
7879
.setup(|app| {
7980
let app_dir = app

0 commit comments

Comments
 (0)