Browse Source

Keep message footer layout and click gesture states consistent

master
evgeny 4 days ago
parent
commit
fc79fa7345
  1. 100
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
  2. 42
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt
  3. 5
      tools/chatgui/src/messagedelegate.cpp

100
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt

@ -158,62 +158,62 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
horizontalArrangement = alignment
) {
MessageWithFooter(footer) {
Column(
modifier = Modifier
.widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp)
// Тень рисуем до clip, чтобы выступающие справа и снизу края оставались видны.
.drawBehind {
val offset = 2.dp.toPx()
val radius = 12.dp.toPx()
drawRoundRect(ChatColors.BubbleShadow, topLeft = Offset(offset, offset), size = size,
cornerRadius = CornerRadius(radius, radius))
}
.clip(RoundedCornerShape(12.dp))
.background(bgColor)
.padding(12.dp)
) {
if (!isVoice) Text(if (message.isOutgoing) stringResource(R.string.you) else message.author,
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary)
Spacer(Modifier.height(2.dp))
Column(
modifier = Modifier
.widthIn(min = minBubbleWidth, max = if (isVoice) voiceMaxWidth else 300.dp)
// Тень рисуем до clip, чтобы выступающие справа и снизу края оставались видны.
.drawBehind {
val offset = 2.dp.toPx()
val radius = 12.dp.toPx()
drawRoundRect(ChatColors.BubbleShadow, topLeft = Offset(offset, offset), size = size,
cornerRadius = CornerRadius(radius, radius))
}
.clip(RoundedCornerShape(12.dp))
.background(bgColor)
.padding(12.dp)
) {
if (!isVoice) Text(if (message.isOutgoing) stringResource(R.string.you) else message.author,
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary)
Spacer(Modifier.height(2.dp))
when {
message.inviteGroupName != null -> {
val clipboard = LocalClipboardManager.current
LaunchedEffect(message.id, message.inviteGroupName) {
LogManager.addLog("DEBUG", "PM", "invitation rendered: seq=${message.id} " +
"group_name_bytes=${message.inviteGroupName.toByteArray().size} link_bytes=${message.text.length}")
when {
message.inviteGroupName != null -> {
val clipboard = LocalClipboardManager.current
LaunchedEffect(message.id, message.inviteGroupName) {
LogManager.addLog("DEBUG", "PM", "invitation rendered: seq=${message.id} " +
"group_name_bytes=${message.inviteGroupName.toByteArray().size} link_bytes=${message.text.length}")
}
Text(stringResource(R.string.group_invitation, message.inviteGroupName), fontSize = 17.sp, color = textColor)
Text(message.text, fontSize = 17.sp, color = Color(0xFF3390EC), textDecoration = TextDecoration.Underline,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().combinedClickable(
onClick = { onLink(message.text) },
onLongClickLabel = stringResource(R.string.copy_link),
onLongClick = {
clipboard.setText(AnnotatedString(message.text))
LogManager.addLog("DEBUG", "PM", "invitation link copied: seq=${message.id} bytes=${message.text.length}")
}))
}
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice, onDownloadFile)
isVideo -> VideoBubble(message, textColor, onDownloadFile, onPlayVideo)
isImage -> ImageBubble(message, textColor, onDownloadFile, onOpenImage)
isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile)
else -> {
EmojiText(message.text, fontSize = 17.sp, color = textColor, onLink = onLink)
}
Text(stringResource(R.string.group_invitation, message.inviteGroupName), fontSize = 17.sp, color = textColor)
Text(message.text, fontSize = 17.sp, color = Color(0xFF3390EC), textDecoration = TextDecoration.Underline,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().combinedClickable(
onClick = { onLink(message.text) },
onLongClickLabel = stringResource(R.string.copy_link),
onLongClick = {
clipboard.setText(AnnotatedString(message.text))
LogManager.addLog("DEBUG", "PM", "invitation link copied: seq=${message.id} bytes=${message.text.length}")
}))
}
isVoice -> VoiceBubble(message, textColor, isActive, playbackState, onPlayVoice, onSeekVoice, onDownloadFile)
isVideo -> VideoBubble(message, textColor, onDownloadFile, onPlayVideo)
isImage -> ImageBubble(message, textColor, onDownloadFile, onOpenImage)
isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile)
else -> {
EmojiText(message.text, fontSize = 17.sp, color = textColor, onLink = onLink)
}
}
if (!isVoice) {
Spacer(Modifier.height(2.dp))
Text(
listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 11.sp,
color = ChatColors.Secondary,
modifier = Modifier.align(Alignment.End)
)
if (!isVoice) {
Spacer(Modifier.height(2.dp))
Text(
listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 11.sp,
color = ChatColors.Secondary,
modifier = Modifier.align(Alignment.End)
)
}
}
}
}
}
}
@Composable

