This commit is contained in:
2026-05-19 00:10:38 +02:00
parent db2290ba14
commit c68787cd01
93 changed files with 5855 additions and 0 deletions
@@ -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
@@ -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
)