завел коммы

This commit is contained in:
Lain Iwakura 2025-08-09 18:12:54 +03:00
parent 7a5d6bbaf8
commit 875aab1185
No known key found for this signature in database
GPG Key ID: C7C18257F2ADC6F8
10 changed files with 142 additions and 62 deletions

View File

@ -16,6 +16,9 @@ import retrofit2.Retrofit
import retrofit2.converter.scalars.ScalarsConverterFactory import retrofit2.converter.scalars.ScalarsConverterFactory
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import okhttp3.FormBody
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ApiClient(context: Context) { class ApiClient(context: Context) {
private val baseUrl = "https://mkch.pooziqo.xyz" private val baseUrl = "https://mkch.pooziqo.xyz"
@ -24,6 +27,7 @@ class ApiClient(context: Context) {
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val prefs = context.getSharedPreferences("api_prefs", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("api_prefs", Context.MODE_PRIVATE)
private val cache = Cache(context)
private val userAgentInterceptor = Interceptor { chain -> private val userAgentInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder() val request = chain.request().newBuilder()
@ -61,63 +65,78 @@ class ApiClient(context: Context) {
} }
} }
fun getBoards(): List<Board> = parseBoards(get("${apiUrl}boards/")) suspend fun getBoards(): List<Board> = withContext(Dispatchers.IO) {
parseBoards(get("${apiUrl}boards/"))
fun getThreads(boardCode: String): List<ThreadItem> = parseThreads(get("${apiUrl}board/$boardCode"))
fun getThreadDetail(boardCode: String, threadId: Int): ThreadDetail =
parseThreadDetail(get("${apiUrl}board/$boardCode/thread/$threadId"))
fun getComments(boardCode: String, threadId: Int): List<Comment> =
parseComments(get("${apiUrl}board/$boardCode/thread/$threadId/comments"))
fun getFullThread(boardCode: String, threadId: Int): Pair<ThreadDetail, List<Comment>> {
val detail = getThreadDetail(boardCode, threadId)
val comments = getComments(boardCode, threadId)
return detail to comments
} }
fun authenticate(authKey: String) { suspend fun getThreads(boardCode: String): List<ThreadItem> = withContext(Dispatchers.IO) {
if (authKey.isEmpty()) return parseThreads(get("${apiUrl}board/$boardCode"))
}
suspend fun getThreadDetail(boardCode: String, threadId: Int): ThreadDetail = withContext(Dispatchers.IO) {
parseThreadDetail(get("${apiUrl}board/$boardCode/thread/$threadId"))
}
suspend fun getComments(boardCode: String, threadId: Int): List<Comment> = withContext(Dispatchers.IO) {
parseComments(get("${apiUrl}board/$boardCode/thread/$threadId/comments"))
}
suspend fun getFullThread(boardCode: String, threadId: Int): Pair<ThreadDetail, List<Comment>> = withContext(Dispatchers.IO) {
val detail = getThreadDetail(boardCode, threadId)
val comments = getComments(boardCode, threadId)
detail to comments
}
suspend fun authenticate(authKey: String) = withContext(Dispatchers.IO) {
if (authKey.isEmpty()) return@withContext
val url = "$baseUrl/key/auth/" val url = "$baseUrl/key/auth/"
val formHtml = get(url) val formHtml = get(url)
val csrf = extractCsrf(formHtml) val csrf = extractCsrf(formHtml)
val body = "csrfmiddlewaretoken=${'$'}csrf&key=${'$'}authKey" val body = FormBody.Builder()
.toRequestBody("application/x-www-form-urlencoded".toMediaType()) .add("csrfmiddlewaretoken", csrf)
.add("key", authKey)
.build()
val req = Request.Builder().url(url) val req = Request.Builder().url(url)
.post(body) .post(body)
.header("Referer", url) .header("Referer", url)
.build() .build()
httpClient.newCall(req).execute().use { resp -> httpClient.newCall(req).execute().use { resp ->
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("auth failed ${'$'}{resp.code}") if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("Auth failed: ${'$'}{resp.code}")
} }
} }
fun loginWithPasscode(passcode: String) { suspend fun loginWithPasscode(passcode: String) = withContext(Dispatchers.IO) {
if (passcode.isEmpty()) return if (passcode.isEmpty()) return@withContext
val url = "$baseUrl/passcode/enter/" val url = "$baseUrl/passcode/enter/"
val formHtml = get(url) val formHtml = get(url)
val csrf = extractCsrf(formHtml) val csrf = extractCsrf(formHtml)
val body = "csrfmiddlewaretoken=${'$'}csrf&passcode=${'$'}passcode" val body = FormBody.Builder()
.toRequestBody("application/x-www-form-urlencoded".toMediaType()) .add("csrfmiddlewaretoken", csrf)
.add("passcode", passcode)
.build()
val req = Request.Builder().url(url) val req = Request.Builder().url(url)
.post(body) .post(body)
.header("Referer", url) .header("Referer", url)
.build() .build()
httpClient.newCall(req).execute().use { resp -> httpClient.newCall(req).execute().use { resp ->
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("passcode failed ${'$'}{resp.code}") if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("Passcode failed: ${'$'}{resp.code}")
} }
} }
fun createThread(boardCode: String, title: String, text: String, files: List<File> = emptyList(), passcode: String = "", authKey: String = "") { suspend fun createThread(boardCode: String, title: String, text: String, files: List<File> = emptyList(), passcode: String = "", authKey: String = "") = withContext(Dispatchers.IO) {
if (authKey.isNotEmpty()) authenticate(authKey) if (authKey.isNotEmpty()) authenticate(authKey)
if (passcode.isNotEmpty()) loginWithPasscode(passcode) if (passcode.isNotEmpty()) loginWithPasscode(passcode)
val formUrl = "$baseUrl/boards/board/$boardCode/new" val formUrl = "$baseUrl/boards/board/$boardCode/new"
val csrf = fetchCsrf(formUrl) val csrf = fetchCsrf(formUrl)
val req = if (files.isEmpty()) { val req = if (files.isEmpty()) {
val body = "csrfmiddlewaretoken=$csrf&title=$title&text=$text" val body = FormBody.Builder()
.toRequestBody("application/x-www-form-urlencoded".toMediaType()) .add("csrfmiddlewaretoken", csrf)
.add("title", title)
.add("text", text)
.build()
Request.Builder().url(formUrl).post(body) Request.Builder().url(formUrl).post(body)
.header("Referer", formUrl)
.build()
} else { } else {
val builder = MultipartBody.Builder().setType(MultipartBody.FORM) val builder = MultipartBody.Builder().setType(MultipartBody.FORM)
.addFormDataPart("csrfmiddlewaretoken", csrf) .addFormDataPart("csrfmiddlewaretoken", csrf)
@ -128,23 +147,28 @@ class ApiClient(context: Context) {
builder.addFormDataPart("files", "photo_${idx + 1}.jpg", rb) builder.addFormDataPart("files", "photo_${idx + 1}.jpg", rb)
} }
Request.Builder().url(formUrl).post(builder.build()) Request.Builder().url(formUrl).post(builder.build())
.header("Referer", formUrl)
.build()
} }
.header("Referer", formUrl)
.build()
httpClient.newCall(req).execute().use { resp -> httpClient.newCall(req).execute().use { resp ->
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("createThread failed ${resp.code}") if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("createThread failed ${resp.code}")
cache.delete("threads_${boardCode}")
} }
} }
fun addComment(boardCode: String, threadId: Int, text: String, files: List<File> = emptyList(), passcode: String = "", authKey: String = "") { suspend fun addComment(boardCode: String, threadId: Int, text: String, files: List<File> = emptyList(), passcode: String = "", authKey: String = "") = withContext(Dispatchers.IO) {
if (authKey.isNotEmpty()) authenticate(authKey) if (authKey.isNotEmpty()) authenticate(authKey)
if (passcode.isNotEmpty()) loginWithPasscode(passcode) if (passcode.isNotEmpty()) loginWithPasscode(passcode)
val formUrl = "$baseUrl/boards/board/$boardCode/thread/$threadId/comment" val formUrl = "$baseUrl/boards/board/$boardCode/thread/$threadId/comment"
val csrf = fetchCsrf(formUrl) val csrf = fetchCsrf(formUrl)
val req = if (files.isEmpty()) { val req = if (files.isEmpty()) {
val body = "csrfmiddlewaretoken=$csrf&text=$text" val body = FormBody.Builder()
.toRequestBody("application/x-www-form-urlencoded".toMediaType()) .add("csrfmiddlewaretoken", csrf)
.add("text", text)
.build()
Request.Builder().url(formUrl).post(body) Request.Builder().url(formUrl).post(body)
.header("Referer", formUrl)
.build()
} else { } else {
val builder = MultipartBody.Builder().setType(MultipartBody.FORM) val builder = MultipartBody.Builder().setType(MultipartBody.FORM)
.addFormDataPart("csrfmiddlewaretoken", csrf) .addFormDataPart("csrfmiddlewaretoken", csrf)
@ -154,11 +178,12 @@ class ApiClient(context: Context) {
builder.addFormDataPart("files", "photo_${idx + 1}.jpg", rb) builder.addFormDataPart("files", "photo_${idx + 1}.jpg", rb)
} }
Request.Builder().url(formUrl).post(builder.build()) Request.Builder().url(formUrl).post(builder.build())
.header("Referer", formUrl)
.build()
} }
.header("Referer", formUrl)
.build()
httpClient.newCall(req).execute().use { resp -> httpClient.newCall(req).execute().use { resp ->
if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("addComment failed ${resp.code}") if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("addComment failed ${resp.code}")
cache.delete("comments_${threadId}")
} }
} }
@ -173,11 +198,11 @@ class ApiClient(context: Context) {
} }
} }
fun checkNewThreads(boardCode: String): List<ThreadItem> { suspend fun checkNewThreads(boardCode: String): List<ThreadItem> = withContext(Dispatchers.IO) {
val current = getThreads(boardCode) val current = getThreads(boardCode)
val key = "savedThreads_${'$'}boardCode" val key = "savedThreads_${boardCode}"
val savedRaw = prefs.getString(key, null) val savedRaw = prefs.getString(key, null)
return if (savedRaw != null) { return@withContext if (savedRaw != null) {
val saved = runCatching { json.decodeFromString<List<ThreadItem>>(savedRaw) }.getOrElse { emptyList() } val saved = runCatching { json.decodeFromString<List<ThreadItem>>(savedRaw) }.getOrElse { emptyList() }
val savedIds = saved.map { it.id }.toSet() val savedIds = saved.map { it.id }.toSet()
val newOnes = current.filter { it.id !in savedIds } val newOnes = current.filter { it.id !in savedIds }

View File

@ -39,6 +39,10 @@ class Cache(private val context: Context) {
File(dir, safeName(key)).delete() File(dir, safeName(key)).delete()
} }
suspend fun clearAll() = withContext(Dispatchers.IO) {
dir.listFiles()?.forEach { runCatching { it.delete() } }
}
private fun safeName(key: String) = key.hashCode().toString(16) + ".json" private fun safeName(key: String) = key.hashCode().toString(16) + ".json"
} }

View File

@ -11,16 +11,16 @@ class PersistentCookieJar(context: Context) : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) { override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val key = url.host val key = url.host
val existing = loadForRequest(url).toMutableList() val existing = loadForRequest(url).toMutableList()
existing.removeAll { e -> cookies.any { it.name == e.name } } existing.removeAll { e -> cookies.any { it.name == e.name && it.domain == e.domain && it.path == e.path } }
existing.addAll(cookies) existing.addAll(cookies)
val serialized = existing.joinToString(";") { it.toString() } val serialized = existing.joinToString("\n") { it.toString() }
prefs.edit().putString(key, serialized).apply() prefs.edit().putString(key, serialized).apply()
} }
override fun loadForRequest(url: HttpUrl): List<Cookie> { override fun loadForRequest(url: HttpUrl): List<Cookie> {
val key = url.host val key = url.host
val stored = prefs.getString(key, null) ?: return emptyList() val stored = prefs.getString(key, null) ?: return emptyList()
return stored.split(";").mapNotNull { Cookie.parse(url, it) } return stored.split('\n').mapNotNull { line -> Cookie.parse(url, line) }
} }
} }

