feat(desktop): profile header polish — banner, rich-text bio, CLINK offer field

- Render kind-0 banner image in the profile card header
- Render bio as rich text (mentions/hashtags/links) via DesktopRichText
- Add CLINK offer (noffer) field to Edit Profile + shared EditProfileFields

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-29 09:39:55 +03:00
co-authored by Claude Opus 4.8
parent b2b5bccec1
commit e862b3ecac
3 changed files with 52 additions and 5 deletions
@@ -54,6 +54,7 @@ class EditProfileFields {
val nip05 = MutableStateFlow("")
val lnAddress = MutableStateFlow("")
val lnURL = MutableStateFlow("")
val clinkOffer = MutableStateFlow("")
// Social proofs (NIP-39)
val twitter = MutableStateFlow("")
@@ -79,6 +80,7 @@ class EditProfileFields {
nip05.value = info.nip05 ?: ""
lnAddress.value = info.lud16 ?: ""
lnURL.value = info.lud06 ?: ""
clinkOffer.value = info.clinkOffer ?: ""
}
twitter.value = ""
@@ -114,6 +116,7 @@ class EditProfileFields {
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
clinkOffer.value = ""
twitter.value = ""
github.value = ""
mastodon.value = ""
@@ -132,6 +135,7 @@ class EditProfileFields {
"nip05" to nip05.value,
"lnAddress" to lnAddress.value,
"lnURL" to lnURL.value,
"clinkOffer" to clinkOffer.value,
"twitter" to twitter.value,
"github" to github.value,
"mastodon" to mastodon.value,
@@ -65,16 +65,21 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
@@ -89,6 +94,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscri
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.DesktopRichText
import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
@@ -149,6 +156,15 @@ fun UserProfileScreen(
)
}
var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) }
var banner by remember {
mutableStateOf(
cachedMetadata
?.flow
?.value
?.info
?.banner,
)
}
var nip05 by remember {
mutableStateOf(
cachedMetadata
@@ -334,6 +350,7 @@ fun UserProfileScreen(
displayName = metadata.displayName ?: metadata.name
about = metadata.about
picture = metadata.picture
banner = metadata.banner
nip05 = metadata.nip05
website = metadata.website
lnAddress = metadata.lnAddress()
@@ -735,6 +752,19 @@ fun UserProfileScreen(
),
) {
Column(modifier = Modifier.padding(16.dp)) {
banner?.takeIf { it.isNotBlank() }?.let { bannerUrl ->
AsyncImage(
model = bannerUrl,
contentDescription = "Profile banner",
contentScale = ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.height(120.dp)
.clip(MaterialTheme.shapes.medium),
)
Spacer(Modifier.height(12.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
@@ -798,12 +828,15 @@ fun UserProfileScreen(
}
}
if (about != null) {
about?.takeIf { it.isNotBlank() }?.let { bio ->
Spacer(Modifier.height(12.dp))
Text(
about!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
val bioState = remember(bio) { CachedRichTextParser.parseText(bio, EmptyTagList) }
DesktopRichText(
content = bio,
state = bioState,
localCache = localCache,
callbacks = RichTextCallbacks(onMentionClick = onNavigateToProfile),
modifier = Modifier.fillMaxWidth(),
)
}
@@ -232,6 +232,7 @@ fun EditProfileDialog(
nip05 = fields.nip05.value,
lnAddress = fields.lnAddress.value,
lnURL = fields.lnURL.value,
clinkOffer = fields.clinkOffer.value,
)
} else {
MetadataEvent.createNew(
@@ -245,6 +246,7 @@ fun EditProfileDialog(
nip05 = fields.nip05.value,
lnAddress = fields.lnAddress.value,
lnURL = fields.lnURL.value,
clinkOffer = fields.clinkOffer.value,
)
}
val signedMetadata = account.signer.sign(metadataTemplate)
@@ -374,6 +376,7 @@ fun EditProfileContent(
val nip05Value by fields.nip05.collectAsState()
val lnAddressValue by fields.lnAddress.collectAsState()
val lnURLValue by fields.lnURL.collectAsState()
val clinkOfferValue by fields.clinkOffer.collectAsState()
val twitterValue by fields.twitter.collectAsState()
val githubValue by fields.github.collectAsState()
val mastodonValue by fields.mastodon.collectAsState()
@@ -676,6 +679,13 @@ fun EditProfileContent(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = clinkOfferValue,
onValueChange = { fields.clinkOffer.value = it },
label = { Text("CLINK offer (noffer…)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Social Proofs — collapsible
SocialProofsSection(