Skip to content

Commit b98be73

Browse files
committed
feat(faq): added HTML parsing
1 parent 0bc93fe commit b98be73

File tree

18 files changed

+895
-42
lines changed

18 files changed

+895
-42
lines changed

.github/workflows/mobile-ci.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
distribution: temurin
3131
java-version: '21'
3232
- uses: gradle/actions/setup-gradle@v4
33-
- run: ./gradlew ktlintCheck detekt --stacktrace
33+
- run: ./gradlew ktlintCheck detekt -Dorg.gradle.java.home=$JAVA_HOME --stacktrace --continue
3434

3535
shared-unit:
3636
name: Shared unit tests
@@ -43,7 +43,7 @@ jobs:
4343
distribution: temurin
4444
java-version: '21'
4545
- uses: gradle/actions/setup-gradle@v4
46-
- run: ./gradlew :shared:testDebugUnitTest --stacktrace
46+
- run: ./gradlew :shared:testDebugUnitTest -Dorg.gradle.java.home=$JAVA_HOME --no-daemon --stacktrace
4747
- uses: actions/upload-artifact@v4
4848
if: always()
4949
with:
@@ -61,7 +61,7 @@ jobs:
6161
distribution: temurin
6262
java-version: '21'
6363
- uses: gradle/actions/setup-gradle@v4
64-
- run: ./gradlew :androidApp:assembleDebug --stacktrace
64+
- run: ./gradlew :androidApp:assembleDebug -Dorg.gradle.java.home=$JAVA_HOME --stacktrace
6565
- uses: actions/upload-artifact@v4
6666
with:
6767
name: androidApp-debug-apk
@@ -84,7 +84,7 @@ jobs:
8484
working-directory: mobile/iosApp
8585
run: xcodegen generate
8686
- name: Assemble shared XCFramework
87-
run: ./gradlew :shared:assembleSharedDebugXCFramework --stacktrace
87+
run: ./gradlew :shared:assembleSharedDebugXCFramework -Dorg.gradle.java.home=$JAVA_HOME --stacktrace
8888
- name: Build iOS app
8989
working-directory: mobile/iosApp
9090
run: |
@@ -108,7 +108,7 @@ jobs:
108108
distribution: temurin
109109
java-version: '21'
110110
- uses: gradle/actions/setup-gradle@v4
111-
- run: ./gradlew :shared:iosSimulatorArm64Test --stacktrace
111+
- run: ./gradlew :shared:iosSimulatorArm64Test -Dorg.gradle.java.home=$JAVA_HOME --stacktrace
112112
- uses: actions/upload-artifact@v4
113113
if: always()
114114
with:

mobile/androidApp/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ plugins {
44
alias(libs.plugins.kotlin.compose)
55
}
66

