Telegram Desktop and Compose Multiplatform

An example of what a chat with images will look like.

An example of what a chat with images will look like.

Hi all! In my last article, I talked about how you can run a Telegram client as a backend service. The library described there has since undergone some optimizations, and overall I was pleased with the capabilities I received. After which there was a desire to add a visual part to the existing backend and at the same time learn something new for myself. The choice fell on the Compose Multiplatform framework. Let's make a desktop version of Telegram!

Where to begin?

Compose Multiplatform is a declarative framework for building interfaces for various platforms using Kotlin and Jetpack Compose. And since this was my first experience with Jetpack Compose, that’s why at the beginning I read some articles and took several laboratories on portal developers for android. Afterwards you can watch tutorials directly in the Compose Multiplatform repository from JetBrains.

Create a project

You can create a project from IntelliJ IDEA (the community version is enough) by selecting a template Compose for Desktop or through wizard. As a result, a Gradle project is formed, where the necessary plugins and dependencies for the framework are already connected, and if we need something additional, it can be added as a dependency. In our case, in addition to the UI part, there will also be a backend, that is, when starting a native application, a java process will also have to start in parallel. It turns out that when compiling a native application, the Compose plugin must know what third-party resources we use and take this into account, and we must be able to access them from our code. You can specify the directory with resources through installation appResourcesRootDirlet's call it resources and place it in the project root:

compose.desktop {
    application {
        mainClass = "TelegramComposeMultiplatformKt"

        jvmArgs += listOf("-Xmx256m")

        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "TelegramComposeMultiplatform"
            packageVersion = "1.0.0"
            appResourcesRootDir.set(project.layout.projectDirectory.dir("resources"))
        }
    }
}

Now we can use the property compose.application.resources.dir access connected resources, I made an auxiliary class for this:

class Resources {

    companion object {

        private val resourcesDirectory: File = File(System.getProperty("compose.application.resources.dir"))

        fun resourcesDirectory(): File = resourcesDirectory

        fun resolve(relative: String) : File = resourcesDirectory.resolve(relative)

    }

}

Let's see what the final structure of the resource directory looks like to pay attention to another important thing:

resources
├── common
│   ├── backend-0.0.1.jar
│   └── content_loader.gif
├── linux-arm64
│   └── libtdjni.so
├── linux-x64
│   └── libtdjni.so
├── macos-arm64
│   └── libtdjni.dylib
├── macos-x64
│   └── libtdjni.dylib
└── windows-x64
    ├── libcrypto-1_1-x64.dll
    ├── libssl-1_1-x64.dll
    ├── tdjni.dll
    └── zlib1.dll
src
├── main
│   ├── kotlin

The Compose plugin has a rule for working with resource subdirectories. Catalog common contains files that will be included in the build for any platform. To include files for a specific OS and architecture, there are naming rules – <resource_dir>/<os_name> or <resource_dir>/<os_name>-<arch_name>(possible OS – windows, macos, linux; architecture – x64, arm64). When building the application, the plugin will independently be able to take the necessary files for the desired architecture. In our case, to the catalog common The backend jar will be sent, and the platform-specific directories will contain compiled native TDLib libraries for the backend (you can see the features of working with this library in my article).

Let's start implementation

When the application starts, we will show the first window in which the launch status will be displayed, and we will also launch the backend in parallel.

fun main() = application {

    val backendStarted = remember { mutableStateOf(false) }

    val appScope = rememberCoroutineScope()

    appScope.launch {
        startBackend()
        awaitReadiness(backendStarted)
    }

    Window(
        title = "Telegram Compose Multiplatform",
        state = WindowState(width = 1200.dp, height = 800.dp),
        onCloseRequest = {
            terminatingApp.set(true)
            appScope.launch {
                delay(300)
                httpClient.post("${baseUrl}/${clientUri}/shutdown")
            }.invokeOnCompletion {
                exitApplication()
                httpClient.close()
            }
        }
    ) {
        if (backendStarted.value) {
            App()
        } else {
            LoadingDisclaimer("Starting...")
        }
    }

}

In the next step, we need to check whether the client is authorized in order to continue working.

@Composable
fun App() {
    Row {
        var waitCode by remember { mutableStateOf(false) }
        var waitPass by remember { mutableStateOf(false) }
        var status by remember { mutableStateOf(Status.NOT_AUTHORIZED) }

        LaunchedEffect(Unit) {
            status = authorizationStatus()
        }

        val mainScope = rememberCoroutineScope()

        mainScope.launch {
            while (!terminatingApp.get()) {
                status = authorizationStatus()
                if (status == Status.AUTHORIZED) break
                waitCode = waitCode()
                delay(300)
                waitPass = waitPass()
                delay(300)
            }
        }

        if (status == Status.AUTHORIZED) {
            InitialLoad()
        } else if (waitCode) {
            AuthForm(AuthType.CODE, mainScope)
        } else if (waitPass) {
            AuthForm(AuthType.PASSWORD, mainScope)
        }
    }
}

