Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions commonbiz/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ kotlin {
implementation(libs.androidx.browser)

implementation(libs.multiplatformsettings.datastore)

implementation("com.google.mlkit:language-id:17.0.6")
}
}
iosMain {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.zhangke.fread.common.language

import com.google.mlkit.nl.languageid.LanguageIdentification
import com.google.mlkit.nl.languageid.LanguageIdentificationOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume

/**
* Android implementation backed by Google ML Kit's on-device language ID model.
*
* Thresholds mirror bsky-social-app's `SuggestedLanguage`:
* - `identifyPossibleLanguages` returns all candidates with confidence above
* a minimum (default 0.01, which is comparable to bsky's 0.0002 floor for
* "the model is at all unsure")
* - We only return a result when exactly one candidate survives that filter
* and its confidence is ≥ 0.97.
*/
actual class LanguageDetector actual constructor() {

private val identifier by lazy {
LanguageIdentification.getClient(
LanguageIdentificationOptions.Builder()
.setConfidenceThreshold(MIN_CONFIDENCE)
.build()
)
}

actual suspend fun detect(text: String): String? =
suspendCancellableCoroutine { cont ->
identifier.identifyPossibleLanguages(text)
.addOnSuccessListener { identified ->
val confident = identified.filter { it.languageTag != "und" }
val top = confident.singleOrNull()
cont.resume(
top?.takeIf { it.confidence >= ACCEPT_CONFIDENCE }?.languageTag
)
}
.addOnFailureListener { cont.resume(null) }
}

private companion object {
/** Minimum confidence for the model to include a candidate at all. */
const val MIN_CONFIDENCE = 0.01F

/** Confidence threshold above which we surface a suggestion to the user. */
const val ACCEPT_CONFIDENCE = 0.97F
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.zhangke.fread.common.review.FreadReviewManager
import com.zhangke.fread.common.startup.FeedsRepoModuleStartup
import com.zhangke.fread.common.startup.FreadConfigModuleStartup
import com.zhangke.fread.common.startup.StartupManager
import com.zhangke.fread.common.language.LanguageDetector
import com.zhangke.fread.common.status.StatusIdGenerator
import com.zhangke.fread.common.status.StatusUpdater
import com.zhangke.fread.common.status.adapter.ContentConfigAdapter
Expand Down Expand Up @@ -52,6 +53,7 @@ val commonModule = module {
singleOf(::SelectedContentSwitcher)
singleOf(::StartupManager)
singleOf(::StatusUpdater)
singleOf(::LanguageDetector)

factoryOf(::LinkPreviewCardRepo)
factoryOf(::AppUpdateManager)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.zhangke.fread.common.language

/**
* On-device detector for the language of free-form text. Used by the post
* composer to suggest a language switch when what the user is typing differs
* from the currently selected post language.
*
* Android is backed by ML Kit's Language Identification model; iOS is a
* no-op stub for now.
*/
expect class LanguageDetector() {

/**
* Returns a BCP-47 language tag (e.g. `"en"`, `"es"`) if the model is
* confident the text is in a single identifiable language. Returns
* `null` otherwise — the caller should treat that as "no suggestion".
*
* The thresholds mirror bsky-social-app's `SuggestedLanguage`:
* the model must surface exactly one candidate with confidence ≥ 0.97.
*/
suspend fun detect(text: String): String?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.zhangke.fread.common.language

/**
* iOS stub. Could be backed by `NSLinguisticTagger` later; returns null for now
* so callers behave as "no suggestion".
*/
actual class LanguageDetector actual constructor() {
actual suspend fun detect(text: String): String? = null
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ val blueskyModule = module {
configManager = get(),
publishingPost = get(),
linkPreviewCardRepo = get(),
languageDetector = get(),
locator = params[0],
defaultText = params.values.getOrNull(1) as String?,
replyBlogJsonString = params.values.getOrNull(2) as String?,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
package com.zhangke.fread.bluesky.internal.screen.publish

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -70,6 +85,8 @@ fun PublishPostScreen(viewModel: PublishPostViewModel) {
},
onMentionCandidateClick = viewModel::onMentionCandidateClick,
onLinkPreviewCardRemoveClicked = viewModel::onLinkPreviewCardRemoveClicked,
onAcceptSuggestedLanguage = viewModel::onAcceptSuggestedLanguage,
onDismissSuggestedLanguage = viewModel::onDismissSuggestedLanguage,
)
ConsumeSnackbarFlow(snackBarHostState, viewModel.snackBarMessageFlow)
ConsumeFlow(viewModel.finishPageFlow) {
Expand All @@ -94,6 +111,8 @@ private fun PublishPostContent(
onAddAccountClick: () -> Unit,
onMentionCandidateClick: (ProfileView) -> Unit,
onLinkPreviewCardRemoveClicked: () -> Unit,
onAcceptSuggestedLanguage: () -> Unit,
onDismissSuggestedLanguage: () -> Unit,
) {
PublishPostScaffold(
account = uiState.account,
Expand Down Expand Up @@ -162,7 +181,91 @@ private fun PublishPostContent(
onRemoveClick = onLinkPreviewCardRemoveClicked,
)
}
uiState.suggestedLanguage?.let { suggestion ->
SuggestedLanguageBanner(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
languageTag = suggestion,
onAccept = onAcceptSuggestedLanguage,
onDismiss = onDismissSuggestedLanguage,
)
}
},
allowHashtagInHashtag = true,
)
}

@Composable
private fun SuggestedLanguageBanner(
modifier: Modifier,
languageTag: String,
onAccept: () -> Unit,
onDismiss: () -> Unit,
) {
val displayName = remember(languageTag) { displayNameOf(languageTag) }
Card(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Row(
modifier = Modifier.padding(start = 12.dp, end = 4.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Default.Language,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
modifier = Modifier.weight(1F),
text = "Are you writing in $displayName?",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onAccept) {
Text("Yes")
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Dismiss",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}

/** Best-effort mapping of a BCP-47 tag to a human-readable name. */
private fun displayNameOf(tag: String): String = when (tag.lowercase().substringBefore('-')) {
"en" -> "English"
"es" -> "Spanish"
"pt" -> "Portuguese"
"fr" -> "French"
"de" -> "German"
"it" -> "Italian"
"ja" -> "Japanese"
"ko" -> "Korean"
"zh" -> "Chinese"
"ru" -> "Russian"
"ar" -> "Arabic"
"nl" -> "Dutch"
"pl" -> "Polish"
"tr" -> "Turkish"
"sv" -> "Swedish"
"no" -> "Norwegian"
"da" -> "Danish"
"fi" -> "Finnish"
"cs" -> "Czech"
"el" -> "Greek"
"he" -> "Hebrew"
"hi" -> "Hindi"
"id" -> "Indonesian"
"th" -> "Thai"
"uk" -> "Ukrainian"
"vi" -> "Vietnamese"
else -> tag
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ data class PublishPostUiState(
val list: List<StatusList>,
val mentionState: LoadableState<List<ProfileView>>,
val detectedLinkCard: DetectedLinkCard?,
/** BCP-47 language tag that the on-device detector is confident about, when it differs from [selectedLanguages]. */
val suggestedLanguage: String? = null,
) {

val publishEnabled: Boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.zhangke.fread.bluesky.internal.client.BlueskyClientManager
import com.zhangke.fread.bluesky.internal.usecase.GetAllListsUseCase
import com.zhangke.fread.bluesky.internal.usecase.PublishingPostUseCase
import com.zhangke.fread.common.config.FreadConfigManager
import com.zhangke.fread.common.language.LanguageDetector
import com.zhangke.fread.common.repo.LinkPreviewCardRepo
import com.zhangke.fread.common.utils.MentionTextUtil
import com.zhangke.fread.common.utils.PlatformUriHelper
Expand All @@ -33,6 +34,7 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
Expand All @@ -49,6 +51,7 @@ class PublishPostViewModel(
private val publishingPost: PublishingPostUseCase,
private val locator: PlatformLocator,
private val linkPreviewCardRepo: LinkPreviewCardRepo,
private val languageDetector: LanguageDetector,
defaultText: String?,
replyBlogJsonString: String?,
quoteBlogJsonString: String?,
Expand All @@ -71,6 +74,11 @@ class PublishPostViewModel(

private var extractLinkPreviewCardJob: Job? = null

private var detectLanguageJob: Job? = null

/** Languages the user has already explicitly waved off for this composer session. */
private val dismissedLanguageSuggestions = mutableSetOf<String>()

init {
launchInViewModel(Dispatchers.IO) {
val reply: Blog? = replyBlogJsonString?.let {
Expand Down Expand Up @@ -101,6 +109,45 @@ class PublishPostViewModel(
_uiState.update { it.copy(content = text) }
maybeNeedSearchAccount(text)
extractLinkPreviewCard(text.text)
maybeDetectLanguage(text.text)
}

/**
* Mirrors bsky-social-app's `SuggestedLanguage` heuristic: only attempt
* detection once the user has typed enough text (≥40 chars). We debounce
* lightly so we don't hit the detector on every keystroke. The
* [LanguageDetector] applies the stricter "single confident match"
* thresholds.
*/
private fun maybeDetectLanguage(text: String) {
detectLanguageJob?.cancel()
val trimmed = text.trim()
if (trimmed.length < MIN_DETECT_LENGTH) {
_uiState.update { it.copy(suggestedLanguage = null) }
return
}
detectLanguageJob = launchInViewModel {
delay(DETECT_DEBOUNCE_MS)
val detected = languageDetector.detect(trimmed)
_uiState.update { state ->
val suggestion = detected
?.takeIf { it !in state.selectedLanguages }
?.takeIf { it !in dismissedLanguageSuggestions }
state.copy(suggestedLanguage = suggestion)
}
}
}

fun onAcceptSuggestedLanguage() {
val suggestion = _uiState.value.suggestedLanguage ?: return
onLanguageSelected(listOf(suggestion))
_uiState.update { it.copy(suggestedLanguage = null) }
}

fun onDismissSuggestedLanguage() {
val suggestion = _uiState.value.suggestedLanguage ?: return
dismissedLanguageSuggestions += suggestion
_uiState.update { it.copy(suggestedLanguage = null) }
}

private fun maybeNeedSearchAccount(content: TextFieldValue) {
Expand Down Expand Up @@ -276,7 +323,16 @@ class PublishPostViewModel(
launchInViewModel {
configManager.updateBskyPublishLanguage(selectedLanguages)
}
_uiState.update { it.copy(selectedLanguages = selectedLanguages) }
_uiState.update { state ->
val suggestion = state.suggestedLanguage
?.takeIf { it !in selectedLanguages }
state.copy(selectedLanguages = selectedLanguages, suggestedLanguage = suggestion)
}
}

private companion object {
const val MIN_DETECT_LENGTH = 40
const val DETECT_DEBOUNCE_MS = 350L
}

fun onPublishClick() {
Expand Down
Loading