7+
kotlin {
8+
compilerOptions {
9+
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
10+
}
11+
}
12+
713
android {
814
namespace = "app.myfaq.android"
915
compileSdk =

mobile/androidApp/src/main/kotlin/app/myfaq/android/screens/FaqDetailScreen.kt

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package app.myfaq.android.screens
22

3+
import android.annotation.SuppressLint
34
import android.content.Intent
45
import android.webkit.WebView
56
import androidx.compose.animation.AnimatedVisibility
@@ -49,6 +50,7 @@ import app.myfaq.android.screens.components.LoadingIndicator
4950
import app.myfaq.shared.api.dto.Comment
5051
import app.myfaq.shared.api.dto.FaqDetail
5152
import app.myfaq.shared.data.ActiveInstanceManager
53+
import app.myfaq.shared.domain.HtmlUtils
5254
import app.myfaq.shared.ui.FaqDetailViewModel
5355
import app.myfaq.shared.ui.UiState
5456
import org.koin.compose.koinInject
@@ -137,7 +139,7 @@ private fun FaqDetailContent(
137139
) {
138140
// Question heading
139141
Text(
140-
text = faq.question,
142+
text = HtmlUtils.decodeEntities(faq.question),
141143
style = MaterialTheme.typography.headlineSmall,
142144
)
143145

@@ -152,17 +154,36 @@ private fun FaqDetailContent(
152154

153155
Spacer(modifier = Modifier.height(16.dp))
154156

155-
// HTML answer in WebView
157+
// HTML answer in WebView (auto-sizing)
156158
if (faq.answer.isNotBlank()) {
157159
val bgHex = String.format("#%06X", bgColor and 0xFFFFFF)
158160
val fgHex = String.format("#%06X", textColor and 0xFFFFFF)
159161
val htmlContent = buildHtml(faq.answer, bgHex, fgHex)
162+
val density = LocalContext.current.resources.displayMetrics.density
163+
var webViewHeight by remember { mutableStateOf(200.dp) }
160164

161165
AndroidView(
162166
factory = { ctx ->
163167
WebView(ctx).apply {
164-
settings.javaScriptEnabled = false
168+
@SuppressLint("SetJavaScriptEnabled")
169+
settings.javaScriptEnabled = true
165170
setBackgroundColor(bgColor)
171+
webViewClient =
172+
object : android.webkit.WebViewClient() {
173+
override fun onPageFinished(
174+
view: WebView?,
175+
url: String?,
176+
) {
177+
view?.evaluateJavascript(
178+
"document.body.scrollHeight",
179+
) { heightStr ->
180+
val heightPx =
181+
heightStr.toFloatOrNull()
182+
?: return@evaluateJavascript
183+
webViewHeight = (heightPx / density).dp + 16.dp
184+
}
185+
}
186+
}
166187
loadDataWithBaseURL(null, htmlContent, "text/html", "UTF-8", null)
167188
}
168189
},
@@ -174,7 +195,7 @@ private fun FaqDetailContent(
174195
modifier =
175196
Modifier
176197
.fillMaxWidth()
177-
.height(300.dp),
198+
.height(webViewHeight),
178199
)
179200
}
180201

mobile/androidApp/src/main/kotlin/app/myfaq/android/screens/SearchScreen.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp
3030
import app.myfaq.android.screens.components.ErrorRetry
3131
import app.myfaq.android.screens.components.LoadingIndicator
3232
import app.myfaq.shared.data.ActiveInstanceManager
33+
import app.myfaq.shared.domain.HtmlUtils
3334
import app.myfaq.shared.ui.SearchViewModel
3435
import app.myfaq.shared.ui.UiState
3536
import org.koin.compose.koinInject
@@ -116,10 +117,21 @@ fun SearchScreen(
116117
ListItem(
117118
headlineContent = {
118119
Text(
119-
text = result.question,
120+
text = HtmlUtils.decodeEntities(result.question),
120121
maxLines = 2,
121122
)
122123
},
124+
supportingContent = {
125+
val preview = result.answer?.let { HtmlUtils.preview(it) }
126+
if (!preview.isNullOrBlank()) {
127+
Text(
128+
text = preview,
129+
maxLines = 2,
130+
style = MaterialTheme.typography.bodySmall,
131+
color = MaterialTheme.colorScheme.onSurfaceVariant,
132+
)
133+
}
134+
},
123135
modifier =
124136
Modifier.clickable {
125137
onFaqClick(result.categoryId, result.id)

mobile/androidApp/src/main/kotlin/app/myfaq/android/screens/components/FaqCard.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.compose.material3.Text
1010
import androidx.compose.runtime.Composable
1111
import androidx.compose.ui.Modifier
1212
import androidx.compose.ui.unit.dp
13+
import app.myfaq.shared.domain.HtmlUtils
1314

1415
@Composable
1516
fun FaqCard(
@@ -26,7 +27,7 @@ fun FaqCard(
2627
) {
2728
Column(modifier = Modifier.padding(16.dp)) {
2829
Text(
29-
text = question,
30+
text = HtmlUtils.decodeEntities(question),
3031
style = MaterialTheme.typography.titleSmall,
3132
maxLines = 3,
3233
)

mobile/iosApp/iosApp/Components/FaqRow.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ struct FaqRow: View {
88

99
var body: some View {
1010
VStack(alignment: .leading, spacing: 4) {
11-
Text(question)
11+
Text(question.strippingHTMLEntities())
1212
.font(.body)
1313
.lineLimit(3)
1414
if let updated = updated, !updated.isEmpty {

mobile/iosApp/iosApp/ContentView.swift

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,10 @@ private struct MainTabView: View {
6262
}
6363

6464
private var searchTab: some View {
65-
NavigationStack {
66-
SearchNavigationView()
67-
}
68-
.tabItem {
69-
Label("Search", systemImage: "magnifyingglass")
70-
}
65+
SearchNavigationView()
66+
.tabItem {
67+
Label("Search", systemImage: "magnifyingglass")
68+
}
7169
}
7270

7371
private var settingsTab: some View {
@@ -140,18 +138,20 @@ private struct SearchNavigationView: View {
140138
@State private var path = NavigationPath()
141139

142140
var body: some View {
143-
SearchScreen(onFaqClick: { categoryId, faqId in
144-
path.append(FaqRoute(categoryId: categoryId, faqId: faqId))
145-
})
146-
.navigationDestination(for: FaqRoute.self) { route in
147-
FaqDetailScreen(
148-
categoryId: route.categoryId,
149-
faqId: route.faqId,
150-
onPaywall: { path.append(PaywallRoute()) }
151-
)
152-
}
153-
.navigationDestination(for: PaywallRoute.self) { _ in
154-
PaywallScreen()
141+
NavigationStack(path: $path) {
142+
SearchScreen(onFaqClick: { categoryId, faqId in
143+
path.append(FaqRoute(categoryId: categoryId, faqId: faqId))
144+
})
145+
.navigationDestination(for: FaqRoute.self) { route in
146+
FaqDetailScreen(
147+
categoryId: route.categoryId,
148+
faqId: route.faqId,
149+
onPaywall: { path.append(PaywallRoute()) }
150+
)
151+
}
152+
.navigationDestination(for: PaywallRoute.self) { _ in
153+
PaywallScreen()
154+
}
155155
}
156156
}
157157
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Foundation
2+
3+
/// Lightweight HTML stripping and entity decoding for plain-text contexts.
4+
/// Full HTML rendering uses WKWebView in the detail screen.
5+
extension String {
6+
7+
/// Decode HTML character entities (& < ' ' etc.).
8+
func strippingHTMLEntities() -> String {
9+
var result = self
10+
// Named entities
11+
let namedEntities: [String: String] = [
12+
"&amp;": "&", "&lt;": "<", "&gt;": ">",
13+
"&quot;": "\"", "&apos;": "'", "&nbsp;": "\u{00A0}",
14+
"&ndash;": "\u{2013}", "&mdash;": "\u{2014}",
15+
"&laquo;": "\u{00AB}", "&raquo;": "\u{00BB}",
16+
"&bull;": "\u{2022}", "&hellip;": "\u{2026}",
17+
"&copy;": "\u{00A9}", "&reg;": "\u{00AE}",
18+
"&trade;": "\u{2122}", "&euro;": "\u{20AC}",
19+
]
20+
for (entity, char) in namedEntities {
21+
result = result.replacingOccurrences(of: entity, with: char)
22+
}
23+
// Decimal numeric entities: &#123;
24+
if let regex = try? NSRegularExpression(pattern: "&#(\\d+);") {
25+
let range = NSRange(result.startIndex..., in: result)
26+
let matches = regex.matches(in: result, range: range).reversed()
27+
for match in matches {
28+
if let codeRange = Range(match.range(at: 1), in: result),
29+
let codePoint = UInt32(result[codeRange]),
30+
let scalar = Unicode.Scalar(codePoint) {
31+
let fullRange = Range(match.range, in: result)!
32+
result.replaceSubrange(fullRange, with: String(Character(scalar)))
33+
}
34+
}
35+
}
36+
// Hex numeric entities: &#x1F4A9;
37+
if let regex = try? NSRegularExpression(pattern: "&#x([0-9a-fA-F]+);") {
38+
let range = NSRange(result.startIndex..., in: result)
39+
let matches = regex.matches(in: result, range: range).reversed()
40+
for match in matches {
41+
if let codeRange = Range(match.range(at: 1), in: result),
42+
let codePoint = UInt32(result[codeRange], radix: 16),
43+
let scalar = Unicode.Scalar(codePoint) {
44+
let fullRange = Range(match.range, in: result)!
45+
result.replaceSubrange(fullRange, with: String(Character(scalar)))
46+
}
47+
}
48+
}
49+
return result
50+
}
51+
52+
/// Strip all HTML tags and decode entities, returning plain text.
53+
/// Block-level tags are replaced with newlines.
54+
func strippingHTML() -> String {
55+
guard !isEmpty else { return "" }
56+
var text = self
57+
// Replace <br> with newline
58+
text = text.replacingOccurrences(
59+
of: "<br\\s*/?>",
60+
with: "\n",
61+
options: [.regularExpression, .caseInsensitive]
62+
)
63+
// Replace block-level closing/opening tags with newline
64+
text = text.replacingOccurrences(
65+
of: "</?(p|div|li|tr|h[1-6])[^>]*>",
66+
with: "\n",
67+
options: [.regularExpression, .caseInsensitive]
68+
)
69+
// Strip remaining tags
70+
text = text.replacingOccurrences(
71+
of: "<[^>]*>",
72+
with: "",
73+
options: .regularExpression
74+
)
75+
// Decode entities
76+
text = text.strippingHTMLEntities()
77+
// Collapse whitespace
78+
text = text.components(separatedBy: .newlines)
79+
.map { $0.trimmingCharacters(in: .whitespaces)
80+
.replacingOccurrences(of: "\\s{2,}", with: " ", options: .regularExpression) }
81+
.joined(separator: "\n")
82+
// Collapse multiple blank lines
83+
text = text.replacingOccurrences(of: "\n{3,}", with: "\n\n", options: .regularExpression)
84+
return text.trimmingCharacters(in: .whitespacesAndNewlines)
85+
}
86+
}
87+

0 commit comments

Comments
 (0)