If authorization is required, a simple form for entering a code will appear, which will be sent to the official client.

If the second verification factor is activated, you will also have to enter it.

After authorization and loading the list of chats (caching icons and other information), you can display the main client scene. I made the simplest possible option – this is a list of chats with their filtering on the left, as well as on the right a place for the chat window that we have selected. To display the list of chats correctly, it is not enough to simply download them – you need to subscribe to updates that may occur in this list, namely:

  1. A new chat may appear

  2. Chat in the list can be deleted

  3. The chat icon and title may change

  4. The last message that we also display changes

  5. A counter of unread messages should appear, it can change value

  6. The position of the chat in the list changes, chats with new messages rise higher in the list.

To figure out what these updates are and how to receive them, let's take a little look at the backend. In this case, this is a regular Spring Boot project. In it I created a package tdlibin which I registered the components of the most necessary alerts from TDLib at this stage, here is its composition:

tdlib
├── UpdateBasicGroup.java
├── UpdateBasicGroupFullInfo.java
├── UpdateChatLastMessageHandler.java
├── UpdateChatNewOrder.java
├── UpdateChatPhoto.java
├── UpdateChatReadInbox.java
├── UpdateChatTitleHandler.java
├── UpdateDeleteMessages.java
├── UpdateFile.java
├── UpdateMessageContent.java
├── UpdateNewChat.java
├── UpdateNewMessage.java
├── UpdateSupergroup.java
├── UpdateSupergroupFullInfo.java
├── UpdateUser.java

For example, let's look at the chat icon update event:

@Service
public class UpdateChatPhoto implements UpdateNotificationListener<TdApi.UpdateChatPhoto> {

    private final UpdatesQueues updatesQueues;

    public UpdateChatPhoto(UpdatesQueues updatesQueues) {
        this.updatesQueues = updatesQueues;
    }

    @Override
    public void handleNotification(TdApi.UpdateChatPhoto updateChatPhoto) {
        TdApi.Chat chat = Caches.initialChatCache.get(updateChatPhoto.chatId);
        if (chat != null) {
            synchronized (chat) {
                chat.photo = updateChatPhoto.photo;
            }
            updatesQueues.addIncomingSidebarUpdate(updateChatPhoto);
        }
    }
    
    @Override
    public Class<TdApi.UpdateChatPhoto> notificationType() {
        return TdApi.UpdateChatPhoto.class;
    }
}

In this case, this is a regular Spring component, in which we receive a notification that the icon has been updated. Then we are free to do whatever we want with it. I simply cache the object and push the update to the queue. You can work similarly with any other updates in the client. As a result, I made a service that can collect and transmit updates for a list of chats.

@Component
public class GetSidebarUpdates implements Supplier<List<ChatPreview>> {

    @Autowired
    private GetChatLastMessage getChatLastMessage;

    @Autowired
    private GetChatNewTitle getChatNewTitle;

    @Autowired
    private GetChatNewOrder getChatNewOrder;

    @Autowired
    private GetChatReadInbox getChatReadInbox;

    @Autowired
    private GetNewChat getNewChat;

    @Autowired
    private GetNewChatPhoto getNewChatPhoto;

    @Autowired
    private UpdatesQueues updatesQueues;

    @Override
    public List<ChatPreview> get() {
        List<ChatPreview> previews = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            TdApi.Update update = updatesQueues.pollIncomingSidebarUpdate();
            if (update == null) break;

            if (update instanceof TdApi.UpdateChatLastMessage upd) {
                addPreviewFromUpdate(getChatLastMessage, upd, previews);
            } else if (update instanceof TdApi.UpdateChatTitle upd) {
                addPreviewFromUpdate(getChatNewTitle, upd, previews);
            } else if (update instanceof TdApi.UpdateChatPosition upd) {
                addPreviewFromUpdate(getChatNewOrder, upd, previews);
            } else if (update instanceof TdApi.UpdateChatReadInbox upd) {
                addPreviewFromUpdate(getChatReadInbox, upd, previews);
            } else if (update instanceof TdApi.UpdateNewChat upd) {
                addPreviewFromUpdate(getNewChat, upd, previews);
            } else if (update instanceof TdApi.UpdateChatPhoto upd) {
                addPreviewFromUpdate(getNewChatPhoto, upd, previews);
            }

        }

        return previews;
    }

    private <T extends TdApi.Update> void addPreviewFromUpdate(Function<T, ChatPreview> func,
                                                               T update,
                                                               List<ChatPreview> previews) {
        ChatPreview preview = func.apply(update);
        if (preview != null) previews.add(preview);
    }

}

