Профиль, редактирование без аватара

This commit is contained in:
Халимов Рустам
2026-04-16 15:41:22 +03:00
parent 8409c51842
commit cea3f4d669
45 changed files with 1063 additions and 178 deletions
@@ -54,6 +54,9 @@ interface ChatApi {
@POST("messages/{messageId}/reactions")
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
@POST("chats/personal")
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto
@POST("chats/{chatId}/typing")
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
@@ -61,6 +64,10 @@ interface ChatApi {
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
}
data class CreatePersonalChatRequest(
val userId: String
)
data class KlipyResponse(
val data: KlipyDataWrapper
)
@@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -19,35 +20,74 @@ class SignalRNotificationObserver @Inject constructor(
private val signalrClient: ChatHubClient,
private val activeChatTracker: ActiveChatTracker,
private val tokenManager: TokenManager,
private val chatRepository: chats.domain.repository.ChatRepository,
@ApplicationContext private val context: Context
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var isStarted = false
private val processedMessageIds = mutableSetOf<String>()
fun refresh() {
scope.launch {
try {
val chats = chatRepository.getChats()
val total = chats.sumOf { it.unreadCount }
activeChatTracker.setTotalUnreadCount(total)
} catch (e: Exception) {
// Ignore load error
}
}
}
fun start() {
if (isStarted) return
isStarted = true
// Initial count load
refresh()
signalrClient.events
.filterIsInstance<ChatEvent.NewMessage>()
.onEach { event ->
val currentUserId = tokenManager.getUserId()
val message = event.message
// Don't show if it's our message
if (message.senderId == currentUserId) return@onEach
// Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
NotificationHelper.showNotification(
context = context,
title = message.sender?.displayName ?: "Новое сообщение",
body = message.content ?: "Вам прислали вложение",
type = "chat",
chatId = message.chatId,
notificationId = message.id.hashCode()
)
when (event) {
is ChatEvent.NewMessage -> {
val currentUserId = tokenManager.getUserId()
val message = event.message
// Don't show if it's our message or already processed
if (message.senderId == currentUserId) return@onEach
if (processedMessageIds.contains(message.id)) return@onEach
// Mark as processed
processedMessageIds.add(message.id)
if (processedMessageIds.size > 200) {
processedMessageIds.remove(processedMessageIds.first())
}
// Increment immediately for UI feedback
activeChatTracker.incrementUnreadCount()
// Refresh total count from source of truth in background
refresh()
// Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
NotificationHelper.showNotification(
context = context,
title = message.sender?.displayName ?: "Новое сообщение",
body = message.content ?: "Вам прислали вложение",
type = "chat",
chatId = message.chatId,
notificationId = message.id.hashCode(),
totalCount = activeChatTracker.totalUnreadCount.value
)
}
is ChatEvent.MessagesRead -> {
// If anyone read messages, sync our total count
refresh()
}
else -> Unit
}
}
.launchIn(scope)
}
@@ -131,4 +131,11 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
return api.getGifCategories().data.categories
}
override suspend fun createPersonalChat(userId: String): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
}
}
@@ -22,5 +22,6 @@ interface ChatRepository {
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
suspend fun createPersonalChat(userId: String): Chat
}
@@ -51,6 +51,7 @@ class ChatDetailViewModel @Inject constructor(
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager,
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
) : ViewModel() {
@@ -275,6 +276,7 @@ class ChatDetailViewModel @Inject constructor(
currentState.copy(messages = updatedMessages)
}
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
signalrNotificationObserver.refresh()
} catch (e: Exception) {
// Ignore
}
@@ -1,78 +1,181 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import core.utils.LinkMetadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private val linkMetadataCache = mutableMapOf<String, LinkMetadata>()
@Composable
fun LinkPreview(
metadata: LinkMetadata,
onClick: () -> Unit,
url: String,
modifier: Modifier = Modifier,
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
contentColor: Color = Color.White
) {
var metadata by remember(url) { mutableStateOf<LinkMetadata?>(linkMetadataCache[url]) }
var loading by remember(url) { mutableStateOf(metadata == null) }
val context = LocalContext.current
LaunchedEffect(url) {
if (metadata != null) {
loading = false
return@LaunchedEffect
}
loading = true
try {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder()
.url("https://api.microlink.io?url=${java.net.URLEncoder.encode(url, "UTF-8")}")
.header("User-Agent", "KnotMessenger/1.0 (Android)")
.build()
withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (body != null) {
val json = com.google.gson.JsonParser.parseString(body).asJsonObject
if (json.has("status") && json.get("status").asString == "success") {
val data = json.getAsJsonObject("data")
val title = data.get("title")?.takeIf { !it.isJsonNull }?.asString
val description = data.get("description")?.takeIf { !it.isJsonNull }?.asString
val imageUrl = data.getAsJsonObject("image")?.get("url")?.takeIf { !it.isJsonNull }?.asString
if (title != null || description != null || imageUrl != null) {
metadata = LinkMetadata(
url = url,
title = title,
description = description,
imageUrl = imageUrl
)
metadata?.let { linkMetadataCache[url] = it }
}
Unit
}
Unit
}
Unit
} else {
android.util.Log.e("LinkPreview", "API error: ${response.code} ${response.message}")
}
}
Unit
}
} catch (e: Exception) {
android.util.Log.e("LinkPreview", "Failed to fetch metadata for $url", e)
} finally {
loading = false
}
}
if (loading) {
Text(
text = "Загрузка предпросмотра...",
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.5f),
modifier = modifier.padding(vertical = 4.dp),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
return
}
val currentMetadata = metadata ?: LinkMetadata(url = url)
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(contentColor.copy(alpha = 0.05f))
.clickable(onClick = onClick)
.padding(8.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.2f))
.clickable {
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
context.startActivity(intent)
} catch (e: Exception) {}
}
.border(
width = if (isSystemInDarkTheme()) 0.5.dp else 0.dp,
color = Color.White.copy(alpha = 0.1f),
shape = RoundedCornerShape(8.dp)
)
) {
if (metadata.imageUrl != null) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(3.dp)
.background(Color(0xFF3096E5))
)
Column(modifier = Modifier.padding(10.dp)) {
val domain = remember(url) {
try { java.net.URL(url).host.replace("www.", "") } catch (e: Exception) { "" }
}
Text(
text = domain,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF3096E5),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 2.dp)
)
Text(
text = currentMetadata.title ?: url,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
currentMetadata.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp
)
}
}
}
currentMetadata.imageUrl?.let {
AsyncImage(
model = metadata.imageUrl,
model = it,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(8.dp)),
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
}
metadata.title?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
metadata.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.8f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp
)
}
Text(
text = metadata.url.removePrefix("https://").removePrefix("http://").split("/")[0],
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
}
}
@@ -255,11 +255,59 @@ fun MessageBubble(
// Text Content
if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = contentColor
)
Column {
val annotatedString = remember(message.content) {
val text = message.content ?: ""
val links = core.utils.LinkParser.findLinks(text)
androidx.compose.ui.text.buildAnnotatedString {
append(text)
links.forEach { link ->
val startIndex = text.indexOf(link)
if (startIndex >= 0) {
val endIndex = startIndex + link.length
addStyle(
style = androidx.compose.ui.text.SpanStyle(
color = Color(0xFF3096E5),
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
),
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = "URL",
annotation = link,
start = startIndex,
end = endIndex
)
}
}
}
}
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.foundation.text.ClickableText(
text = annotatedString,
style = MaterialTheme.typography.bodyMedium.copy(color = contentColor),
onClick = { offset ->
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(annotation.item))
context.startActivity(intent)
} catch (e: Exception) {}
}
}
)
val links = remember(message.content) { core.utils.LinkParser.findLinks(message.content) }
if (links.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
LinkPreview(
url = links[0],
contentColor = contentColor
)
}
}
}
// Time and Status