Compare commits

..

2 Commits

Author SHA1 Message Date
Lonami Exo 49fa3c4d5a Render formatted text in the ChatScreen 2022-10-29 13:35:05 +02:00
Lonami Exo d350d1a048 Load messages from API 2022-10-29 13:06:38 +02:00
8 changed files with 295 additions and 32 deletions

View File

@ -21,6 +21,7 @@ import androidx.navigation.compose.rememberNavController
import dev.lonami.talaria.ui.screens.ChatScreen import dev.lonami.talaria.ui.screens.ChatScreen
import dev.lonami.talaria.ui.screens.DialogScreen import dev.lonami.talaria.ui.screens.DialogScreen
import dev.lonami.talaria.ui.screens.LoginScreen import dev.lonami.talaria.ui.screens.LoginScreen
import dev.lonami.talaria.ui.state.ChatViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uniffi.talaria.needLogin import uniffi.talaria.needLogin
@ -163,6 +164,8 @@ fun TalariaApp(modifier: Modifier = Modifier) {
} }
} }
val chatViewModel = remember { ChatViewModel() }
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@ -193,11 +196,12 @@ fun TalariaApp(modifier: Modifier = Modifier) {
composable(route = TalariaScreen.Dialog.name) { composable(route = TalariaScreen.Dialog.name) {
DialogScreen(onDialogSelected = { DialogScreen(onDialogSelected = {
selectedDialog = it selectedDialog = it
chatViewModel.loadMessages(it)
navController.navigate(TalariaScreen.Chat.name) navController.navigate(TalariaScreen.Chat.name)
}) })
} }
composable(route = TalariaScreen.Chat.name) { composable(route = TalariaScreen.Chat.name) {
ChatScreen(selectedDialog) ChatScreen(selectedDialog, chatViewModel = chatViewModel)
} }
composable(route = TalariaScreen.Login.name) { composable(route = TalariaScreen.Login.name) {
LoginScreen(onConfirmOtp = { LoginScreen(onConfirmOtp = {

View File

@ -1,11 +1,46 @@
package dev.lonami.talaria.data package dev.lonami.talaria.data
import dev.lonami.talaria.models.Message import uniffi.talaria.Message
import uniffi.talaria.getMessages
import java.time.Instant
object MessageRepository { interface MessageRepository {
fun loadMessages(): List<Message> { fun loadMessages(chat: String): List<Message>
return generateSequence { fun sendMessage(chat: String, message: String): Message
Message("Alice", "Testing") }
}.take(50).toList()
class NativeMessageRepository : MessageRepository {
override fun loadMessages(chat: String): List<Message> {
return getMessages(chat)
}
override fun sendMessage(chat: String, message: String): Message {
return uniffi.talaria.sendMessage(chat, message)
}
}
class MockMessageRepository(private var msgCounter: Int = 50) : MessageRepository {
override fun loadMessages(chat: String): List<Message> {
return (0 until 50).map {
Message(
id = it,
sender = "Alice",
text = "Testing",
date = Instant.now(),
editDate = null,
formatting = listOf(),
)
}.toList()
}
override fun sendMessage(chat: String, message: String): Message {
return Message(
id = msgCounter++,
sender = "You",
text = message,
date = Instant.now(),
editDate = null,
formatting = listOf(),
)
} }
} }

View File

@ -1,3 +0,0 @@
package dev.lonami.talaria.models
data class Message(val sender: String, val text: String)

View File

@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button import androidx.compose.material.Button
@ -12,17 +13,79 @@ import androidx.compose.material.Text
import androidx.compose.material.TextField import androidx.compose.material.TextField
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import dev.lonami.talaria.R import dev.lonami.talaria.R
import dev.lonami.talaria.models.Message import dev.lonami.talaria.data.MockMessageRepository
import dev.lonami.talaria.ui.state.ChatViewModel import dev.lonami.talaria.ui.state.ChatViewModel
import dev.lonami.talaria.ui.theme.TalariaTheme import dev.lonami.talaria.ui.theme.TalariaTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uniffi.talaria.Formatting
import uniffi.talaria.Message
import uniffi.talaria.TextFormat
@Composable
fun FormattedText(
text: String,
formatting: List<TextFormat>,
onFormatClicked: (TextFormat) -> Unit,
) {
val anno = AnnotatedString(text, spanStyles = formatting.map {
AnnotatedString.Range(SpanStyle(
fontWeight = when (it.format) {
Formatting.BOLD -> FontWeight.Bold
else -> FontWeight.Normal
},
fontStyle = when (it.format) {
Formatting.ITALIC -> FontStyle.Italic
else -> FontStyle.Normal
},
fontFamily = when (it.format) {
Formatting.CODE,
Formatting.PRE,
-> FontFamily.Monospace
else -> FontFamily.Default
},
textDecoration = when (it.format) {
Formatting.UNDERLINE -> TextDecoration.Underline
Formatting.STRIKE -> TextDecoration.LineThrough
else -> TextDecoration.None
},
color = when (it.format) {
Formatting.MENTION,
Formatting.HASH_TAG,
Formatting.BOT_COMMAND,
Formatting.URL,
Formatting.EMAIL,
Formatting.TEXT_URL,
Formatting.MENTION_NAME,
Formatting.PHONE,
Formatting.CASH_TAG,
-> Color(0xff0000ff)
else -> Color.Black
}), it.offset, it.offset + it.length)
})
ClickableText(
anno,
onClick = { offset ->
anno.spanStyles.indexOfFirst {
it.start <= offset && offset <= it.end
}.takeIf { it != -1 }?.also {
onFormatClicked(formatting[it])
}
}
)
}
@Composable @Composable
fun MessageCard(message: Message, modifier: Modifier = Modifier) { fun MessageCard(message: Message, modifier: Modifier = Modifier) {
@ -38,7 +101,7 @@ fun MessageCard(message: Message, modifier: Modifier = Modifier) {
.padding(8.dp) .padding(8.dp)
) { ) {
Text(message.sender, fontWeight = FontWeight.Bold) Text(message.sender, fontWeight = FontWeight.Bold)
Text(message.text) FormattedText(message.text, message.formatting, onFormatClicked = {})
} }
} }
} }
@ -110,7 +173,9 @@ fun ChatScreen(
@Preview @Preview
@Composable @Composable
fun ChatPreview() { fun ChatPreview() {
val viewModel = remember { ChatViewModel(MockMessageRepository()).apply { loadMessages("") } }
TalariaTheme { TalariaTheme {
ChatScreen("") ChatScreen("", chatViewModel = viewModel)
} }
} }

View File

@ -1,5 +1,5 @@
package dev.lonami.talaria.ui.state package dev.lonami.talaria.ui.state
import dev.lonami.talaria.models.Message import uniffi.talaria.Message
data class ChatUiState(val messages: MutableList<Message> = mutableListOf()) data class ChatUiState(val messages: MutableList<Message> = mutableListOf())

View File

@ -2,29 +2,30 @@ package dev.lonami.talaria.ui.state
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dev.lonami.talaria.data.MessageRepository import dev.lonami.talaria.data.MessageRepository
import dev.lonami.talaria.models.Message import dev.lonami.talaria.data.NativeMessageRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
class ChatViewModel : ViewModel() { class ChatViewModel(private val repository: MessageRepository = NativeMessageRepository()) :
ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState()) private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private fun loadMessages() { fun loadMessages(chat: String) {
_uiState.value = ChatUiState(MessageRepository.loadMessages().toMutableList()) _uiState.update {
} it.messages.clear()
it.messages.addAll(repository.loadMessages(chat))
fun sendMessage(dialog: String, message: String) { it
sendMessage(dialog, message)
_uiState.update { state ->
state.messages.add(Message("You", message))
state
} }
} }
init { fun sendMessage(dialog: String, message: String) {
loadMessages() val sent = repository.sendMessage(dialog, message)
_uiState.update {
it.messages.add(sent)
it
}
} }
} }

View File

@ -3,7 +3,7 @@
mod db; mod db;
use grammers_client::types::LoginToken; use grammers_client::types::{LoginToken, Message as OgMessage};
use grammers_client::{Client, Config, InitParams}; use grammers_client::{Client, Config, InitParams};
use grammers_session::{PackedChat, Session, UpdateState}; use grammers_session::{PackedChat, Session, UpdateState};
use grammers_tl_types as tl; use grammers_tl_types as tl;
@ -75,6 +75,48 @@ pub enum MessageAck {
Sent, Sent,
} }
#[derive(Debug, Clone, Copy)]
pub enum Formatting {
Unknown,
Mention,
HashTag,
BotCommand,
Url,
Email,
Bold,
Italic,
Code,
Pre,
TextUrl,
MentionName,
Phone,
CashTag,
Underline,
Strike,
Blockquote,
BankCard,
Spoiler,
CustomEmoji,
}
#[derive(Debug, Clone)]
pub struct TextFormat {
format: Formatting,
offset: i32,
length: i32,
extra: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Message {
id: i32,
sender: String,
text: String,
date: SystemTime,
edit_date: Option<SystemTime>,
formatting: Vec<TextFormat>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MessagePreview { pub struct MessagePreview {
sender: String, sender: String,
@ -323,9 +365,87 @@ pub fn get_dialogs() -> Result<Vec<Dialog>> {
}) })
} }
pub fn send_message(packed: String, text: String) -> Result<()> { fn adapt_message(m: OgMessage) -> Message {
Message {
id: m.id(),
sender: if let Some(sender) = m.sender() {
sender.name().to_string()
} else {
"unknown".to_string()
},
text: m.text().to_string(),
date: m.date().into(),
edit_date: m.edit_date().map(|d| d.into()),
formatting: if let Some(entities) = m.fmt_entities() {
use tl::enums::MessageEntity as ME;
macro_rules! tf {
($formatting:ident($entity:ident)) => {
tf!($formatting($entity).extra(None))
};
($formatting:ident($entity:ident).extra($extra:expr)) => {
TextFormat {
format: Formatting::$formatting,
offset: $entity.offset,
length: $entity.length,
extra: $extra,
}
};
}
entities
.into_iter()
.map(|e| match e {
ME::Unknown(e) => tf!(Unknown(e)),
ME::Mention(e) => tf!(Mention(e)),
ME::Hashtag(e) => tf!(HashTag(e)),
ME::BotCommand(e) => tf!(BotCommand(e)),
ME::Url(e) => tf!(Url(e)),
ME::Email(e) => tf!(Email(e)),
ME::Bold(e) => tf!(Bold(e)),
ME::Italic(e) => tf!(Italic(e)),
ME::Code(e) => tf!(Code(e)),
ME::Pre(e) => tf!(Pre(e).extra(Some(e.language.to_string()))),
ME::TextUrl(e) => tf!(TextUrl(e).extra(Some(e.url.to_string()))),
ME::MentionName(e) => tf!(MentionName(e).extra(Some(e.user_id.to_string()))),
ME::InputMessageEntityMentionName(e) => tf!(Unknown(e)),
ME::Phone(e) => tf!(Phone(e)),
ME::Cashtag(e) => tf!(CashTag(e)),
ME::Underline(e) => tf!(Underline(e)),
ME::Strike(e) => tf!(Strike(e)),
ME::Blockquote(e) => tf!(Blockquote(e)),
ME::BankCard(e) => tf!(BankCard(e)),
ME::Spoiler(e) => tf!(Spoiler(e)),
ME::CustomEmoji(e) => {
tf!(CustomEmoji(e).extra(Some(e.document_id.to_string())))
}
})
.collect()
} else {
Vec::new()
},
}
}
pub fn get_messages(packed: String) -> Result<Vec<Message>> {
let chat = PackedChat::from_hex(&packed).unwrap(); let chat = PackedChat::from_hex(&packed).unwrap();
let client = CLIENT.get().ok_or(NativeError::Initialization)?; let client = CLIENT.get().ok_or(NativeError::Initialization)?;
block_on(client.send_message(chat, text)).map_err(|_| NativeError::Network)?;
Ok(()) block_on(async {
let mut result = Vec::new();
let mut messages = client.iter_messages(chat);
while let Some(message) = messages.next().await.map_err(|_| NativeError::Network)? {
result.push(message);
}
Ok(result)
})
.map(|messages| messages.into_iter().map(adapt_message).collect())
}
pub fn send_message(packed: String, text: String) -> Result<Message> {
let chat = PackedChat::from_hex(&packed).unwrap();
let client = CLIENT.get().ok_or(NativeError::Initialization)?;
block_on(client.send_message(chat, text))
.map(adapt_message)
.map_err(|_| NativeError::Network)
} }

View File

@ -11,6 +11,45 @@ enum MessageAck {
"Sent", "Sent",
}; };
enum Formatting {
"Unknown",
"Mention",
"HashTag",
"BotCommand",
"Url",
"Email",
"Bold",
"Italic",
"Code",
"Pre", // language:string
"TextUrl", // url:string
"MentionName", // user_id:long
"Phone",
"CashTag",
"Underline",
"Strike",
"Blockquote",
"BankCard",
"Spoiler",
"CustomEmoji", // document_id:long
};
dictionary TextFormat {
Formatting format;
i32 offset;
i32 length;
string? extra;
};
dictionary Message {
i32 id;
string sender;
string text;
timestamp date;
timestamp? edit_date;
sequence<TextFormat> formatting;
};
dictionary MessagePreview { dictionary MessagePreview {
string sender; string sender;
string text; string text;
@ -43,5 +82,7 @@ namespace talaria {
[Throws=NativeError] [Throws=NativeError]
sequence<Dialog> get_dialogs(); sequence<Dialog> get_dialogs();
[Throws=NativeError] [Throws=NativeError]
void send_message(string packed, string text); sequence<Message> get_messages(string packed);
[Throws=NativeError]
Message send_message(string packed, string text);
}; };