Let's return to the UI. Now we can display the list of chat cards:

LazyColumn(state = lazyListState, modifier = sidebarWidthModifier.background(MaterialTheme.colors.surface).fillMaxHeight()) {

    itemsIndexed(clientStates.chatPreviews, { _, v -> v}) { _, chatPreview ->

        if (chatSearchInput.value.isBlank() || chatPreview.title.contains(chatSearchInput.value, ignoreCase = true)) {
            var hasUnread = false
            chatPreview.unreadCount?.let {
                if (it != 0) {
                    hasUnread = true
                }
            }
            val onChatClick = {
                selectedChatId.value = chatPreview.id
                clientStates.selectedChatPreview.value = chatPreview
            }
            if (filterUnreadChats.value && hasUnread) {
                ChatCard(chatListUpdateScope = chatListUpdateScope, chatPreview = chatPreview, selectedChatId = selectedChatId, onClick = onChatClick)
                Divider(modifier = sidebarWidthModifier.height(2.dp), color = greyColor)
            } else if (!filterUnreadChats.value) {
                ChatCard(chatListUpdateScope = chatListUpdateScope, chatPreview = chatPreview, selectedChatId = selectedChatId, onClick = onChatClick)
                Divider(modifier = sidebarWidthModifier.height(2.dp), color = greyColor)
            }

        }

    }

}

Using this list, you can scroll, filter, switch to unread chats, and you can also delete a chat or mark it as read.

Window with a list of chats to select
@Composable
fun MainScene(clientStates: ClientStates) {

    val selectedChatId: MutableState<Long> = remember { mutableStateOf(-1) }

    var needToScrollUpSidebar by remember { mutableStateOf(false) }

    val chatSearchInput: MutableState<String> = remember { mutableStateOf("") }

    val chatListUpdateScope = rememberCoroutineScope()

    val lazyListState = rememberLazyListState()

    val filterUnreadChats: MutableState<Boolean> = remember { mutableStateOf(false) }

    chatListUpdateScope.launch {
        while (!terminatingApp.get()) {
            val chatsSizeBeforeUpdates = clientStates.chatPreviews.size
            var firstChatPreviewBeforeUpdates: ChatPreview? = null
            if (clientStates.chatPreviews.isNotEmpty()) {
                firstChatPreviewBeforeUpdates = clientStates.chatPreviews[0]
            }

            handleSidebarUpdates(clientStates.chatPreviews)

            needToScrollUpSidebar = (clientStates.chatPreviews.size > chatsSizeBeforeUpdates) ||
                    (clientStates.chatPreviews.isNotEmpty() && lazyListState.firstVisibleItemIndex < 3 && firstChatPreviewBeforeUpdates != clientStates.chatPreviews[0])
            if (needToScrollUpSidebar) {
                lazyListState.scrollToItem(0)
            }

            delay(1000)
        }
    }

    Scaffold(
        topBar = { ScaffoldTopBar(clientStates, chatSearchInput, filterUnreadChats) },
        backgroundColor = greyColor
    ) {

        Row {

            Box {
                LazyColumn(state = lazyListState, modifier = sidebarWidthModifier.background(MaterialTheme.colors.surface).fillMaxHeight()) {

                    itemsIndexed(clientStates.chatPreviews, { _, v -> v}) { _, chatPreview ->

                        if (chatSearchInput.value.isBlank() || chatPreview.title.contains(chatSearchInput.value, ignoreCase = true)) {
                            var hasUnread = false
                            chatPreview.unreadCount?.let {
                                if (it != 0) {
                                    hasUnread = true
                                }
                            }
                            val onChatClick = {
                                selectedChatId.value = chatPreview.id
                                clientStates.selectedChatPreview.value = chatPreview
                            }
                            if (filterUnreadChats.value && hasUnread) {
                                ChatCard(chatListUpdateScope = chatListUpdateScope, chatPreview = chatPreview, selectedChatId = selectedChatId, onClick = onChatClick)
                                Divider(modifier = sidebarWidthModifier.height(2.dp), color = greyColor)
                            } else if (!filterUnreadChats.value) {
                                ChatCard(chatListUpdateScope = chatListUpdateScope, chatPreview = chatPreview, selectedChatId = selectedChatId, onClick = onChatClick)
                                Divider(modifier = sidebarWidthModifier.height(2.dp), color = greyColor)
                            }

                        }

                    }

                }

                if (lazyListState.firstVisibleItemIndex > 3) {
                    Row(modifier = sidebarWidthModifier) {
                        Row(modifier = Modifier.fillMaxSize().padding(top = 12.dp, end = 12.dp), horizontalArrangement = Arrangement.End) {
                            ScrollButton(
                                direction = ScrollDirection.UP,
                                onClick = {
                                    chatListUpdateScope.launch {
                                        lazyListState.animateScrollToItem(0)
                                    }
                                }
                            )
                        }
                    }
                }
            }

            Column {
                if (selectedChatId.value == -1L && clientStates.chatPreviews.isNotEmpty()) {
                    SelectChatOffer()
                } else if (selectedChatId.value != -1L) {

                    var readChat by remember { mutableStateOf(-1L) }

                    Row {
                        Divider(modifier = Modifier.fillMaxHeight().width(2.dp), color = greyColor)
                        ChatWindow(chatId = selectedChatId.value, chatListUpdateScope = chatListUpdateScope, clientStates = clientStates)
                    }

                    chatListUpdateScope.launch {
                        clientStates.selectedChatPreview.let {
                            it.value?.let { currentChat ->
                                currentChat.unreadCount?.let {
                                    if (it > 0 && readChat != currentChat.id) {
                                        readChat = currentChat.id
                                        markAsRead(readChat)
                                    }
                                }
                            }
                        }
                    }

                }
            }

        }

    }

}

