diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2e4d517c..8471869b1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,9 +101,9 @@ android { dependencies { implementation("androidx.appcompat:appcompat:1.6.1") - implementation("com.github.alorma:compose-settings-ui-m3:1.0.2") + implementation("com.github.alorma:compose-settings-ui-m3:1.0.3") - implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11") + implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.12") // Markdown support implementation("io.noties.markwon:core:4.6.2") @@ -152,16 +152,13 @@ dependencies { testImplementation("pl.pragmatists:JUnitParams:1.1.1") androidTestImplementation("androidx.room:room-testing:2.6.1") - // optional - Paging 3 Integration - implementation("androidx.room:room-paging:2.6.1") - implementation("io.arrow-kt:arrow-core:1.2.1") // Unfortunately, ui tooling, and the markdown thing, still brings in the other material2 dependencies implementation("androidx.compose.material3:material3:1.1.2") implementation("androidx.compose.material3:material3-window-size-class:1.1.2") implementation("org.ocpsoft.prettytime:prettytime:5.0.7.Final") - implementation("androidx.navigation:navigation-compose:2.7.5") + implementation("androidx.navigation:navigation-compose:2.7.6") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") testImplementation("androidx.arch.core:core-testing:2.2.0") @@ -172,23 +169,23 @@ dependencies { debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.4") implementation("androidx.compose.material:material-icons-extended:1.5.4") - implementation("androidx.activity:activity-compose:1.8.1") + implementation("androidx.activity:activity-compose:1.8.2") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - testImplementation("org.mockito:mockito-core:5.7.0") - testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") + testImplementation("org.mockito:mockito-core:5.8.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.2.1") implementation("androidx.browser:browser:1.7.0") implementation("androidx.profileinstaller:profileinstaller:1.3.1") baselineProfile(project(":benchmarks")) - implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.6") + implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7") - implementation("it.vercruysse.lemmyapi:lemmy-api:0.2.0-SNAPSHOT") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.1") + implementation("it.vercruysse.lemmyapi:lemmy-api:0.2.2-SNAPSHOT") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2") // Ktor uses SLF4J implementation("org.slf4j:slf4j-api:2.0.9") implementation("uk.uuid.slf4j:slf4j-android:2.0.9-0") diff --git a/app/src/main/java/com/jerboa/model/CommunityViewModel.kt b/app/src/main/java/com/jerboa/model/CommunityViewModel.kt index d9975e733..11c962045 100644 --- a/app/src/main/java/com/jerboa/model/CommunityViewModel.kt +++ b/app/src/main/java/com/jerboa/model/CommunityViewModel.kt @@ -2,7 +2,6 @@ package com.jerboa.model import android.content.Context import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel @@ -10,75 +9,29 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.CreationExtras import arrow.core.Either -import com.jerboa.JerboaAppState import com.jerboa.api.API import com.jerboa.api.ApiState import com.jerboa.api.toApiState -import com.jerboa.findAndUpdatePost -import com.jerboa.mergePosts import com.jerboa.showBlockCommunityToast -import it.vercruysse.lemmyapi.dto.SortType import it.vercruysse.lemmyapi.v0x19.datatypes.BlockCommunity import it.vercruysse.lemmyapi.v0x19.datatypes.BlockCommunityResponse import it.vercruysse.lemmyapi.v0x19.datatypes.CommunityId import it.vercruysse.lemmyapi.v0x19.datatypes.CommunityResponse -import it.vercruysse.lemmyapi.v0x19.datatypes.CreatePostLike -import it.vercruysse.lemmyapi.v0x19.datatypes.DeletePost import it.vercruysse.lemmyapi.v0x19.datatypes.FollowCommunity import it.vercruysse.lemmyapi.v0x19.datatypes.GetCommunity import it.vercruysse.lemmyapi.v0x19.datatypes.GetCommunityResponse import it.vercruysse.lemmyapi.v0x19.datatypes.GetPosts -import it.vercruysse.lemmyapi.v0x19.datatypes.GetPostsResponse -import it.vercruysse.lemmyapi.v0x19.datatypes.MarkPostAsRead -import it.vercruysse.lemmyapi.v0x19.datatypes.PaginationCursor -import it.vercruysse.lemmyapi.v0x19.datatypes.PostResponse -import it.vercruysse.lemmyapi.v0x19.datatypes.PostView -import it.vercruysse.lemmyapi.v0x19.datatypes.SavePost import kotlinx.coroutines.launch -class CommunityViewModel(communityArg: Either) : ViewModel() { +class CommunityViewModel(communityArg: Either) : PostsViewModel() { var communityRes: ApiState by mutableStateOf(ApiState.Empty) private set - private var followCommunityRes: ApiState by - mutableStateOf(ApiState.Empty) - - var postsRes: ApiState by mutableStateOf(ApiState.Empty) - private set - - private var likePostRes: ApiState by mutableStateOf(ApiState.Empty) - private var savePostRes: ApiState by mutableStateOf(ApiState.Empty) - private var deletePostRes: ApiState by mutableStateOf(ApiState.Empty) - private var blockCommunityRes: ApiState by - mutableStateOf(ApiState.Empty) - private var markPostRes: ApiState by mutableStateOf(ApiState.Empty) - - var sortType by mutableStateOf(SortType.Active) - private set - - private var page by mutableIntStateOf(1) - private var pageCursor: PaginationCursor? by mutableStateOf(null) - + private var followCommunityRes: ApiState by mutableStateOf(ApiState.Empty) + private var blockCommunityRes: ApiState by mutableStateOf(ApiState.Empty) private var communityId: Int? by mutableStateOf(null) private var communityName: String? by mutableStateOf(null) - fun updateSortType(sortType: SortType) { - this.sortType = sortType - } - - private fun resetPage() { - page = 1 - pageCursor = null - } - - private fun nextPage() { - page += 1 - } - - private fun prevPage() { - page -= 1 - } - private fun getCommunity(form: GetCommunity) { viewModelScope.launch { communityRes = ApiState.Loading @@ -86,70 +39,6 @@ class CommunityViewModel(communityArg: Either) : ViewModel( } } - private fun getPosts( - form: GetPosts, - state: ApiState = ApiState.Loading, - ) { - viewModelScope.launch { - postsRes = state - postsRes = API.getInstance().getPosts(form).toApiState() - } - } - - fun appendPosts() { - viewModelScope.launch { - val oldRes = postsRes - postsRes = - when (oldRes) { - is ApiState.Appending -> return@launch - is ApiState.Holder -> ApiState.Appending(oldRes.data) - else -> return@launch - } - - // Update the page cursor before fetching again - pageCursor = oldRes.data.next_page - nextPage() - - val newRes = API.getInstance().getPosts(getForm()).toApiState() - - postsRes = - when (newRes) { - is ApiState.Success -> { - ApiState.Success( - GetPostsResponse( - posts = - mergePosts( - oldRes.data.posts, - newRes.data.posts, - ), - next_page = newRes.data.next_page, - ), - ) - } - - else -> { - prevPage() - ApiState.AppendingFailure(oldRes.data) - } - } - } - } - - fun resetPosts() { - resetPage() - getPosts( - getForm(), - ) - } - - fun refreshPosts() { - resetPage() - getPosts( - getForm(), - ApiState.Refreshing, - ) - } - fun followCommunity( form: FollowCommunity, onSuccess: () -> Unit = {}, @@ -178,49 +67,6 @@ class CommunityViewModel(communityArg: Either) : ViewModel( } } - fun likePost(form: CreatePostLike) { - viewModelScope.launch { - likePostRes = ApiState.Loading - likePostRes = API.getInstance().createPostLike(form).toApiState() - - when (val likeRes = likePostRes) { - is ApiState.Success -> { - updatePost(likeRes.data.post_view) - } - - else -> {} - } - } - } - - fun savePost(form: SavePost) { - viewModelScope.launch { - savePostRes = ApiState.Loading - savePostRes = API.getInstance().savePost(form).toApiState() - when (val saveRes = savePostRes) { - is ApiState.Success -> { - updatePost(saveRes.data.post_view) - } - - else -> {} - } - } - } - - fun deletePost(form: DeletePost) { - viewModelScope.launch { - deletePostRes = ApiState.Loading - deletePostRes = API.getInstance().deletePost(form).toApiState() - when (val deletePost = deletePostRes) { - is ApiState.Success -> { - updatePost(deletePost.data.post_view) - } - - else -> {} - } - } - } - fun blockCommunity( form: BlockCommunity, ctx: Context, @@ -254,37 +100,6 @@ class CommunityViewModel(communityArg: Either) : ViewModel( } } - fun updatePost(postView: PostView) { - when (val existing = postsRes) { - is ApiState.Success -> { - val newPosts = findAndUpdatePost(existing.data.posts, postView) - val newRes = ApiState.Success(existing.data.copy(posts = newPosts)) - postsRes = newRes - } - - else -> {} - } - } - - fun markPostAsRead( - form: MarkPostAsRead, - post: PostView, - appState: JerboaAppState, - ) { - appState.coroutineScope.launch { - markPostRes = ApiState.Loading - markPostRes = API.getInstance().markPostAsRead(form).toApiState() - - when (markPostRes) { - is ApiState.Success -> { - updatePost(post.copy(read = form.read)) - } - - else -> {} - } - } - } - init { communityId = communityArg.fold({ it }, { null }) communityName = communityArg.fold({ null }, { it }) @@ -303,7 +118,7 @@ class CommunityViewModel(communityArg: Either) : ViewModel( ) } - private fun getForm(): GetPosts { + override fun getForm(): GetPosts { return GetPosts( community_id = communityId, community_name = communityName, diff --git a/app/src/main/java/com/jerboa/model/HomeViewModel.kt b/app/src/main/java/com/jerboa/model/HomeViewModel.kt index 47ce2da7c..56c41246c 100644 --- a/app/src/main/java/com/jerboa/model/HomeViewModel.kt +++ b/app/src/main/java/com/jerboa/model/HomeViewModel.kt @@ -3,57 +3,28 @@ package com.jerboa.model import android.util.Log import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import com.jerboa.JerboaAppState -import com.jerboa.api.API import com.jerboa.api.ApiState -import com.jerboa.api.toApiState import com.jerboa.db.entity.AnonAccount import com.jerboa.db.repository.AccountRepository -import com.jerboa.findAndUpdatePost import com.jerboa.jerboaApplication -import com.jerboa.mergePosts import com.jerboa.toEnumSafe -import it.vercruysse.lemmyapi.dto.ListingType -import it.vercruysse.lemmyapi.dto.SortType -import it.vercruysse.lemmyapi.v0x19.datatypes.CreatePostLike -import it.vercruysse.lemmyapi.v0x19.datatypes.DeletePost -import it.vercruysse.lemmyapi.v0x19.datatypes.GetPosts -import it.vercruysse.lemmyapi.v0x19.datatypes.GetPostsResponse -import it.vercruysse.lemmyapi.v0x19.datatypes.MarkPostAsRead -import it.vercruysse.lemmyapi.v0x19.datatypes.PaginationCursor import it.vercruysse.lemmyapi.v0x19.datatypes.PostResponse -import it.vercruysse.lemmyapi.v0x19.datatypes.PostView -import it.vercruysse.lemmyapi.v0x19.datatypes.SavePost import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -class HomeViewModel(private val accountRepository: AccountRepository) : ViewModel() { - var postsRes: ApiState by mutableStateOf(ApiState.Empty) - private set - +class HomeViewModel(private val accountRepository: AccountRepository) : PostsViewModel() { private var likePostRes: ApiState by mutableStateOf(ApiState.Empty) private var savePostRes: ApiState by mutableStateOf(ApiState.Empty) private var deletePostRes: ApiState by mutableStateOf(ApiState.Empty) val lazyListState = LazyListState() - var sortType by mutableStateOf(SortType.Active) - private set - var listingType by mutableStateOf(ListingType.Local) - private set - - private var page by mutableIntStateOf(1) - - private var pageCursor: PaginationCursor? by mutableStateOf(null) - init { viewModelScope.launch { accountRepository.currentAccount @@ -68,176 +39,6 @@ class HomeViewModel(private val accountRepository: AccountRepository) : ViewMode } } - fun updateSortType(sortType: SortType) { - this.sortType = sortType - } - - fun updateListingType(listingType: ListingType) { - this.listingType = listingType - } - - private fun nextPage() { - page += 1 - } - - private fun prevPage() { - page -= 1 - } - - private fun resetPage() { - page = 1 - pageCursor = null - } - - private fun getPosts( - form: GetPosts, - state: ApiState = ApiState.Loading, - ) { - viewModelScope.launch { - postsRes = state - postsRes = API.getInstance().getPosts(form).toApiState() - } - } - - fun appendPosts() { - viewModelScope.launch { - val oldRes = postsRes - postsRes = - when (oldRes) { - is ApiState.Appending -> return@launch - is ApiState.Holder -> ApiState.Appending(oldRes.data) - else -> return@launch - } - - // Update the page cursor before fetching again - pageCursor = oldRes.data.next_page - nextPage() - val newRes = API.getInstance().getPosts(getForm()).toApiState() - - postsRes = - when (newRes) { - is ApiState.Success -> { - val res = - GetPostsResponse( - posts = - mergePosts( - oldRes.data.posts, - newRes.data.posts, - ), - next_page = newRes.data.next_page, - ) - ApiState.Success( - res, - ) - } - - else -> { - prevPage() - ApiState.AppendingFailure(oldRes.data) - } - } - } - } - - fun likePost(form: CreatePostLike) { - viewModelScope.launch { - likePostRes = ApiState.Loading - likePostRes = API.getInstance().createPostLike(form).toApiState() - - when (val likeRes = likePostRes) { - is ApiState.Success -> { - updatePost(likeRes.data.post_view) - } - - else -> {} - } - } - } - - fun savePost(form: SavePost) { - viewModelScope.launch { - savePostRes = ApiState.Loading - savePostRes = API.getInstance().savePost(form).toApiState() - when (val saveRes = savePostRes) { - is ApiState.Success -> { - updatePost(saveRes.data.post_view) - } - - else -> {} - } - } - } - - fun deletePost(form: DeletePost) { - viewModelScope.launch { - deletePostRes = ApiState.Loading - deletePostRes = API.getInstance().deletePost(form).toApiState() - when (val deletePost = deletePostRes) { - is ApiState.Success -> { - updatePost(deletePost.data.post_view) - } - - else -> {} - } - } - } - - fun updatePost(postView: PostView) { - when (val existing = postsRes) { - is ApiState.Success -> { - val newPosts = findAndUpdatePost(existing.data.posts, postView) - val newRes = ApiState.Success(existing.data.copy(posts = newPosts)) - postsRes = newRes - } - - else -> {} - } - } - - fun resetPosts() { - resetPage() - getPosts( - GetPosts( - page_cursor = pageCursor, - sort = sortType, - type_ = listingType, - ), - ) - } - - fun refreshPosts() { - resetPage() - getPosts( - getForm(), - ApiState.Refreshing, - ) - } - - private fun getForm(): GetPosts { - return GetPosts( - page = page, - page_cursor = pageCursor, - sort = sortType, - type_ = listingType, - ) - } - - fun markPostAsRead( - form: MarkPostAsRead, - post: PostView, - appState: JerboaAppState, - ) { - appState.coroutineScope.launch { - when (API.getInstance().markPostAsRead(form).toApiState()) { - is ApiState.Success -> { - updatePost(post.copy(read = form.read)) - } - - else -> {} - } - } - } - companion object { val Factory = viewModelFactory { diff --git a/app/src/main/java/com/jerboa/model/PostsViewModel.kt b/app/src/main/java/com/jerboa/model/PostsViewModel.kt new file mode 100644 index 000000000..bd8034ad1 --- /dev/null +++ b/app/src/main/java/com/jerboa/model/PostsViewModel.kt @@ -0,0 +1,180 @@ +package com.jerboa.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.jerboa.JerboaAppState +import com.jerboa.api.API +import com.jerboa.api.ApiState +import com.jerboa.api.toApiState +import com.jerboa.findAndUpdatePost +import com.jerboa.mergePosts +import it.vercruysse.lemmyapi.dto.ListingType +import it.vercruysse.lemmyapi.dto.SortType +import it.vercruysse.lemmyapi.v0x19.datatypes.CreatePostLike +import it.vercruysse.lemmyapi.v0x19.datatypes.DeletePost +import it.vercruysse.lemmyapi.v0x19.datatypes.GetPosts +import it.vercruysse.lemmyapi.v0x19.datatypes.GetPostsResponse +import it.vercruysse.lemmyapi.v0x19.datatypes.MarkPostAsRead +import it.vercruysse.lemmyapi.v0x19.datatypes.PaginationCursor +import it.vercruysse.lemmyapi.v0x19.datatypes.PostView +import it.vercruysse.lemmyapi.v0x19.datatypes.SavePost +import kotlinx.coroutines.launch + +open class PostsViewModel : ViewModel() { + var postsRes: ApiState by mutableStateOf(ApiState.Empty) + private set + private var page by mutableIntStateOf(1) + protected var pageCursor: PaginationCursor? by mutableStateOf(null) + private set + var sortType by mutableStateOf(SortType.Active) + private set + var listingType by mutableStateOf(ListingType.Local) + private set + + protected fun nextPage() { + page += 1 + } + + protected fun prevPage() { + page -= 1 + } + + protected fun resetPage() { + page = 1 + pageCursor = null + } + + protected fun getPosts( + form: GetPosts, + state: ApiState = ApiState.Loading, + ) { + viewModelScope.launch { + postsRes = state + postsRes = API.getInstance().getPosts(form).toApiState() + } + } + + fun appendPosts() { + viewModelScope.launch { + val oldRes = postsRes + postsRes = + when (oldRes) { + is ApiState.Appending -> return@launch + is ApiState.Holder -> ApiState.Appending(oldRes.data) + else -> return@launch + } + + // Update the page cursor before fetching again + pageCursor = oldRes.data.next_page + nextPage() + val newRes = API.getInstance().getPosts(getForm()).toApiState() + + postsRes = + when (newRes) { + is ApiState.Success -> { + val res = + GetPostsResponse( + posts = + mergePosts( + oldRes.data.posts, + newRes.data.posts, + ), + next_page = newRes.data.next_page, + ) + ApiState.Success( + res, + ) + } + + else -> { + prevPage() + ApiState.AppendingFailure(oldRes.data) + } + } + } + } + + fun updatePost(postView: PostView) { + when (val existing = postsRes) { + is ApiState.Success -> { + val newPosts = findAndUpdatePost(existing.data.posts, postView) + val newRes = ApiState.Success(existing.data.copy(posts = newPosts)) + postsRes = newRes + } + + else -> {} + } + } + + fun resetPosts() { + resetPage() + getPosts( + getForm(), + ) + } + + fun refreshPosts() { + resetPage() + getPosts( + getForm(), + ApiState.Refreshing, + ) + } + + protected open fun getForm(): GetPosts { + return GetPosts( + page = page, + page_cursor = pageCursor, + sort = sortType, + type_ = listingType, + ) + } + + fun markPostAsRead( + form: MarkPostAsRead, + post: PostView, + appState: JerboaAppState, + ) { + appState.coroutineScope.launch { + API.getInstance().markPostAsRead(form).onSuccess { + updatePost(post.copy(read = form.read)) + } + } + } + + fun updateSortType(sortType: SortType) { + this.sortType = sortType + } + + fun updateListingType(listingType: ListingType) { + this.listingType = listingType + } + + fun likePost(form: CreatePostLike) { + viewModelScope.launch { + API.getInstance().createPostLike(form).onSuccess { + updatePost(it.post_view) + } + } + } + + fun savePost(form: SavePost) { + viewModelScope.launch { + API.getInstance().savePost(form).onSuccess { + updatePost(it.post_view) + } + } + } + + fun deletePost(form: DeletePost) { + viewModelScope.launch { + API.getInstance().deletePost(form).onSuccess { + updatePost(it.post_view) + } + } + } +} diff --git a/app/src/main/java/com/jerboa/util/CustomHttpLoggingInterceptor.kt b/app/src/main/java/com/jerboa/util/CustomHttpLoggingInterceptor.kt index 48f2e0983..96ad3a51f 100644 --- a/app/src/main/java/com/jerboa/util/CustomHttpLoggingInterceptor.kt +++ b/app/src/main/java/com/jerboa/util/CustomHttpLoggingInterceptor.kt @@ -1,10 +1,11 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package com.jerboa.util import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.Response -import okhttp3.internal.charset import okhttp3.internal.http.promisesBody import okhttp3.internal.platform.Platform import okhttp3.logging.HttpLoggingInterceptor @@ -16,6 +17,7 @@ import org.json.JSONObject import java.io.IOException import java.nio.charset.Charset import java.util.concurrent.TimeUnit +import okhttp3.internal.charsetOrUtf8 as charsetOrUtf8 /** * Based of [HttpLoggingInterceptor], redacts the giving fields @@ -103,7 +105,7 @@ class CustomHttpLoggingInterceptor } else if (gzippedLength != null) { logger.log("--> END ${request.method} (${buffer.size}-byte, $gzippedLength-gzipped-byte body)") } else { - val charset: Charset = requestBody.contentType().charset() + val charset: Charset = requestBody.contentType().charsetOrUtf8() logger.log(redactBody(buffer.readString(charset))) logger.log("--> END ${request.method} (${requestBody.contentLength()}-byte body)") } @@ -154,7 +156,7 @@ class CustomHttpLoggingInterceptor } } - val charset: Charset = responseBody.contentType().charset() + val charset: Charset = responseBody.contentType().charsetOrUtf8() if (!buffer.isProbablyUtf8()) { logger.log("") diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 5404d564a..26f8d6964 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -56,8 +56,8 @@ Лучшее за все время Старое Непрочитанные - Версия сервера (%1s) ниже минимальной поддерживаемой версии (%2s). Сообщите об этом своему администратору и войдите в другой экземпляр или выйдите и используйте экземпляр по умолчанию. - Версия сервера (%1s) слишком низкая + Версия сервера (%1$s) ниже минимальной поддерживаемой версии (%2$s). Сообщите об этом своему администратору и войдите в другой экземпляр или выйдите и используйте экземпляр по умолчанию. + Версия сервера (%1$s) слишком низкая Создать пост Отправить Выберите ленту @@ -336,7 +336,7 @@ Перейти в анонимный режим Вы уже вошли в систему под этой учетной записью Отмечать как прочитанное при пролистывании - %1s@%2s Информация + %1$s@%2$s Информация Отправить сообщение Не удалось отправить сообщение, повторная попытка… Пишите ваше сообщение здесь