View File

@ -105,11 +105,17 @@ fun AppRoot() {
composable("thread/{board}/{id}/comment") { backStack -> composable("thread/{board}/{id}/comment") { backStack ->
val board = backStack.arguments?.getString("board").orEmpty() val board = backStack.arguments?.getString("board").orEmpty()
val id = backStack.arguments?.getString("id")?.toIntOrNull() ?: 0 val id = backStack.arguments?.getString("id")?.toIntOrNull() ?: 0
AddCommentScreen(boardCode = board, threadId = id) AddCommentScreen(boardCode = board, threadId = id, onDone = {
navController.popBackStack()
})
} }
composable("threads/{board}/new") { backStack -> composable("threads/{board}/new") { backStack ->
val board = backStack.arguments?.getString("board").orEmpty() val board = backStack.arguments?.getString("board").orEmpty()
CreateThreadScreen(boardCode = board) CreateThreadScreen(boardCode = board, onDone = {
navController.popBackStack()
navController.popBackStack()
navController.navigate("boards")
})
} }
} }
} }

View File

@ -55,9 +55,12 @@ class AddCommentVm(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { viewModelScope.launch {
val settings = SettingsStore(getApplication()).data.first() val settings = SettingsStore(getApplication()).data.first()
val files = withContext(Dispatchers.IO) { uris.mapNotNull { uri -> ImageUtil.downscaleJpeg(context, uri) } } val files = withContext(Dispatchers.IO) { uris.mapNotNull { uri -> ImageUtil.downscaleJpeg(context, uri) } }
runCatching { api.addComment(board, id, text, files, passcode = settings.passcode, authKey = settings.key) } try {
.onSuccess { onDone() } api.addComment(board, id, text, files, passcode = settings.passcode, authKey = settings.key)
.onFailure { error = it.message } onDone()
} catch (t: Throwable) {
error = t.message
}
isLoading = false isLoading = false
withContext(Dispatchers.IO) { files.forEach { it.delete() } } withContext(Dispatchers.IO) { files.forEach { it.delete() } }
} }
@ -67,7 +70,7 @@ class AddCommentVm(app: Application) : AndroidViewModel(app) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddCommentScreen(boardCode: String, threadId: Int, vm: AddCommentVm = viewModel()) { fun AddCommentScreen(boardCode: String, threadId: Int, onDone: () -> Unit = {}, vm: AddCommentVm = viewModel()) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var picked by remember { mutableStateOf<List<Uri>>(emptyList()) } var picked by remember { mutableStateOf<List<Uri>>(emptyList()) }
@ -89,7 +92,7 @@ fun AddCommentScreen(boardCode: String, threadId: Int, vm: AddCommentVm = viewMo
} }
} }
if (vm.error != null) Text(vm.error ?: "") if (vm.error != null) Text(vm.error ?: "")
Button(onClick = { vm.send(context, boardCode, threadId, text, picked) {} }, enabled = text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Добавить") } Button(onClick = { vm.send(context, boardCode, threadId, text, picked, onDone) }, enabled = text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Добавить") }
} }
} }
} }

