feat: auto-load OpenGraph data when adding web bookmarks

When creating a new web bookmark, automatically fetches OpenGraph
metadata (title, description) after the user leaves the URL field.
Only populates fields that are still empty, preserving any user input.

https://claude.ai/code/session_01RteQotL6WHqJtDr38fUHan
This commit is contained in:
Claude
2026-03-27 13:28:41 +00:00
parent 511fcb4c2a
commit c0a8da0e0c
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -52,9 +54,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -65,6 +69,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
@@ -80,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
import kotlinx.coroutines.launch
@Composable @Composable
fun WebBookmarksScreen( fun WebBookmarksScreen(
@@ -102,6 +108,7 @@ private fun RenderWebBookmarksScreen(
if (showAddDialog) { if (showAddDialog) {
WebBookmarkEditDialog( WebBookmarkEditDialog(
accountViewModel = accountViewModel,
onDismiss = { showAddDialog = false }, onDismiss = { showAddDialog = false },
onSave = { url, title, description, tags -> onSave = { url, title, description, tags ->
accountViewModel.launchSigner { accountViewModel.launchSigner {
@@ -193,6 +200,7 @@ private fun WebBookmarkCard(
if (showEditDialog) { if (showEditDialog) {
WebBookmarkEditDialog( WebBookmarkEditDialog(
accountViewModel = accountViewModel,
initialUrl = event.url(), initialUrl = event.url(),
initialTitle = event.title() ?: "", initialTitle = event.title() ?: "",
initialDescription = event.description(), initialDescription = event.description(),
@@ -312,6 +320,7 @@ private fun WebBookmarkCard(
@Composable @Composable
fun WebBookmarkEditDialog( fun WebBookmarkEditDialog(
accountViewModel: AccountViewModel,
initialUrl: String = "", initialUrl: String = "",
initialTitle: String = "", initialTitle: String = "",
initialDescription: String = "", initialDescription: String = "",
@@ -323,8 +332,38 @@ fun WebBookmarkEditDialog(
var title by remember { mutableStateOf(initialTitle) } var title by remember { mutableStateOf(initialTitle) }
var description by remember { mutableStateOf(initialDescription) } var description by remember { mutableStateOf(initialDescription) }
var tags by remember { mutableStateOf(initialTags) } var tags by remember { mutableStateOf(initialTags) }
var isLoadingPreview by remember { mutableStateOf(false) }
var lastFetchedUrl by remember { mutableStateOf(initialUrl) }
val isEditing = initialUrl.isNotEmpty() val isEditing = initialUrl.isNotEmpty()
val scope = rememberCoroutineScope()
fun fetchOpenGraphData(urlToFetch: String) {
if (urlToFetch.isBlank() || urlToFetch == lastFetchedUrl) return
val normalizedUrl = if (!urlToFetch.startsWith("http")) "https://$urlToFetch" else urlToFetch
lastFetchedUrl = urlToFetch
isLoadingPreview = true
accountViewModel.urlPreview(normalizedUrl) { state ->
scope.launch {
when (state) {
is UrlPreviewState.Loaded -> {
val info = state.previewInfo
if (title.isBlank() && info.title.isNotBlank()) {
title = info.title
}
if (description.isBlank() && info.description.isNotBlank()) {
description = info.description
}
}
else -> {}
}
isLoadingPreview = false
}
}
}
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -343,7 +382,22 @@ fun WebBookmarkEditDialog(
label = { Text(stringResource(R.string.web_bookmark_url_label)) }, label = { Text(stringResource(R.string.web_bookmark_url_label)) },
placeholder = { Text(stringResource(R.string.web_bookmark_url_placeholder)) }, placeholder = { Text(stringResource(R.string.web_bookmark_url_placeholder)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier =
Modifier
.fillMaxWidth()
.onFocusChanged { focusState ->
if (!focusState.isFocused && url.isNotBlank()) {
fetchOpenGraphData(url)
}
},
trailingIcon = {
if (isLoadingPreview) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
)
}
},
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))