42
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/PollUi.kt

@ -60,31 +60,31 @@ fun PollMessageBubble(message: Message, onVote: (Message, List<Int>, String) ->
val poll = message.feedback?.poll
Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = if (message.isOutgoing) Arrangement.End else Arrangement.Start) {
MessageWithFooter(footer) {
Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.widthIn(max = 300.dp),
colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (!message.isOutgoing) Text(message.author, style = MaterialTheme.typography.labelMedium)
if (poll == null) Text(stringResource(R.string.poll_pending))
else {
Text(poll.question, style = MaterialTheme.typography.titleMedium)
Text(stringResource(if (poll.multiple) R.string.poll_multiple else R.string.poll_single),
style = MaterialTheme.typography.labelSmall)
for (option in poll.options) {
Text(option.text)
LinearProgressIndicator(progress = { (option.percent / 100).toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth())
Text(stringResource(R.string.poll_option_result, option.count, option.percent),
style = MaterialTheme.typography.labelSmall)
MessageWithFooter(footer) {
Card(onClick = { if (poll != null) showVote = true }, modifier = Modifier.widthIn(max = 300.dp),
colors = CardDefaults.cardColors(containerColor = if (message.isOutgoing) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (!message.isOutgoing) Text(message.author, style = MaterialTheme.typography.labelMedium)
if (poll == null) Text(stringResource(R.string.poll_pending))
else {
Text(poll.question, style = MaterialTheme.typography.titleMedium)
Text(stringResource(if (poll.multiple) R.string.poll_multiple else R.string.poll_single),
style = MaterialTheme.typography.labelSmall)
for (option in poll.options) {
Text(option.text)
LinearProgressIndicator(progress = { (option.percent / 100).toFloat().coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth())
Text(stringResource(R.string.poll_option_result, option.count, option.percent),
style = MaterialTheme.typography.labelSmall)
}
Text(stringResource(R.string.poll_voters, poll.voters))
TextButton(onClick = { showVote = true }) { Text(stringResource(R.string.poll_vote_results)) }
}
}
Text(stringResource(R.string.poll_voters, poll.voters))
TextButton(onClick = { showVote = true }) { Text(stringResource(R.string.poll_vote_results)) }
}
}
}
}
}
if (showVote && poll != null) PollVoteDialog(message, poll, { showVote = false }, onVote, authorName)
}

5
tools/chatgui/src/messagedelegate.cpp

@ -137,6 +137,7 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
Layout L = calcLayout(option, index);
const auto* mouse = static_cast<QMouseEvent*>(event);
if (mouse->button() != Qt::LeftButton) return false;
const QPoint point = mouse->pos() - option.rect.topLeft();
int reaction = L.reactionHeart.contains(point) ? -1 : -2;
for (const auto& chip : L.reactionChips) if (chip.rect.contains(point)) { reaction = chip.id; break; }
@ -147,13 +148,13 @@ bool MessageDelegate::editorEvent(QEvent *event, QAbstractItemModel *model,
m_avatarPress=onAvatar ? index : QModelIndex(); m_avatarPressPosition=mouse->pos(); return onAvatar || onReaction;
}
const bool hadReactionPress = m_reactionPress.isValid();
const bool activateAvatar=onAvatar && m_avatarPress==index && (mouse->pos()-m_avatarPressPosition).manhattanLength()<5;
m_avatarPress=QPersistentModelIndex();
const bool activateReaction = onReaction && m_reactionPress == index && m_pressedReaction == reaction
&& (mouse->pos() - m_reactionPressPosition).manhattanLength() < 5;
m_reactionPress = QPersistentModelIndex(); m_pressedReaction = -2;
if (activateReaction) { emit reactionRequested(index, reaction); return true; }
if (hadReactionPress || onReaction || (L.reactionHeart.isValid() && point.y() >= L.reactionHeart.top())) return true;
const bool activateAvatar=onAvatar && m_avatarPress==index && (mouse->pos()-m_avatarPressPosition).manhattanLength()<5;
m_avatarPress=QPersistentModelIndex();
if (activateAvatar) {
emit avatarRequested(index.data(MsgChannelIdRole).toString(), index.data(MsgAuthorNodeIdRole).toULongLong());
return true;

Loading…
Cancel
Save