wip
This commit is contained in:
@@ -0,0 +1,563 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import android.content.ClipDescription
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.draganddrop.dragAndDropTarget
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.exclude
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.paddingFrom
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||
import androidx.compose.ui.draganddrop.DragAndDropTarget
|
||||
import androidx.compose.ui.draganddrop.mimeTypes
|
||||
import androidx.compose.ui.draganddrop.toAndroidDragEvent
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.LastBaseline
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.components.JetchatAppBar
|
||||
import xyz.magicalbits.smsremote.data.exampleUiState
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Entry point for a conversation screen.
|
||||
*
|
||||
* @param uiState [ConversationUiState] that contains messages to display
|
||||
* @param navigateToProfile User action when navigation to a profile is requested
|
||||
* @param modifier [Modifier] to apply to this layout node
|
||||
* @param onNavIconPressed Sends an event up when the user clicks on the menu
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ConversationContent(
|
||||
uiState: ConversationUiState,
|
||||
navigateToProfile: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onNavIconPressed: () -> Unit = { },
|
||||
) {
|
||||
val authorMe = stringResource(R.string.author_me)
|
||||
val timeNow = stringResource(id = R.string.now)
|
||||
|
||||
val scrollState = rememberLazyListState()
|
||||
val topBarState = rememberTopAppBarState()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topBarState)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var background by remember {
|
||||
mutableStateOf(Color.Transparent)
|
||||
}
|
||||
|
||||
var borderStroke by remember {
|
||||
mutableStateOf(Color.Transparent)
|
||||
}
|
||||
|
||||
val dragAndDropCallback = remember {
|
||||
object : DragAndDropTarget {
|
||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||
val clipData = event.toAndroidDragEvent().clipData
|
||||
|
||||
if (clipData.itemCount < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
uiState.addMessage(
|
||||
Message(authorMe, clipData.getItemAt(0).text.toString(), timeNow),
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onStarted(event: DragAndDropEvent) {
|
||||
super.onStarted(event)
|
||||
borderStroke = Color.Red
|
||||
}
|
||||
|
||||
override fun onEntered(event: DragAndDropEvent) {
|
||||
super.onEntered(event)
|
||||
background = Color.Red.copy(alpha = .3f)
|
||||
}
|
||||
|
||||
override fun onExited(event: DragAndDropEvent) {
|
||||
super.onExited(event)
|
||||
background = Color.Transparent
|
||||
}
|
||||
|
||||
override fun onEnded(event: DragAndDropEvent) {
|
||||
super.onEnded(event)
|
||||
background = Color.Transparent
|
||||
borderStroke = Color.Transparent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
ChannelNameBar(
|
||||
channelName = uiState.channelName,
|
||||
channelMembers = uiState.channelMembers,
|
||||
onNavIconPressed = onNavIconPressed,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
// Exclude ime and navigation bar padding so this can be added by the UserInput composable
|
||||
contentWindowInsets = ScaffoldDefaults
|
||||
.contentWindowInsets
|
||||
.exclude(WindowInsets.navigationBars)
|
||||
.exclude(WindowInsets.ime),
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(paddingValues)
|
||||
.background(color = background)
|
||||
.border(width = 2.dp, color = borderStroke)
|
||||
.dragAndDropTarget(shouldStartDragAndDrop = { event ->
|
||||
event
|
||||
.mimeTypes()
|
||||
.contains(
|
||||
ClipDescription.MIMETYPE_TEXT_PLAIN,
|
||||
)
|
||||
}, target = dragAndDropCallback),
|
||||
) {
|
||||
Messages(
|
||||
messages = uiState.messages,
|
||||
navigateToProfile = navigateToProfile,
|
||||
modifier = Modifier.weight(1f),
|
||||
scrollState = scrollState,
|
||||
)
|
||||
UserInput(
|
||||
onMessageSent = { content ->
|
||||
uiState.addMessage(
|
||||
Message(authorMe, content, timeNow),
|
||||
)
|
||||
},
|
||||
resetScroll = {
|
||||
scope.launch {
|
||||
scrollState.scrollToItem(0)
|
||||
}
|
||||
},
|
||||
// let this element handle the padding so that the elevation is shown behind the
|
||||
// navigation bar
|
||||
modifier = Modifier.navigationBarsPadding().imePadding(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChannelNameBar(
|
||||
channelName: String,
|
||||
channelMembers: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollBehavior: TopAppBarScrollBehavior? = null,
|
||||
onNavIconPressed: () -> Unit = { },
|
||||
) {
|
||||
var functionalityNotAvailablePopupShown by remember { mutableStateOf(false) }
|
||||
if (functionalityNotAvailablePopupShown) {
|
||||
FunctionalityNotAvailablePopup { functionalityNotAvailablePopupShown = false }
|
||||
}
|
||||
JetchatAppBar(
|
||||
modifier = modifier,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onNavIconPressed = onNavIconPressed,
|
||||
title = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// Channel name
|
||||
Text(
|
||||
text = channelName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
// Number of members
|
||||
Text(
|
||||
text = stringResource(R.string.members, channelMembers),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Search icon
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_search),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { functionalityNotAvailablePopupShown = true })
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.height(24.dp),
|
||||
contentDescription = stringResource(id = R.string.search),
|
||||
)
|
||||
// Info icon
|
||||
Icon(
|
||||
painterResource(id = R.drawable.ic_info),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { functionalityNotAvailablePopupShown = true })
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
.height(24.dp),
|
||||
contentDescription = stringResource(id = R.string.info),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const val ConversationTestTag = "ConversationTestTag"
|
||||
|
||||
@Composable
|
||||
fun Messages(messages: List<Message>, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Box(modifier = modifier) {
|
||||
|
||||
val authorMe = stringResource(id = R.string.author_me)
|
||||
LazyColumn(
|
||||
reverseLayout = true,
|
||||
state = scrollState,
|
||||
modifier = Modifier
|
||||
.testTag(ConversationTestTag)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
for (index in messages.indices) {
|
||||
val prevAuthor = messages.getOrNull(index - 1)?.author
|
||||
val nextAuthor = messages.getOrNull(index + 1)?.author
|
||||
val content = messages[index]
|
||||
val isFirstMessageByAuthor = prevAuthor != content.author
|
||||
val isLastMessageByAuthor = nextAuthor != content.author
|
||||
|
||||
// Hardcode day dividers for simplicity
|
||||
if (index == messages.size - 1) {
|
||||
item {
|
||||
DayHeader("20 Aug")
|
||||
}
|
||||
} else if (index == 2) {
|
||||
item {
|
||||
DayHeader("Today")
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Message(
|
||||
onAuthorClick = { name -> navigateToProfile(name) },
|
||||
msg = content,
|
||||
isUserMe = content.author == authorMe,
|
||||
isFirstMessageByAuthor = isFirstMessageByAuthor,
|
||||
isLastMessageByAuthor = isLastMessageByAuthor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Jump to bottom button shows up when user scrolls past a threshold.
|
||||
// Convert to pixels:
|
||||
val jumpThreshold = with(LocalDensity.current) {
|
||||
JumpToBottomThreshold.toPx()
|
||||
}
|
||||
|
||||
// Show the button if the first visible item is not the first one or if the offset is
|
||||
// greater than the threshold.
|
||||
val jumpToBottomButtonEnabled by remember {
|
||||
derivedStateOf {
|
||||
scrollState.firstVisibleItemIndex != 0 ||
|
||||
scrollState.firstVisibleItemScrollOffset > jumpThreshold
|
||||
}
|
||||
}
|
||||
|
||||
JumpToBottom(
|
||||
// Only show if the scroller is not at the bottom
|
||||
enabled = jumpToBottomButtonEnabled,
|
||||
onClicked = {
|
||||
scope.launch {
|
||||
scrollState.animateScrollToItem(0)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Message(
|
||||
onAuthorClick: (String) -> Unit,
|
||||
msg: Message,
|
||||
isUserMe: Boolean,
|
||||
isFirstMessageByAuthor: Boolean,
|
||||
isLastMessageByAuthor: Boolean,
|
||||
) {
|
||||
val borderColor = if (isUserMe) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
|
||||
val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier
|
||||
Row(modifier = spaceBetweenAuthors) {
|
||||
if (isLastMessageByAuthor) {
|
||||
// Avatar
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { onAuthorClick(msg.author) })
|
||||
.padding(horizontal = 16.dp)
|
||||
.size(42.dp)
|
||||
.border(1.5.dp, borderColor, CircleShape)
|
||||
.border(3.dp, MaterialTheme.colorScheme.surface, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.align(Alignment.Top),
|
||||
painter = painterResource(id = msg.authorImage),
|
||||
contentScale = ContentScale.Crop,
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
// Space under avatar
|
||||
Spacer(modifier = Modifier.width(74.dp))
|
||||
}
|
||||
AuthorAndTextMessage(
|
||||
msg = msg,
|
||||
isUserMe = isUserMe,
|
||||
isFirstMessageByAuthor = isFirstMessageByAuthor,
|
||||
isLastMessageByAuthor = isLastMessageByAuthor,
|
||||
authorClicked = onAuthorClick,
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorAndTextMessage(
|
||||
msg: Message,
|
||||
isUserMe: Boolean,
|
||||
isFirstMessageByAuthor: Boolean,
|
||||
isLastMessageByAuthor: Boolean,
|
||||
authorClicked: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (isLastMessageByAuthor) {
|
||||
AuthorNameTimestamp(msg)
|
||||
}
|
||||
ChatItemBubble(msg, isUserMe, authorClicked = authorClicked)
|
||||
if (isFirstMessageByAuthor) {
|
||||
// Last bubble before next author
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
} else {
|
||||
// Between bubbles
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorNameTimestamp(msg: Message) {
|
||||
// Combine author and timestamp for a11y.
|
||||
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
|
||||
Text(
|
||||
text = msg.author,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.alignBy(LastBaseline)
|
||||
.paddingFrom(LastBaseline, after = 8.dp), // Space to 1st bubble
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = msg.timestamp,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.alignBy(LastBaseline),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp)
|
||||
|
||||
@Composable
|
||||
fun DayHeader(dayString: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.height(16.dp),
|
||||
) {
|
||||
DayHeaderLine()
|
||||
Text(
|
||||
text = dayString,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
DayHeaderLine()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.DayHeaderLine() {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
|
||||
|
||||
val backgroundBubbleColor = if (isUserMe) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
|
||||
Column {
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = ChatBubbleShape,
|
||||
) {
|
||||
ClickableMessage(
|
||||
message = message,
|
||||
isUserMe = isUserMe,
|
||||
authorClicked = authorClicked,
|
||||
)
|
||||
}
|
||||
|
||||
message.image?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Surface(
|
||||
color = backgroundBubbleColor,
|
||||
shape = ChatBubbleShape,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(it),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.size(160.dp),
|
||||
contentDescription = stringResource(id = R.string.attached_image),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
val styledMessage = messageFormatter(
|
||||
text = message.content,
|
||||
primary = isUserMe,
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = styledMessage,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
onClick = {
|
||||
styledMessage
|
||||
.getStringAnnotations(start = it, end = it)
|
||||
.firstOrNull()
|
||||
?.let { annotation ->
|
||||
when (annotation.tag) {
|
||||
SymbolAnnotationType.LINK.name -> uriHandler.openUri(annotation.item)
|
||||
SymbolAnnotationType.PERSON.name -> authorClicked(annotation.item)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ConversationPreview() {
|
||||
JetchatTheme {
|
||||
ConversationContent(
|
||||
uiState = exampleUiState,
|
||||
navigateToProfile = { },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ChannelBarPrev() {
|
||||
JetchatTheme {
|
||||
ChannelNameBar(channelName = "composers", channelMembers = 52)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DayHeaderPrev() {
|
||||
DayHeader("Aug 6")
|
||||
}
|
||||
|
||||
private val JumpToBottomThreshold = 56.dp
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.navigation.findNavController
|
||||
import xyz.magicalbits.smsremote.MainViewModel
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import xyz.magicalbits.smsremote.data.exampleUiState
|
||||
import xyz.magicalbits.smsremote.data.exampleUiState2
|
||||
import xyz.magicalbits.smsremote.theme.JetchatTheme
|
||||
|
||||
class ConversationFragment : Fragment() {
|
||||
private val activityViewModel: MainViewModel by activityViewModels()
|
||||
|
||||
var phoneNumber: String = ""
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
// Consider using safe args plugin
|
||||
val phoneNumber = arguments?.getString("phoneNumber")
|
||||
// viewModel.setDeviceId(deviceId)
|
||||
this.phoneNumber = phoneNumber!!
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View =
|
||||
ComposeView(inflater.context).apply {
|
||||
layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
|
||||
|
||||
val uiState =
|
||||
if (phoneNumber == "+420123456789") {
|
||||
exampleUiState
|
||||
} else {
|
||||
exampleUiState2
|
||||
}
|
||||
uiState.channelName = phoneNumber
|
||||
|
||||
setContent {
|
||||
JetchatTheme {
|
||||
ConversationContent(
|
||||
uiState = uiState,
|
||||
navigateToProfile = { user ->
|
||||
// Click callback
|
||||
val bundle = bundleOf("userId" to user)
|
||||
findNavController().navigate(
|
||||
R.id.nav_profile,
|
||||
bundle,
|
||||
)
|
||||
},
|
||||
onNavIconPressed = {
|
||||
activityViewModel.openDrawer()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import xyz.magicalbits.smsremote.R
|
||||
|
||||
class ConversationUiState(
|
||||
var channelName: String,
|
||||
val channelMembers: Int,
|
||||
initialMessages: List<Message>,
|
||||
) {
|
||||
private val _messages: MutableList<Message> = initialMessages.toMutableStateList()
|
||||
val messages: List<Message> = _messages
|
||||
|
||||
fun addMessage(msg: Message) {
|
||||
_messages.add(0, msg) // Add to the beginning of the list
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class Message(
|
||||
val author: String,
|
||||
val content: String,
|
||||
val timestamp: String,
|
||||
val image: Int? = null,
|
||||
val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else,
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import xyz.magicalbits.smsremote.R
|
||||
|
||||
private enum class Visibility {
|
||||
VISIBLE,
|
||||
GONE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a button that lets the user scroll to the bottom.
|
||||
*/
|
||||
@Composable
|
||||
fun JumpToBottom(enabled: Boolean, onClicked: () -> Unit, modifier: Modifier = Modifier) {
|
||||
// Show Jump to Bottom button
|
||||
val transition = updateTransition(
|
||||
if (enabled) Visibility.VISIBLE else Visibility.GONE,
|
||||
label = "JumpToBottom visibility animation",
|
||||
)
|
||||
val bottomOffset by transition.animateDp(label = "JumpToBottom offset animation") {
|
||||
if (it == Visibility.GONE) {
|
||||
(-32).dp
|
||||
} else {
|
||||
32.dp
|
||||
}
|
||||
}
|
||||
if (bottomOffset > 0.dp) {
|
||||
ExtendedFloatingActionButton(
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_arrow_downward),
|
||||
modifier = Modifier.height(18.dp),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(id = R.string.jumpBottom))
|
||||
},
|
||||
onClick = onClicked,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = modifier
|
||||
.offset(x = 0.dp, y = -bottomOffset)
|
||||
.height(36.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun JumpToBottomPreview() {
|
||||
JumpToBottom(enabled = true, onClicked = {})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
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.style.BaselineShift
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Regex containing the syntax tokens
|
||||
val symbolPattern by lazy {
|
||||
Regex("""(https?://[^\s\t\n]+)|(`[^`]+`)|(@\w+)|(\*[\w]+\*)|(_[\w]+_)|(~[\w]+~)""")
|
||||
}
|
||||
|
||||
// Accepted annotations for the ClickableTextWrapper
|
||||
enum class SymbolAnnotationType {
|
||||
PERSON,
|
||||
LINK,
|
||||
}
|
||||
typealias StringAnnotation = AnnotatedString.Range<String>
|
||||
|
||||
// Pair returning styled content and annotation for ClickableText when matching syntax token
|
||||
typealias SymbolAnnotation = Pair<AnnotatedString, StringAnnotation?>
|
||||
|
||||
/**
|
||||
* Format a message following Markdown-lite syntax
|
||||
* | @username -> bold, primary color and clickable element
|
||||
* | http(s)://... -> clickable link, opening it into the browser
|
||||
* | *bold* -> bold
|
||||
* | _italic_ -> italic
|
||||
* | ~strikethrough~ -> strikethrough
|
||||
* | `MyClass.myMethod` -> inline code styling
|
||||
*
|
||||
* @param text contains message to be parsed
|
||||
* @return AnnotatedString with annotations used inside the ClickableText wrapper
|
||||
*/
|
||||
@Composable
|
||||
fun messageFormatter(
|
||||
text: String,
|
||||
primary: Boolean,
|
||||
): AnnotatedString {
|
||||
val tokens = symbolPattern.findAll(text)
|
||||
|
||||
return buildAnnotatedString {
|
||||
var cursorPosition = 0
|
||||
|
||||
val codeSnippetBackground =
|
||||
if (primary) {
|
||||
MaterialTheme.colorScheme.secondary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
for (token in tokens) {
|
||||
append(text.slice(cursorPosition until token.range.first))
|
||||
|
||||
val (annotatedString, stringAnnotation) =
|
||||
getSymbolAnnotation(
|
||||
matchResult = token,
|
||||
colorScheme = MaterialTheme.colorScheme,
|
||||
primary = primary,
|
||||
codeSnippetBackground = codeSnippetBackground,
|
||||
)
|
||||
append(annotatedString)
|
||||
|
||||
if (stringAnnotation != null) {
|
||||
val (item, start, end, tag) = stringAnnotation
|
||||
addStringAnnotation(tag = tag, start = start, end = end, annotation = item)
|
||||
}
|
||||
|
||||
cursorPosition = token.range.last + 1
|
||||
}
|
||||
|
||||
if (!tokens.none()) {
|
||||
append(text.slice(cursorPosition..text.lastIndex))
|
||||
} else {
|
||||
append(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map regex matches found in a message with supported syntax symbols
|
||||
*
|
||||
* @param matchResult is a regex result matching our syntax symbols
|
||||
* @return pair of AnnotatedString with annotation (optional) used inside the ClickableText wrapper
|
||||
*/
|
||||
private fun getSymbolAnnotation(
|
||||
matchResult: MatchResult,
|
||||
colorScheme: ColorScheme,
|
||||
primary: Boolean,
|
||||
codeSnippetBackground: Color,
|
||||
): SymbolAnnotation =
|
||||
when (matchResult.value.first()) {
|
||||
'@' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value,
|
||||
spanStyle =
|
||||
SpanStyle(
|
||||
color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
),
|
||||
StringAnnotation(
|
||||
item = matchResult.value.substring(1),
|
||||
start = matchResult.range.first,
|
||||
end = matchResult.range.last,
|
||||
tag = SymbolAnnotationType.PERSON.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
'*' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value.trim('*'),
|
||||
spanStyle = SpanStyle(fontWeight = FontWeight.Bold),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
'_' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value.trim('_'),
|
||||
spanStyle = SpanStyle(fontStyle = FontStyle.Italic),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
'~' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value.trim('~'),
|
||||
spanStyle = SpanStyle(textDecoration = TextDecoration.LineThrough),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
'`' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value.trim('`'),
|
||||
spanStyle =
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
background = codeSnippetBackground,
|
||||
baselineShift = BaselineShift(0.2f),
|
||||
),
|
||||
),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
'h' -> {
|
||||
SymbolAnnotation(
|
||||
AnnotatedString(
|
||||
text = matchResult.value,
|
||||
spanStyle =
|
||||
SpanStyle(
|
||||
color = if (primary) colorScheme.inversePrimary else colorScheme.primary,
|
||||
),
|
||||
),
|
||||
StringAnnotation(
|
||||
item = matchResult.value,
|
||||
start = matchResult.range.first,
|
||||
end = matchResult.range.last,
|
||||
tag = SymbolAnnotationType.LINK.name,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
SymbolAnnotation(AnnotatedString(matchResult.value), null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
/*
|
||||
* Copyright 2026 MagicalBits
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package xyz.magicalbits.smsremote.conversation
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.MutableTransitionState
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.paddingFrom
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusTarget
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.FirstBaseline
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyKey
|
||||
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import xyz.magicalbits.smsremote.FunctionalityNotAvailablePopup
|
||||
import xyz.magicalbits.smsremote.R
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
enum class InputSelector {
|
||||
NONE,
|
||||
MAP,
|
||||
DM,
|
||||
EMOJI,
|
||||
PHONE,
|
||||
PICTURE,
|
||||
}
|
||||
|
||||
enum class EmojiStickerSelector {
|
||||
EMOJI,
|
||||
STICKER,
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun UserInputPreview() {
|
||||
UserInput(onMessageSent = {})
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun UserInput(onMessageSent: (String) -> Unit, modifier: Modifier = Modifier, resetScroll: () -> Unit = {}) {
|
||||
var currentInputSelector by rememberSaveable { mutableStateOf(InputSelector.NONE) }
|
||||
val dismissKeyboard = { currentInputSelector = InputSelector.NONE }
|
||||
|
||||
// Intercept back navigation if there's a InputSelector visible
|
||||
if (currentInputSelector != InputSelector.NONE) {
|
||||
BackHandler(onBack = dismissKeyboard)
|
||||
}
|
||||
|
||||
var textState by rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue())
|
||||
}
|
||||
|
||||
// Used to decide if the keyboard should be shown
|
||||
var textFieldFocusState by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(tonalElevation = 2.dp, contentColor = MaterialTheme.colorScheme.secondary) {
|
||||
Column(modifier = modifier) {
|
||||
UserInputText(
|
||||
textFieldValue = textState,
|
||||
onTextChanged = { textState = it },
|
||||
// Only show the keyboard if there's no input selector and text field has focus
|
||||
keyboardShown = currentInputSelector == InputSelector.NONE && textFieldFocusState,
|
||||
// Close extended selector if text field receives focus
|
||||
onTextFieldFocused = { focused ->
|
||||
if (focused) {
|
||||
currentInputSelector = InputSelector.NONE
|
||||
resetScroll()
|
||||
}
|
||||
textFieldFocusState = focused
|
||||
},
|
||||
onMessageSent = {
|
||||
onMessageSent(textState.text)
|
||||
// Reset text field and close keyboard
|
||||
textState = TextFieldValue()
|
||||
// Move scroll to bottom
|
||||
resetScroll()
|
||||
},
|
||||
focusState = textFieldFocusState,
|
||||
)
|
||||
UserInputSelector(
|
||||
onSelectorChange = { currentInputSelector = it },
|
||||
sendMessageEnabled = textState.text.isNotBlank(),
|
||||
onMessageSent = {
|
||||
onMessageSent(textState.text)
|
||||
// Reset text field and close keyboard
|
||||
textState = TextFieldValue()
|
||||
// Move scroll to bottom
|
||||
resetScroll()
|
||||
dismissKeyboard()
|
||||
},
|
||||
currentInputSelector = currentInputSelector,
|
||||
)
|
||||
SelectorExpanded(
|
||||
onCloseRequested = dismissKeyboard,
|
||||
onTextAdded = { textState = textState.addText(it) },
|
||||
currentSelector = currentInputSelector,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TextFieldValue.addText(newString: String): TextFieldValue {
|
||||
val newText = this.text.replaceRange(
|
||||
this.selection.start,
|
||||
this.selection.end,
|
||||
newString,
|
||||
)
|
||||
val newSelection = TextRange(
|
||||
start = newText.length,
|
||||
end = newText.length,
|
||||
)
|
||||
|
||||
return this.copy(text = newText, selection = newSelection)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectorExpanded(currentSelector: InputSelector, onCloseRequested: () -> Unit, onTextAdded: (String) -> Unit) {
|
||||
if (currentSelector == InputSelector.NONE) return
|
||||
|
||||
// Request focus to force the TextField to lose it
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
// If the selector is shown, always request focus to trigger a TextField.onFocusChange.
|
||||
SideEffect {
|
||||
if (currentSelector == InputSelector.EMOJI) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Surface(tonalElevation = 8.dp) {
|
||||
when (currentSelector) {
|
||||
InputSelector.EMOJI -> EmojiSelector(onTextAdded, focusRequester)
|
||||
InputSelector.DM -> NotAvailablePopup(onCloseRequested)
|
||||
InputSelector.PICTURE -> FunctionalityNotAvailablePanel()
|
||||
InputSelector.MAP -> FunctionalityNotAvailablePanel()
|
||||
InputSelector.PHONE -> FunctionalityNotAvailablePanel()
|
||||
InputSelector.NONE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FunctionalityNotAvailablePanel() {
|
||||
AnimatedVisibility(
|
||||
visibleState = remember { MutableTransitionState(false).apply { targetState = true } },
|
||||
enter = expandHorizontally() + fadeIn(),
|
||||
exit = shrinkHorizontally() + fadeOut(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.height(320.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.not_available),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(id = R.string.not_available_subtitle),
|
||||
modifier = Modifier.paddingFrom(FirstBaseline, before = 32.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserInputSelector(
|
||||
onSelectorChange: (InputSelector) -> Unit,
|
||||
sendMessageEnabled: Boolean,
|
||||
onMessageSent: () -> Unit,
|
||||
currentInputSelector: InputSelector,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.height(72.dp)
|
||||
.wrapContentHeight()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.EMOJI) },
|
||||
icon = painterResource(id = R.drawable.ic_mood),
|
||||
selected = currentInputSelector == InputSelector.EMOJI,
|
||||
description = stringResource(id = R.string.emoji_selector_bt_desc),
|
||||
)
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.DM) },
|
||||
icon = painterResource(id = R.drawable.ic_alternate_email),
|
||||
selected = currentInputSelector == InputSelector.DM,
|
||||
description = stringResource(id = R.string.dm_desc),
|
||||
)
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.PICTURE) },
|
||||
icon = painterResource(id = R.drawable.ic_insert_photo),
|
||||
selected = currentInputSelector == InputSelector.PICTURE,
|
||||
description = stringResource(id = R.string.attach_photo_desc),
|
||||
)
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.MAP) },
|
||||
icon = painterResource(id = R.drawable.ic_place),
|
||||
selected = currentInputSelector == InputSelector.MAP,
|
||||
description = stringResource(id = R.string.map_selector_desc),
|
||||
)
|
||||
InputSelectorButton(
|
||||
onClick = { onSelectorChange(InputSelector.PHONE) },
|
||||
icon = painterResource(id = R.drawable.ic_duo),
|
||||
selected = currentInputSelector == InputSelector.PHONE,
|
||||
description = stringResource(id = R.string.videochat_desc),
|
||||
)
|
||||
|
||||
val border = if (!sendMessageEnabled) {
|
||||
BorderStroke(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
val disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
|
||||
|
||||
val buttonColors = ButtonDefaults.buttonColors(
|
||||
disabledContainerColor = Color.Transparent,
|
||||
disabledContentColor = disabledContentColor,
|
||||
)
|
||||
|
||||
// Send button
|
||||
Button(
|
||||
modifier = Modifier.height(36.dp),
|
||||
enabled = sendMessageEnabled,
|
||||
onClick = onMessageSent,
|
||||
colors = buttonColors,
|
||||
border = border,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.send),
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InputSelectorButton(
|
||||
onClick: () -> Unit,
|
||||
icon: androidx.compose.ui.graphics.painter.Painter,
|
||||
description: String,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundModifier = if (selected) {
|
||||
Modifier.background(
|
||||
color = LocalContentColor.current,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.then(backgroundModifier),
|
||||
) {
|
||||
val tint = if (selected) {
|
||||
contentColorFor(backgroundColor = LocalContentColor.current)
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
}
|
||||
Icon(
|
||||
icon,
|
||||
tint = tint,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(56.dp),
|
||||
contentDescription = description,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotAvailablePopup(onDismissed: () -> Unit) {
|
||||
FunctionalityNotAvailablePopup(onDismissed)
|
||||
}
|
||||
|
||||
val KeyboardShownKey = SemanticsPropertyKey<Boolean>("KeyboardShownKey")
|
||||
var SemanticsPropertyReceiver.keyboardShownProperty by KeyboardShownKey
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
private fun UserInputText(
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
onTextChanged: (TextFieldValue) -> Unit,
|
||||
textFieldValue: TextFieldValue,
|
||||
keyboardShown: Boolean,
|
||||
onTextFieldFocused: (Boolean) -> Unit,
|
||||
onMessageSent: (String) -> Unit,
|
||||
focusState: Boolean,
|
||||
) {
|
||||
val swipeOffset = remember { mutableStateOf(0f) }
|
||||
var isRecordingMessage by remember { mutableStateOf(false) }
|
||||
val a11ylabel = stringResource(id = R.string.textfield_desc)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = isRecordingMessage,
|
||||
label = "text-field",
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
) { recording ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
if (recording) {
|
||||
RecordingIndicator { swipeOffset.value }
|
||||
} else {
|
||||
UserInputTextField(
|
||||
textFieldValue,
|
||||
onTextChanged,
|
||||
onTextFieldFocused,
|
||||
keyboardType,
|
||||
focusState,
|
||||
onMessageSent,
|
||||
Modifier.fillMaxWidth().semantics {
|
||||
contentDescription = a11ylabel
|
||||
keyboardShownProperty = keyboardShown
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.UserInputTextField(
|
||||
textFieldValue: TextFieldValue,
|
||||
onTextChanged: (TextFieldValue) -> Unit,
|
||||
onTextFieldFocused: (Boolean) -> Unit,
|
||||
keyboardType: KeyboardType,
|
||||
focusState: Boolean,
|
||||
onMessageSent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var lastFocusState by remember { mutableStateOf(false) }
|
||||
BasicTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = { onTextChanged(it) },
|
||||
modifier = modifier
|
||||
.padding(start = 32.dp)
|
||||
.align(Alignment.CenterStart)
|
||||
.onFocusChanged { state ->
|
||||
if (lastFocusState != state.isFocused) {
|
||||
onTextFieldFocused(state.isFocused)
|
||||
}
|
||||
lastFocusState = state.isFocused
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
imeAction = ImeAction.Send,
|
||||
),
|
||||
keyboardActions = KeyboardActions {
|
||||
if (textFieldValue.text.isNotBlank()) onMessageSent(textFieldValue.text)
|
||||
},
|
||||
maxLines = 1,
|
||||
cursorBrush = SolidColor(LocalContentColor.current),
|
||||
textStyle = LocalTextStyle.current.copy(color = LocalContentColor.current),
|
||||
)
|
||||
|
||||
val disableContentColor =
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
if (textFieldValue.text.isEmpty() && !focusState) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 32.dp),
|
||||
text = stringResource(R.string.textfield_hint),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(color = disableContentColor),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordingIndicator(swipeOffset: () -> Float) {
|
||||
var duration by remember { mutableStateOf(Duration.ZERO) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
duration += 1.seconds
|
||||
}
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
|
||||
val animatedPulse = infiniteTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = 0.2f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
tween(2000),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "pulse",
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.size(56.dp)
|
||||
.padding(24.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = animatedPulse.value
|
||||
scaleY = animatedPulse.value
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(Color.Red),
|
||||
)
|
||||
Text(
|
||||
duration.toComponents { minutes, seconds, _ ->
|
||||
val min = minutes.toString().padStart(2, '0')
|
||||
val sec = seconds.toString().padStart(2, '0')
|
||||
"$min:$sec"
|
||||
},
|
||||
Modifier.alignByBaseline(),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.alignByBaseline()
|
||||
.clipToBounds(),
|
||||
) {
|
||||
val swipeThreshold = with(LocalDensity.current) { 200.dp.toPx() }
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.graphicsLayer {
|
||||
translationX = swipeOffset() / 2
|
||||
alpha = 1 - (swipeOffset().absoluteValue / swipeThreshold)
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
text = stringResource(R.string.swipe_to_cancel_recording),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiSelector(onTextAdded: (String) -> Unit, focusRequester: FocusRequester) {
|
||||
var selected by remember { mutableStateOf(EmojiStickerSelector.EMOJI) }
|
||||
|
||||
val a11yLabel = stringResource(id = R.string.emoji_selector_desc)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.focusRequester(focusRequester) // Requests focus when the Emoji selector is displayed
|
||||
// Make the emoji selector focusable so it can steal focus from TextField
|
||||
.focusTarget()
|
||||
.semantics { contentDescription = a11yLabel },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
) {
|
||||
ExtendedSelectorInnerButton(
|
||||
text = stringResource(id = R.string.emojis_label),
|
||||
onClick = { selected = EmojiStickerSelector.EMOJI },
|
||||
selected = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ExtendedSelectorInnerButton(
|
||||
text = stringResource(id = R.string.stickers_label),
|
||||
onClick = { selected = EmojiStickerSelector.STICKER },
|
||||
selected = false,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
EmojiTable(onTextAdded, modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
if (selected == EmojiStickerSelector.STICKER) {
|
||||
NotAvailablePopup(onDismissed = { selected = EmojiStickerSelector.EMOJI })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExtendedSelectorInnerButton(text: String, onClick: () -> Unit, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
else Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.74f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.padding(8.dp)
|
||||
.height(36.dp),
|
||||
colors = colors,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiTable(onTextAdded: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
repeat(4) { x ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
repeat(EMOJI_COLUMNS) { y ->
|
||||
val emoji = emojis[x * EMOJI_COLUMNS + y]
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = { onTextAdded(emoji) })
|
||||
.sizeIn(minWidth = 42.dp, minHeight = 42.dp)
|
||||
.padding(8.dp),
|
||||
text = emoji,
|
||||
style = LocalTextStyle.current.copy(
|
||||
fontSize = 18.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val EMOJI_COLUMNS = 10
|
||||
|
||||
private val emojis = listOf(
|
||||
"\ud83d\ude00", // Grinning Face
|
||||
"\ud83d\ude01", // Grinning Face With Smiling Eyes
|
||||
"\ud83d\ude02", // Face With Tears of Joy
|
||||
"\ud83d\ude03", // Smiling Face With Open Mouth
|
||||
"\ud83d\ude04", // Smiling Face With Open Mouth and Smiling Eyes
|
||||
"\ud83d\ude05", // Smiling Face With Open Mouth and Cold Sweat
|
||||
"\ud83d\ude06", // Smiling Face With Open Mouth and Tightly-Closed Eyes
|
||||
"\ud83d\ude09", // Winking Face
|
||||
"\ud83d\ude0a", // Smiling Face With Smiling Eyes
|
||||
"\ud83d\ude0b", // Face Savouring Delicious Food
|
||||
"\ud83d\ude0e", // Smiling Face With Sunglasses
|
||||
"\ud83d\ude0d", // Smiling Face With Heart-Shaped Eyes
|
||||
"\ud83d\ude18", // Face Throwing a Kiss
|
||||
"\ud83d\ude17", // Kissing Face
|
||||
"\ud83d\ude19", // Kissing Face With Smiling Eyes
|
||||
"\ud83d\ude1a", // Kissing Face With Closed Eyes
|
||||
"\u263a", // White Smiling Face
|
||||
"\ud83d\ude42", // Slightly Smiling Face
|
||||
"\ud83e\udd17", // Hugging Face
|
||||
"\ud83d\ude07", // Smiling Face With Halo
|
||||
"\ud83e\udd13", // Nerd Face
|
||||
"\ud83e\udd14", // Thinking Face
|
||||
"\ud83d\ude10", // Neutral Face
|
||||
"\ud83d\ude11", // Expressionless Face
|
||||
"\ud83d\ude36", // Face Without Mouth
|
||||
"\ud83d\ude44", // Face With Rolling Eyes
|
||||
"\ud83d\ude0f", // Smirking Face
|
||||
"\ud83d\ude23", // Persevering Face
|
||||
"\ud83d\ude25", // Disappointed but Relieved Face
|
||||
"\ud83d\ude2e", // Face With Open Mouth
|
||||
"\ud83e\udd10", // Zipper-Mouth Face
|
||||
"\ud83d\ude2f", // Hushed Face
|
||||
"\ud83d\ude2a", // Sleepy Face
|
||||
"\ud83d\ude2b", // Tired Face
|
||||
"\ud83d\ude34", // Sleeping Face
|
||||
"\ud83d\ude0c", // Relieved Face
|
||||
"\ud83d\ude1b", // Face With Stuck-Out Tongue
|
||||
"\ud83d\ude1c", // Face With Stuck-Out Tongue and Winking Eye
|
||||
"\ud83d\ude1d", // Face With Stuck-Out Tongue and Tightly-Closed Eyes
|
||||
"\ud83d\ude12", // Unamused Face
|
||||
"\ud83d\ude13", // Face With Cold Sweat
|
||||
"\ud83d\ude14", // Pensive Face
|
||||
"\ud83d\ude15", // Confused Face
|
||||
"\ud83d\ude43", // Upside-Down Face
|
||||
"\ud83e\udd11", // Money-Mouth Face
|
||||
"\ud83d\ude32", // Astonished Face
|
||||
"\ud83d\ude37", // Face With Medical Mask
|
||||
"\ud83e\udd12", // Face With Thermometer
|
||||
"\ud83e\udd15", // Face With Head-Bandage
|
||||
"\u2639", // White Frowning Face
|
||||
"\ud83d\ude41", // Slightly Frowning Face
|
||||
"\ud83d\ude16", // Confounded Face
|
||||
"\ud83d\ude1e", // Disappointed Face
|
||||
"\ud83d\ude1f", // Worried Face
|
||||
"\ud83d\ude24", // Face With Look of Triumph
|
||||
"\ud83d\ude22", // Crying Face
|
||||
"\ud83d\ude2d", // Loudly Crying Face
|
||||
"\ud83d\ude26", // Frowning Face With Open Mouth
|
||||
"\ud83d\ude27", // Anguished Face
|
||||
"\ud83d\ude28", // Fearful Face
|
||||
"\ud83d\ude29", // Weary Face
|
||||
"\ud83d\ude2c", // Grimacing Face
|
||||
"\ud83d\ude30", // Face With Open Mouth and Cold Sweat
|
||||
"\ud83d\ude31", // Face Screaming in Fear
|
||||
"\ud83d\ude33", // Flushed Face
|
||||
"\ud83d\ude35", // Dizzy Face
|
||||
"\ud83d\ude21", // Pouting Face
|
||||
"\ud83d\ude20", // Angry Face
|
||||
"\ud83d\ude08", // Smiling Face With Horns
|
||||
"\ud83d\udc7f", // Imp
|
||||
"\ud83d\udc79", // Japanese Ogre
|
||||
"\ud83d\udc7a", // Japanese Goblin
|
||||
"\ud83d\udc80", // Skull
|
||||
"\ud83d\udc7b", // Ghost
|
||||
"\ud83d\udc7d", // Extraterrestrial Alien
|
||||
"\ud83e\udd16", // Robot Face
|
||||
"\ud83d\udca9", // Pile of Poo
|
||||
"\ud83d\ude3a", // Smiling Cat Face With Open Mouth
|
||||
"\ud83d\ude38", // Grinning Cat Face With Smiling Eyes
|
||||
"\ud83d\ude39", // Cat Face With Tears of Joy
|
||||
"\ud83d\ude3b", // Smiling Cat Face With Heart-Shaped Eyes
|
||||
"\ud83d\ude3c", // Cat Face With Wry Smile
|
||||
"\ud83d\ude3d", // Kissing Cat Face With Closed Eyes
|
||||
"\ud83d\ude40", // Weary Cat Face
|
||||
"\ud83d\ude3f", // Crying Cat Face
|
||||
"\ud83d\ude3e", // Pouting Cat Face
|
||||
"\ud83d\udc66", // Boy
|
||||
"\ud83d\udc67", // Girl
|
||||
"\ud83d\udc68", // Man
|
||||
"\ud83d\udc69", // Woman
|
||||
"\ud83d\udc74", // Older Man
|
||||
"\ud83d\udc75", // Older Woman
|
||||
"\ud83d\udc76", // Baby
|
||||
"\ud83d\udc71", // Person With Blond Hair
|
||||
"\ud83d\udc6e", // Police Officer
|
||||
"\ud83d\udc72", // Man With Gua Pi Mao
|
||||
"\ud83d\udc73", // Man With Turban
|
||||
"\ud83d\udc77", // Construction Worker
|
||||
"\u26d1", // Helmet With White Cross
|
||||
"\ud83d\udc78", // Princess
|
||||
"\ud83d\udc82", // Guardsman
|
||||
"\ud83d\udd75", // Sleuth or Spy
|
||||
"\ud83c\udf85", // Father Christmas
|
||||
"\ud83d\udc70", // Bride With Veil
|
||||
"\ud83d\udc7c", // Baby Angel
|
||||
"\ud83d\udc86", // Face Massage
|
||||
"\ud83d\udc87", // Haircut
|
||||
"\ud83d\ude4d", // Person Frowning
|
||||
"\ud83d\ude4e", // Person With Pouting Face
|
||||
"\ud83d\ude45", // Face With No Good Gesture
|
||||
"\ud83d\ude46", // Face With OK Gesture
|
||||
"\ud83d\udc81", // Information Desk Person
|
||||
"\ud83d\ude4b", // Happy Person Raising One Hand
|
||||
"\ud83d\ude47", // Person Bowing Deeply
|
||||
"\ud83d\ude4c", // Person Raising Both Hands in Celebration
|
||||
"\ud83d\ude4f", // Person With Folded Hands
|
||||
"\ud83d\udde3", // Speaking Head in Silhouette
|
||||
"\ud83d\udc64", // Bust in Silhouette
|
||||
"\ud83d\udc65", // Busts in Silhouette
|
||||
"\ud83d\udeb6", // Pedestrian
|
||||
"\ud83c\udfc3", // Runner
|
||||
"\ud83d\udc6f", // Woman With Bunny Ears
|
||||
"\ud83d\udc83", // Dancer
|
||||
"\ud83d\udd74", // Man in Business Suit Levitating
|
||||
"\ud83d\udc6b", // Man and Woman Holding Hands
|
||||
"\ud83d\udc6c", // Two Men Holding Hands
|
||||
"\ud83d\udc6d", // Two Women Holding Hands
|
||||
"\ud83d\udc8f", // Kiss
|
||||
)
|
||||
Reference in New Issue
Block a user