View File

@ -53,9 +53,12 @@ class CreateThreadVm(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { viewModelScope.launch {
val settings = SettingsStore(getApplication()).data.first() val settings = SettingsStore(getApplication()).data.first()
val files = withContext(Dispatchers.IO) { uris.mapNotNull { uri -> ImageUtil.downscaleJpeg(context, uri) } } val files = withContext(Dispatchers.IO) { uris.mapNotNull { uri -> ImageUtil.downscaleJpeg(context, uri) } }
runCatching { api.createThread(board, title, text, files, passcode = settings.passcode, authKey = settings.key) } try {
.onSuccess { onDone() } api.createThread(board, title, text, files, passcode = settings.passcode, authKey = settings.key)
.onFailure { error = it.message } onDone()
} catch (t: Throwable) {
error = t.message
}
isLoading = false isLoading = false
withContext(Dispatchers.IO) { files.forEach { it.delete() } } withContext(Dispatchers.IO) { files.forEach { it.delete() } }
} }
@ -65,7 +68,7 @@ class CreateThreadVm(app: Application) : AndroidViewModel(app) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CreateThreadScreen(boardCode: String, vm: CreateThreadVm = viewModel()) { fun CreateThreadScreen(boardCode: String, onDone: () -> Unit = {}, vm: CreateThreadVm = viewModel()) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
@ -94,7 +97,7 @@ fun CreateThreadScreen(boardCode: String, vm: CreateThreadVm = viewModel()) {
} }
} }
if (vm.error != null) Text(vm.error ?: "") if (vm.error != null) Text(vm.error ?: "")
Button(onClick = { vm.create(context, boardCode, title, text, picked) {} }, enabled = title.isNotEmpty() && text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Создать") } Button(onClick = { vm.create(context, boardCode, title, text, picked, onDone) }, enabled = title.isNotEmpty() && text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Создать") }
} }
} }
} }