Using the same logic, you can work with the chat window. When we open it, we load some initial list of messages from the history, and when we scroll, we load it dynamically. Plus we need to monitor updates in the open chat:

  1. Changing the chat name

  2. If this is a group or channel, then we monitor the change in the participant counter.

  3. If we receive a new message, we add it to the list.

  4. The content of the displayed messages may be changed, so we also monitor it.

  5. If a message is deleted, then we need to delete it too.

It will look like this.

Of course, this is very basic functionality. So far, only text messages and photos are displayed correctly, but Telegram has many different types of messages (videos, animations, documents, polls, stickers, emojis, etc.). Also, text blocks and links are not highlighted yet, but I want to add this soon along with the ability to send messages. Chat forums are also not supported yet, all messages there are displayed in the root chat (this functionality has not yet been fully transferred to TDLib, I think I’ll wait).

Let's put the project together

The Compose plugin allows you to run an application for the jvm platform as a java process(run), build a ready-to-run jar for the current platform(packageUberJarForCurrentOS), compile the native application(createDistributable, packageDistributionForCurrentOS), launch the native application from the plugin(runDistributable). Here is the complete list of plugin tasks:

Compose desktop tasks
Compose desktop tasks
---------------------
checkRuntime
createDistributable
createReleaseDistributable
createRuntimeImage
notarizeDmg
notarizeReleaseDmg
package
packageDeb
packageDistributionForCurrentOS
packageDmg
packageMsi
packageReleaseDeb
packageReleaseDistributionForCurrentOS
packageReleaseDmg
packageReleaseMsi
packageReleaseUberJarForCurrentOS
packageUberJarForCurrentOS
prepareAppResources
proguardReleaseJars
run
runDistributable
runRelease
runReleaseDistributable
suggestRuntimeModules
unpackDefaultComposeDesktopJvmApplicationResources

Let's now look at where the connected resources are ultimately sent using the example of a native application for MacOS. If we ask to see the contents .app package, we will see (the output has been shortened, of course):

TelegramComposeMultiplatform.app
└── Contents
    ├── Info.plist
    ├── MacOS
    │   └── TelegramComposeMultiplatform
    ├── PkgInfo
    ├── Resources
    │   └── TelegramComposeMultiplatform.icns
    ├── _CodeSignature
    │   └── CodeResources
    ├── app
    │   ├── TelegramComposeMultiplatform.cfg
    │   ├── animation-core-desktop-1.5.12-c65799cdb55518dd8ec9156ecfe547d.jar
    │   ├── animation-desktop-1.5.12-dc4e76c5a73ca9b53362d097ff8157.jar
    │   ├── annotations-23.0.0-8484cd17d040d837983323f760b2c660.jar
    │   ├── resources
    │   │   ├── backend-0.0.1.jar
    │   │   ├── content_loader.gif
    │   │   └── libtdjni.dylib
    │   ├── runtime-desktop-1.5.12-1698bf91f4fdffbbb15d2b84e7e0f69e.jar

And here they are – our backend, the native TDLib library for it and a gif with a loader for loading content. When the application starts, it successfully accesses them and everything works as expected.

IN repositories project, I described detailed instructions on what needs to be prepared for the application and added an assembly script. We were able to test the native application on MacOS (x64 + M) and Windows (x64). So if anyone is interested, be sure to come 🙂

Results

We looked at how we can create and assemble a desktop application using the Compose Multiplatform framework. The result is a Telegram client with very basic functionality, but this can act as a skeleton for future improvements, and the practical implementation allows you to dive into studying the Telegram API in more detail. I had pleasant impressions from Compose Multiplatform and will try to continue exploring the capabilities of the framework. I hope you found it interesting!

Useful links:

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *