Edit Title dan Filter Chat History
This commit is contained in:
parent
74e1a720cd
commit
5503d53881
@ -51,7 +51,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
val CATEGORIES_KEY = stringPreferencesKey("categories")
|
val CATEGORIES_KEY = stringPreferencesKey("categories")
|
||||||
val NOTES_KEY = stringPreferencesKey("notes")
|
val NOTES_KEY = stringPreferencesKey("notes")
|
||||||
val CHAT_HISTORY_KEY = stringPreferencesKey("chat_history")
|
val CHAT_HISTORY_KEY = stringPreferencesKey("chat_history")
|
||||||
val THEME_KEY = stringPreferencesKey("theme") // NEW: "dark" or "light"
|
val THEME_KEY = stringPreferencesKey("theme") // "dark" or "light"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
@ -116,7 +116,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Chat History Flow
|
// Chat History Flow
|
||||||
val chatHistoryFlow: Flow<List<ChatHistory>> = context.dataStore.data
|
val chatHistoryFlow: Flow<List<ChatHistory>> = context.dataStore.data
|
||||||
.catch { exception ->
|
.catch { exception ->
|
||||||
if (exception is IOException) {
|
if (exception is IOException) {
|
||||||
@ -180,7 +180,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Save Chat History
|
// Save Chat History
|
||||||
suspend fun saveChatHistory(chatHistoryList: List<ChatHistory>) {
|
suspend fun saveChatHistory(chatHistoryList: List<ChatHistory>) {
|
||||||
try {
|
try {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
@ -191,13 +191,21 @@ class DataStoreManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Add new chat history
|
// Add new chat history
|
||||||
suspend fun addChatHistory(chatHistory: ChatHistory) {
|
suspend fun addChatHistory(chatHistory: ChatHistory) {
|
||||||
try {
|
try {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
|
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
|
||||||
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
|
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
|
||||||
|
|
||||||
|
// Check if already exists, update instead
|
||||||
|
val existingIndex = currentList.indexOfFirst { it.id == chatHistory.id }
|
||||||
|
if (existingIndex != -1) {
|
||||||
|
currentList[existingIndex] = chatHistory
|
||||||
|
} else {
|
||||||
currentList.add(0, chatHistory) // Add to beginning
|
currentList.add(0, chatHistory) // Add to beginning
|
||||||
|
}
|
||||||
|
|
||||||
preferences[CHAT_HISTORY_KEY] = json.encodeToString(currentList)
|
preferences[CHAT_HISTORY_KEY] = json.encodeToString(currentList)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@ -205,7 +213,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Delete chat history (soft delete)
|
// Delete chat history (soft delete)
|
||||||
suspend fun deleteChatHistory(historyId: String) {
|
suspend fun deleteChatHistory(historyId: String) {
|
||||||
try {
|
try {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
@ -221,7 +229,27 @@ class DataStoreManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Theme Preference Flow
|
// NEW: Update chat history title
|
||||||
|
suspend fun updateChatHistoryTitle(historyId: String, newTitle: String) {
|
||||||
|
try {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
val jsonString = preferences[CHAT_HISTORY_KEY] ?: "[]"
|
||||||
|
val currentList = json.decodeFromString<List<ChatHistory>>(jsonString).toMutableList()
|
||||||
|
val updatedList = currentList.map {
|
||||||
|
if (it.id == historyId) {
|
||||||
|
it.copy(customTitle = newTitle)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
preferences[CHAT_HISTORY_KEY] = json.encodeToString(updatedList)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme Preference Flow
|
||||||
val themeFlow: Flow<String> = context.dataStore.data
|
val themeFlow: Flow<String> = context.dataStore.data
|
||||||
.catch { exception ->
|
.catch { exception ->
|
||||||
if (exception is IOException) {
|
if (exception is IOException) {
|
||||||
@ -234,7 +262,7 @@ class DataStoreManager(private val context: Context) {
|
|||||||
preferences[THEME_KEY] ?: "dark" // Default dark theme
|
preferences[THEME_KEY] ?: "dark" // Default dark theme
|
||||||
}
|
}
|
||||||
|
|
||||||
// NEW: Save Theme Preference
|
// Save Theme Preference
|
||||||
suspend fun saveTheme(theme: String) {
|
suspend fun saveTheme(theme: String) {
|
||||||
try {
|
try {
|
||||||
context.dataStore.edit { preferences ->
|
context.dataStore.edit { preferences ->
|
||||||
|
|||||||
@ -10,6 +10,7 @@ data class ChatHistory(
|
|||||||
val categoryName: String, // Untuk display
|
val categoryName: String, // Untuk display
|
||||||
val messages: List<SerializableChatMessage>,
|
val messages: List<SerializableChatMessage>,
|
||||||
val lastMessagePreview: String, // Preview pesan terakhir
|
val lastMessagePreview: String, // Preview pesan terakhir
|
||||||
|
val customTitle: String? = null, // Custom title yang di-edit user (support markdown)
|
||||||
val timestamp: Long = System.currentTimeMillis(),
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
val isDeleted: Boolean = false
|
val isDeleted: Boolean = false
|
||||||
)
|
)
|
||||||
|
|||||||
@ -38,6 +38,16 @@ import com.example.notesai.presentation.screens.ai.components.CompactStatItem
|
|||||||
import com.example.notesai.presentation.screens.ai.components.SuggestionChip
|
import com.example.notesai.presentation.screens.ai.components.SuggestionChip
|
||||||
import com.example.notesai.util.AppColors
|
import com.example.notesai.util.AppColors
|
||||||
|
|
||||||
|
private const val MAX_CHAT_TITLE_LENGTH = 30
|
||||||
|
|
||||||
|
private fun String.toSafeChatPreview(maxLength: Int = MAX_CHAT_TITLE_LENGTH): String {
|
||||||
|
return if (this.length > maxLength) {
|
||||||
|
this.take(maxLength).trim() + "..."
|
||||||
|
} else {
|
||||||
|
this.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AIHelperScreen(
|
fun AIHelperScreen(
|
||||||
@ -92,9 +102,7 @@ fun AIHelperScreen(
|
|||||||
if (chatMessages.isNotEmpty()) {
|
if (chatMessages.isNotEmpty()) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val lastMessage = chatMessages.lastOrNull()?.message ?: ""
|
val lastMessage = chatMessages.lastOrNull()?.message ?: ""
|
||||||
val preview = if (lastMessage.length > 100) {
|
val preview = lastMessage.toSafeChatPreview()
|
||||||
lastMessage.take(100) + "..."
|
|
||||||
} else lastMessage
|
|
||||||
|
|
||||||
val chatHistory = ChatHistory(
|
val chatHistory = ChatHistory(
|
||||||
id = currentChatId ?: UUID.randomUUID().toString(),
|
id = currentChatId ?: UUID.randomUUID().toString(),
|
||||||
@ -102,6 +110,7 @@ fun AIHelperScreen(
|
|||||||
categoryName = selectedCategory?.name ?: "Semua Kategori",
|
categoryName = selectedCategory?.name ?: "Semua Kategori",
|
||||||
messages = chatMessages.map { it.toSerializable() },
|
messages = chatMessages.map { it.toSerializable() },
|
||||||
lastMessagePreview = preview,
|
lastMessagePreview = preview,
|
||||||
|
customTitle = null,
|
||||||
timestamp = System.currentTimeMillis()
|
timestamp = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -555,7 +564,12 @@ fun AIHelperScreen(
|
|||||||
onCategorySelected = { category ->
|
onCategorySelected = { category ->
|
||||||
selectedCategory = category
|
selectedCategory = category
|
||||||
},
|
},
|
||||||
onNewChat = { startNewChat() }
|
onNewChat = { startNewChat() },
|
||||||
|
onEditHistoryTitle = { historyId, newTitle ->
|
||||||
|
scope.launch {
|
||||||
|
dataStoreManager.updateChatHistoryTitle(historyId, newTitle)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -499,10 +499,10 @@ private fun CompactChatHistoryItem(
|
|||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(3.dp)
|
verticalArrangement = Arrangement.spacedBy(3.dp)
|
||||||
) {
|
) {
|
||||||
// Preview Text with Markdown
|
// Preview Text with Markdown (use customTitle if available)
|
||||||
Text(
|
Text(
|
||||||
text = buildAnnotatedString {
|
text = buildAnnotatedString {
|
||||||
appendInlineMarkdown(history.lastMessagePreview)
|
appendInlineMarkdown(history.customTitle ?: history.lastMessagePreview)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AppColors.OnSurface,
|
color = AppColors.OnSurface,
|
||||||
@ -709,7 +709,11 @@ private fun CompactChatHistoryItem(
|
|||||||
|
|
||||||
// Edit Dialog
|
// Edit Dialog
|
||||||
if (showEditDialog) {
|
if (showEditDialog) {
|
||||||
var editTitle by remember { mutableStateOf(history.lastMessagePreview.take(50)) }
|
val maxTitleLength = 30
|
||||||
|
var editTitle by remember {
|
||||||
|
val currentTitle = history.customTitle ?: history.lastMessagePreview
|
||||||
|
mutableStateOf(currentTitle.take(maxTitleLength))
|
||||||
|
}
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showEditDialog = false },
|
onDismissRequest = { showEditDialog = false },
|
||||||
@ -730,40 +734,105 @@ private fun CompactChatHistoryItem(
|
|||||||
text = {
|
text = {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
"Ubah judul untuk memudahkan pencarian",
|
"Maks 30 karakter • Gunakan markdown",
|
||||||
fontSize = 13.sp,
|
fontSize = 11.sp,
|
||||||
color = AppColors.OnSurfaceVariant
|
color = AppColors.OnSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = editTitle,
|
value = editTitle,
|
||||||
onValueChange = { editTitle = it },
|
onValueChange = {
|
||||||
|
if (it.length <= maxTitleLength) {
|
||||||
|
editTitle = it
|
||||||
|
}
|
||||||
|
},
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
"Judul Chat",
|
"Judul Chat",
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
"**Analisis** Data",
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingText = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"${editTitle.length}/$maxTitleLength",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = when {
|
||||||
|
editTitle.length >= maxTitleLength -> AppColors.Error
|
||||||
|
editTitle.length >= maxTitleLength - 5 -> AppColors.Primary
|
||||||
|
else -> AppColors.OnSurfaceTertiary
|
||||||
|
},
|
||||||
|
fontWeight = if (editTitle.length >= maxTitleLength - 5)
|
||||||
|
FontWeight.Bold
|
||||||
|
else
|
||||||
|
FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedBorderColor = AppColors.Primary,
|
focusedBorderColor = if (editTitle.length >= maxTitleLength)
|
||||||
|
AppColors.Error
|
||||||
|
else
|
||||||
|
AppColors.Primary,
|
||||||
|
unfocusedBorderColor = if (editTitle.length >= maxTitleLength)
|
||||||
|
AppColors.Error.copy(alpha = 0.5f)
|
||||||
|
else
|
||||||
|
Color.Gray,
|
||||||
cursorColor = AppColors.Primary
|
cursorColor = AppColors.Primary
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(8.dp),
|
shape = RoundedCornerShape(8.dp),
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp)
|
textStyle = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
if (editTitle.isNotBlank()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Preview:",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = AppColors.OnSurfaceTertiary,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Surface(
|
||||||
|
color = AppColors.SurfaceVariant,
|
||||||
|
shape = RoundedCornerShape(6.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = buildAnnotatedString {
|
||||||
|
appendInlineMarkdown(editTitle)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (editTitle.isNotBlank()) {
|
if (editTitle.isNotBlank()) {
|
||||||
onEdit(editTitle)
|
onEdit(editTitle.trim())
|
||||||
showEditDialog = false
|
showEditDialog = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
enabled = editTitle.isNotBlank() && editTitle.trim().isNotEmpty(),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = AppColors.Primary
|
containerColor = AppColors.Primary,
|
||||||
|
disabledContainerColor = AppColors.OnSurfaceVariant.copy(alpha = 0.3f)
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(8.dp)
|
shape = RoundedCornerShape(8.dp)
|
||||||
) {
|
) {
|
||||||
@ -883,79 +952,80 @@ private fun groupHistoriesByDate(histories: List<ChatHistory>): Map<String, List
|
|||||||
|
|
||||||
// Parse inline markdown for chat history titles
|
// Parse inline markdown for chat history titles
|
||||||
private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
|
private fun AnnotatedString.Builder.appendInlineMarkdown(text: String) {
|
||||||
|
var currentIndex = 0
|
||||||
var processedText = text
|
var processedText = text
|
||||||
val styles = mutableListOf<Triple<IntRange, SpanStyle, String>>()
|
|
||||||
|
|
||||||
// Pattern untuk bold dengan **
|
// List of patterns to process
|
||||||
val boldPattern = Regex("""\*\*(.+?)\*\*""")
|
val patterns = listOf(
|
||||||
boldPattern.findAll(processedText).forEach { match ->
|
// Bold with **
|
||||||
styles.add(
|
Regex("""\*\*(.+?)\*\*""") to { content: String ->
|
||||||
Triple(
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
match.range,
|
|
||||||
SpanStyle(fontWeight = FontWeight.Bold),
|
|
||||||
match.groupValues[1]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern untuk italic dengan *
|
|
||||||
val italicPattern = Regex("""\*(?!\*)(.+?)\*(?!\*)""")
|
|
||||||
italicPattern.findAll(processedText).forEach { match ->
|
|
||||||
if (!processedText.substring(maxOf(0, match.range.first - 1), match.range.first).contains("*")) {
|
|
||||||
styles.add(
|
|
||||||
Triple(
|
|
||||||
match.range,
|
|
||||||
SpanStyle(fontStyle = FontStyle.Italic),
|
|
||||||
match.groupValues[1]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern untuk inline code dengan `
|
|
||||||
val codePattern = Regex("""`(.+?)`""")
|
|
||||||
codePattern.findAll(processedText).forEach { match ->
|
|
||||||
styles.add(
|
|
||||||
Triple(
|
|
||||||
match.range,
|
|
||||||
SpanStyle(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
background = AppColors.Primary.copy(alpha = 0.15f),
|
|
||||||
fontSize = 12.sp
|
|
||||||
),
|
|
||||||
match.groupValues[1]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern untuk strikethrough dengan ~~
|
|
||||||
val strikePattern = Regex("""~~(.+?)~~""")
|
|
||||||
strikePattern.findAll(processedText).forEach { match ->
|
|
||||||
styles.add(
|
|
||||||
Triple(
|
|
||||||
match.range,
|
|
||||||
SpanStyle(textDecoration = TextDecoration.LineThrough),
|
|
||||||
match.groupValues[1]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by range start
|
|
||||||
val sortedStyles = styles.sortedBy { it.first.first }
|
|
||||||
|
|
||||||
var lastIndex = 0
|
|
||||||
sortedStyles.forEach { (range, style, content) ->
|
|
||||||
// Append text before styled content
|
|
||||||
if (range.first > lastIndex) {
|
|
||||||
append(processedText.substring(lastIndex, range.first))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append styled content
|
|
||||||
withStyle(style) {
|
|
||||||
append(content)
|
append(content)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// Inline code with `
|
||||||
|
Regex("""`(.+?)`""") to { content: String ->
|
||||||
|
withStyle(
|
||||||
|
SpanStyle(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
background = AppColors.Primary.copy(alpha = 0.15f)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Italic with * (but not **)
|
||||||
|
Regex("""\*([^*]+?)\*""") to { content: String ->
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Strikethrough with ~~
|
||||||
|
Regex("""~~(.+?)~~""") to { content: String ->
|
||||||
|
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||||
|
append(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
lastIndex = range.last + 1
|
// Collect all matches
|
||||||
|
data class Match(val start: Int, val end: Int, val content: String, val applier: (String) -> Unit)
|
||||||
|
val matches = mutableListOf<Match>()
|
||||||
|
|
||||||
|
patterns.forEach { (regex, applier) ->
|
||||||
|
regex.findAll(processedText).forEach { matchResult ->
|
||||||
|
val content = matchResult.groupValues[1]
|
||||||
|
matches.add(
|
||||||
|
Match(
|
||||||
|
start = matchResult.range.first,
|
||||||
|
end = matchResult.range.last + 1,
|
||||||
|
content = content,
|
||||||
|
applier = applier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort matches by start position and remove overlaps
|
||||||
|
val sortedMatches = matches
|
||||||
|
.sortedBy { it.start }
|
||||||
|
.fold(mutableListOf<Match>()) { acc, match ->
|
||||||
|
if (acc.isEmpty() || match.start >= acc.last().end) {
|
||||||
|
acc.add(match)
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the annotated string
|
||||||
|
var lastIndex = 0
|
||||||
|
sortedMatches.forEach { match ->
|
||||||
|
// Append text before match
|
||||||
|
if (match.start > lastIndex) {
|
||||||
|
append(processedText.substring(lastIndex, match.start))
|
||||||
|
}
|
||||||
|
// Apply style
|
||||||
|
match.applier(match.content)
|
||||||
|
lastIndex = match.end
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append remaining text
|
// Append remaining text
|
||||||
Loading…
x
Reference in New Issue
Block a user