View File

@ -16,6 +16,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -60,11 +62,19 @@ class SettingsVm(app: Application) : AndroidViewModel(app) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen(vm: SettingsVm = viewModel()) { fun SettingsScreen(vm: SettingsVm = viewModel()) {
val scope = androidx.compose.runtime.rememberCoroutineScope()
val settings by vm.flow.collectAsState(initial = SettingsData()) val settings by vm.flow.collectAsState(initial = SettingsData())
val subs by vm.subsFlow.collectAsState(initial = emptySet()) val subs by vm.subsFlow.collectAsState(initial = emptySet())
LaunchedEffect(Unit) { vm.loadBoards() } LaunchedEffect(Unit) { vm.loadBoards() }
Scaffold(topBar = { TopAppBar(title = { Text("Настройки") }) }) { padding -> Scaffold(topBar = { TopAppBar(title = { Text("Настройки") }) }) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
Text("Показывать файлы", modifier = Modifier.weight(1f)) Text("Показывать файлы", modifier = Modifier.weight(1f))
Checkbox(checked = settings.showFiles, onCheckedChange = { vm.update { it.copy(showFiles = it.showFiles.not()) } }) Checkbox(checked = settings.showFiles, onCheckedChange = { vm.update { it.copy(showFiles = it.showFiles.not()) } })
@ -106,8 +116,8 @@ fun SettingsScreen(vm: SettingsVm = viewModel()) {
if (vm.boards.isEmpty()) { if (vm.boards.isEmpty()) {
Text("Загрузка...") Text("Загрузка...")
} else { } else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(vm.boards) { b -> vm.boards.forEach { b ->
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
Text("/${'$'}{b.code}/ ${'$'}{b.description}", modifier = Modifier.weight(1f)) Text("/${'$'}{b.code}/ ${'$'}{b.description}", modifier = Modifier.weight(1f))
val on = subs.contains(b.code) val on = subs.contains(b.code)
@ -141,7 +151,14 @@ fun SettingsScreen(vm: SettingsVm = viewModel()) {
Text("Принудительно оффлайн", modifier = Modifier.weight(1f)) Text("Принудительно оффлайн", modifier = Modifier.weight(1f))
Checkbox(checked = settings.offlineMode, onCheckedChange = { vm.update { it.copy(offlineMode = it.offlineMode.not()) } }) Checkbox(checked = settings.offlineMode, onCheckedChange = { vm.update { it.copy(offlineMode = it.offlineMode.not()) } })
} }
Spacer(Modifier.weight(1f)) val ctx = androidx.compose.ui.platform.LocalContext.current
Row(Modifier.fillMaxWidth()) {
Button(onClick = {
scope.launch { com.mkfunproj.mobilemkch.data.Cache(ctx).clearAll() }
}) { Text("Очистить кэш API") }
Spacer(Modifier.weight(1f))
Button(onClick = { /* Coil кэш чистить можно при необходимости */ }) { Text("Очистить кэш изображ.") }
}
} }
} }
} }

