Пример того, как будет выглядеть чат с изображениями.
Пример того, как будет выглядеть чат с изображениями.

Всем привет! В своей прошлой статье я рассказывал как можно запустить Telegram клиент в качестве backend‑сервиса. В описываемой там библиотеке с тех пор были внесены некоторые оптимизации, и в целом, я остался доволен возможностями, которые получил. После чего возникло желание добавить визуальную часть к имеющемуся бэкенду и заодно изучить что‑то новое для себя. Выбор пал на фреймворк Compose Multiplatform. Давайте сделаем десктопную версию Telegram!

С чего начать?

Compose Multiplatform — это декларативный фреймворк для построения интерфейсов под различные платформы с использованием Kotlin и Jetpack Compose. И так как это был мой первый опыт работы с Jetpack Compose, то поэтому в начале я прочитал некоторые статьи и прошел несколько лабораторок на портале разработчиков для android. После можно посмотреть tutorials непосредственно в репозитории Compose Multiplatform от JetBrains.

Создаем проект

Создать проект можно из IntelliJ IDEA(достаточно версии community) выбрав шаблон Compose for Desktop или через wizard. В итоге формируется Gradle проект, где уже подключены необходимые плагины и зависимости для фреймворка, а если нам нужно что‑то дополнительно, то это можно добавить в виде зависимости. В нашем случае помимо UI‑части будет еще и бэкенд, то есть при запуске нативного приложения должен будет запуститься еще java‑процесс параллельно. Получается, что при компиляции нативного приложения Compose‑плагин должен знать какие сторонние ресурсы мы используем и учесть это, а у нас должна быть возможность обращаться к ним из нашего кода. Указать каталог с ресурсами можно через установку appResourcesRootDir, давайте назовем его resources и расположим в корне проекта:

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"))
        }
    }
}

Теперь мы можем с помощью свойства compose.application.resources.dir обращаться к подключаемым ресурсам, я для этого сделал вспомогательный класс:

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)

    }

}

Давайте посмотрим как выглядит итоговая структура каталога ресурсов, чтобы обратить внимание на еще одну важную вещь:

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

У Compose-плагина есть правило по работе с подкаталогами ресурсов. Каталог common содержит файлы, которые будут включены в сборку для любой платформы. Для включения файлов под конкретную ОС и архитектуру существуют правила именования - <resource_dir>/<os_name> или <resource_dir>/<os_name>-<arch_name>(возможные ОС - windows, macos, linux; архитектуры - x64, arm64). При сборке приложения плагин самостоятельно сможет взять необходимые файлы под нужную архитектуру. В нашем случае в каталог common отправится jar бэкенда, а в специфичных каталогах платформ будут лежать скомпилированные нативные библиотеки TDLib для бэкенда(особенности работы с данной библиотекой можно посмотреть в моей статье).

Начнем реализацию

При старте приложения мы покажем первое окно в котором будет отображаться статус запуска, а также запустим бэкенд параллельно.

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...")
        }
    }

}

На следующем шаге нам нужно проверить авторизован ли клиент, чтобы продолжить работу.

@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)
        }
    }
}

Если необходима авторизация, то появится простая форма ввода кода, который придет в официальный клиент.

Если активирован второй фактор верификации, то его тоже придется ввести.

После авторизации и загрузки списка чатов(кэширование иконок и прочей информации) можно отобразить основную сцену клиента. Я сделал максимально простой вариант — это список чатов с их фильтрацией слева, а также справа место под окно чата, который мы выбрали. Чтобы отображать список чатов корректно, недостаточно их просто загрузить — нужно выполнить подписку на обновления, которые в этом списке могут происходить, а именно:

  1. Может появиться новый чат

  2. Чат в списке может быть удален

  3. В чате может измениться иконка, заголовок

  4. Меняется последнее сообщение, которые мы тоже отображаем

  5. Должен появляться счетчик непрочитанных сообщений, он может менять значение

  6. Меняется позиция чата в списке, чаты с новыми сообщениями поднимаются в списке выше.