View File

@ -25,10 +25,15 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.platform.LocalLifecycleOwner
import com.mkfunproj.mobilemkch.data.Comment import com.mkfunproj.mobilemkch.data.Comment
import com.mkfunproj.mobilemkch.data.Repository import com.mkfunproj.mobilemkch.data.Repository
import com.mkfunproj.mobilemkch.data.ThreadDetail import com.mkfunproj.mobilemkch.data.ThreadDetail
@ -71,8 +76,18 @@ fun ThreadDetailScreen(
vm: ThreadDetailVm = viewModel(), vm: ThreadDetailVm = viewModel(),
onAddComment: () -> Unit = {} onAddComment: () -> Unit = {}
) { ) {
val scope = androidx.compose.runtime.rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(threadId) { vm.load(boardCode, threadId) } LaunchedEffect(threadId) { vm.load(boardCode, threadId) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, boardCode, threadId) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
scope.launch { vm.load(boardCode, threadId) }
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Scaffold( Scaffold(
topBar = { TopAppBar(title = { Text("#$threadId") }, actions = { androidx.compose.material3.TextButton(onClick = { scope.launch { vm.load(boardCode, threadId) } }) { Text("Обновить") } }) }, topBar = { TopAppBar(title = { Text("#$threadId") }, actions = { androidx.compose.material3.TextButton(onClick = { scope.launch { vm.load(boardCode, threadId) } }) { Text("Обновить") } }) },
floatingActionButton = { FloatingActionButton(onClick = onAddComment) { Icon(Icons.Default.Add, contentDescription = "Add") } } floatingActionButton = { FloatingActionButton(onClick = onAddComment) { Icon(Icons.Default.Add, contentDescription = "Add") } }

View File

@ -95,11 +95,18 @@ fun ThreadsScreen(
onCreateThread: (boardCode: String) -> Unit = {}, onCreateThread: (boardCode: String) -> Unit = {},
vm: ThreadsVm = viewModel() vm: ThreadsVm = viewModel()
) { ) {
LaunchedEffect(boardCode) { vm.load(boardCode) } val context = androidx.compose.ui.platform.LocalContext.current
LaunchedEffect(boardCode) {
vm.load(boardCode)
vm.viewModelScope.launch { com.mkfunproj.mobilemkch.data.SettingsStore(context).update { it.copy(lastBoard = boardCode) } }
}
var currentPage by androidx.compose.runtime.remember { mutableStateOf(0) } var currentPage by androidx.compose.runtime.remember { mutableStateOf(0) }
val scope = androidx.compose.runtime.rememberCoroutineScope() val scope = androidx.compose.runtime.rememberCoroutineScope()
Scaffold(topBar = { TopAppBar(title = { Text("/$boardCode/ — $boardDescription") }, actions = { Scaffold(topBar = { TopAppBar(title = { Text("/$boardCode/ — $boardDescription") }, actions = {
androidx.compose.material3.TextButton(onClick = { scope.launch { vm.load(boardCode) } }) { Text("Обновить") } androidx.compose.material3.TextButton(onClick = {
currentPage = 0
scope.launch { vm.load(boardCode) }
}) { Text("Обновить") }
androidx.compose.material3.TextButton(onClick = { onCreateThread(boardCode) }) { Text("Создать") } androidx.compose.material3.TextButton(onClick = { onCreateThread(boardCode) }) { Text("Создать") }
}) }) { padding -> }) }) { padding ->
when { when {
@ -189,7 +196,6 @@ private fun ThreadList(padding: PaddingValues, vm: ThreadsVm, boardCode: String,
if (vm.showFiles && t.files.isNotEmpty()) { Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary); Spacer(Modifier.width(4.dp)); Text("${t.files.size}", style = MaterialTheme.typography.labelSmall) } if (vm.showFiles && t.files.isNotEmpty()) { Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary); Spacer(Modifier.width(4.dp)); Text("${t.files.size}", style = MaterialTheme.typography.labelSmall) }
} }
if (t.text.isNotEmpty()) Text(t.text, style = MaterialTheme.typography.bodyMedium, maxLines = 3) if (t.text.isNotEmpty()) Text(t.text, style = MaterialTheme.typography.bodyMedium, maxLines = 3)
if (vm.showFiles && t.files.isNotEmpty()) { Spacer(Modifier.height(4.dp)); t.files.forEach { f -> AsyncImage(model = "https://mkch.pooziqo.xyz$f", contentDescription = null) } }
} }
Divider(Modifier.padding(top = 8.dp)) Divider(Modifier.padding(top = 8.dp))
} }

View File

@ -18,12 +18,13 @@ class ThreadsCheckWorker(appContext: Context, workerParams: WorkerParameters) :
val api = ApiClient(applicationContext) val api = ApiClient(applicationContext)
var notified = 0 var notified = 0
subs.forEach { board -> subs.forEach { board ->
runCatching { api.checkNewThreads(board) }.onSuccess { list -> try {
val list = api.checkNewThreads(board)
list.forEach { t -> list.forEach { t ->
NotificationHelper.notifyThread(applicationContext, board, t.id, t.title) NotificationHelper.notifyThread(applicationContext, board, t.id, t.title)
notified += 1 notified += 1
} }
} } catch (_: Throwable) {}
} }
return Result.success() return Result.success()
} }