Чтобы разобраться, что это за обновления и как их получать давайте немного посмотрим в бэкенд. В данном случае это обычный Spring Boot проект. В нем я создал пакет tdlib, в котором зарегистрировал компоненты самых необходимых на данном этапе оповещений от TDLib, вот его состав:

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

Для примера, давайте посмотрим на событие обновления иконки чата:

@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;
    }
}

В данном случае это обычный Spring компонент, в котором к нам прилетает нотификация, что иконка обновилась. Дальше мы вольны делать с этим что угодно. Я просто кэширую объект и перекладываю обновление в очередь. Аналогично можно работать с любыми другими обновлениями в клиенте. В итоге я сделал сервис, который может собирать и передавать обновления для списка чатов.

@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);
    }

}

Вернемся к UI. Теперь мы можем отобразить список карточек чатов:

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)
            }

        }

    }

}

По данному списку возможен скролл, фильтрация, переключение на непрочитанные чаты, а также можно удалить чат или отметить прочитанным.

Окно со списком чатов для выбора
@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)
                                    }
                                }
                            }
                        }
                    }

                }
            }

        }

    }

}

По такой же логике можно работать и с окном чата. Когда мы его открываем, то подгружаем из истории какой‑то первоначальный список сообщений, а при скролле выполняем загрузку уже динамически. Плюс нам нужно следить за обновлениями в открытом чате:

  1. Изменение названия чата

  2. Если это группа или канал, то следим за изменением счетчика участников.

  3. Если получаем новое сообщение, то дописываем в список.

  4. В отображенных сообщениях может быть изменен контент, поэтому тоже следим.

  5. Если сообщение удаляется, то нам его тоже нужно удалить.

Выглядеть это будет так.

Конечно, это очень базовая функциональность. Пока корректно отображаются только текстовые сообщения и фото, а в Telegram много различных типов сообщений(видео, анимации, документы, опросы, стикеры, эмодзи и т. д.). Также пока не выделяются текстовые блоки и ссылки, но я хочу скоро это добавить вместе с возможностью отправки сообщений. Чаты‑форумы пока тоже не поддерживаются, там все сообщения отображаются в корневом чате(в TDLib эта функциональность не до конца перенесена еще, думаю подождать).

Соберем проект

Compose-плагин позволяет запускать приложение для jvm-платформы в виде java-процесса(run), собрать готовый к запуску jar для текущей платформы(packageUberJarForCurrentOS), скомпилировать нативное приложение(createDistributable, packageDistributionForCurrentOS), выполнить запуск нативного приложения из плагина(runDistributable). Вот полный список задач плагина:

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

Давайте теперь посмотрим куда же отправляются в итоге подключаемые ресурсы на примере нативного приложения под MacOS. Если мы попросим показать содержимое .app пакета, то увидим(вывод сократил, конечно):

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

А вот и они — наш бэкенд, нативная библиотека TDLib для него и gif c loader'ом загрузки контента. При запуске приложения оно к ним успешно обращается и все работает как ожидалось.

В репозитории проекта я описал подробную инструкцию, что нужно подготовить для приложения и добавил скрипт сборки. Нативное приложение получилось протестировать на MacOS(x64 + M) и Windows(x64). Поэтому если кому‑то интересно, то обязательно заходите:)

Итоги

Мы рассмотрели как можем создавать и собирать desktop приложение используя фреймворк Compose Multiplatform. Получился Telegram клиент с очень базовой функциональностью, но это может выступить скелетом для будущих доработок, а практическая реализация позволяет погрузиться в изучение Telegram API более детально. От Compose Multiplatform у меня остались приятные впечатления, постараюсь продолжить изучать возможности фреймворка. Надеюсь, что вам было интересно!

Полезные ссылки:

Комментарии (0)