Adds a way to view WoT scores

This commit is contained in:
Vitor Pamplona
2025-11-25 15:47:08 -05:00
parent fd628fffd2
commit f76638f077
7 changed files with 323 additions and 18 deletions
@@ -120,6 +120,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.dimension
import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent import com.vitorpamplona.quartz.experimental.profileGallery.fromEvent
import com.vitorpamplona.quartz.experimental.profileGallery.hash import com.vitorpamplona.quartz.experimental.profileGallery.hash
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType import com.vitorpamplona.quartz.experimental.profileGallery.mimeType
import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -208,12 +209,17 @@ import com.vitorpamplona.quartz.utils.containsAny
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
@@ -1719,6 +1725,25 @@ class Account(
).toSet() ).toSet()
} }
@OptIn(ExperimentalCoroutinesApi::class)
fun loadUserCardFlow(target: HexKey): Flow<Int?> =
trustProviderList.liveUserRankProvider
.transformLatest { provider ->
if (provider != null) {
emitAll(
cache
.getOrCreateAddressableNote(
ContactCardEvent.createAddress(provider.pubkey, target),
).flow()
.metadata.stateFlow,
)
} else {
emit(null)
}
}.map {
(it?.note?.event as? ContactCardEvent)?.rank()
}.flowOn(Dispatchers.IO)
suspend fun saveDMRelayList(dmRelays: List<NormalizedRelayUrl>) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays)) suspend fun saveDMRelayList(dmRelays: List<NormalizedRelayUrl>) = sendLiterallyEverywhere(dmRelayList.saveRelayList(dmRelays))
suspend fun savePrivateOutboxRelayList(relays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays)) suspend fun savePrivateOutboxRelayList(relays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(privateStorageRelayList.saveRelayList(relays))
@@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes
import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -81,15 +80,16 @@ class TrustProviderListState(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val liveUserRankProvider: StateFlow<ServiceProviderTag?> = val liveUserRankProvider: StateFlow<ServiceProviderTag?> =
liveTrustProviderList.map { liveTrustProviderList
it.firstOrNull { it.service == ProviderTypes.rank } .map {
}.onStart { it.firstOrNull { it.service == ProviderTypes.rank }
emit( }.onStart {
liveTrustProviderList.value.firstOrNull { emit(
it.service == ProviderTypes.rank liveTrustProviderList.value.firstOrNull {
} it.service == ProviderTypes.rank
) },
}.flowOn(Dispatchers.IO) )
}.flowOn(Dispatchers.IO)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
@@ -98,13 +98,14 @@ class TrustProviderListState(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val liveUserFollowerCount: StateFlow<ServiceProviderTag?> = val liveUserFollowerCount: StateFlow<ServiceProviderTag?> =
liveTrustProviderList.map { tagList -> liveTrustProviderList
tagList.firstOrNull { it.service == ProviderTypes.followerCount } .map { tagList ->
}.onStart { tagList.firstOrNull { it.service == ProviderTypes.followerCount }
emit( }.onStart {
liveTrustProviderList.value.firstOrNull { it.service == ProviderTypes.followerCount } emit(
) liveTrustProviderList.value.firstOrNull { it.service == ProviderTypes.followerCount },
}.flowOn(Dispatchers.IO) )
}.flowOn(Dispatchers.IO)
.stateIn( .stateIn(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserWatcherSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -46,6 +47,7 @@ class UserFinderFilterAssembler(
UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys), UserOutboxFinderSubAssembler(client, cache, failureTracker, ::allKeys),
UserWatcherSubAssembler(client, cache, ::allKeys), UserWatcherSubAssembler(client, cache, ::allKeys),
UserReportsSubAssembler(client, ::allKeys), UserReportsSubAssembler(client, ::allKeys),
UserCardsSubAssembler(client, ::allKeys),
) )
override fun invalidateFilters() = group.forEach { it.invalidateFilters() } override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
@@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
val ContactCardKindList = listOf(ContactCardEvent.KIND)
fun filterContactCardsToKeysFromTrusted(
targets: Set<HexKey>,
trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (targets.isEmpty() || trustedAccounts.isEmpty()) return emptyList()
val sortedTargets = mapOf("d" to targets.sorted())
return trustedAccounts.mapNotNull { relayAuthors ->
if (relayAuthors.value.isNotEmpty()) {
RelayBasedFilter(
relay = relayAuthors.key,
filter =
Filter(
kinds = ContactCardKindList,
authors = relayAuthors.value.sorted(),
tags = sortedTargets,
since = since?.get(relayAuthors.key)?.time,
),
)
} else {
null
}
}
}
@@ -0,0 +1,147 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.ammolite.relays.filters.MutableTime
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
class UserCardsSubAssembler(
client: INostrClient,
allKeys: () -> Set<UserFinderQueryState>,
) : SingleSubEoseManager<UserFinderQueryState>(client, allKeys) {
var lastUsersOnFilter: Set<User> = emptySet()
/**
* This assembler saves the EOSE per user key. That EOSE includes their metadata, etc
* and reports, but only from trusted accounts (follows of all logged in users).
*/
var latestEOSEs: EOSEAccountFast<User> = EOSEAccountFast<User>(2000)
override fun newEose(
relay: NormalizedRelayUrl,
time: Long,
filters: List<Filter>?,
) {
lastUsersOnFilter.forEach {
latestEOSEs.newEose(it, relay, time)
}
super.newEose(relay, time, filters)
}
override fun updateFilter(
keys: List<UserFinderQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (keys.isEmpty()) return null
lastUsersOnFilter = keys.mapTo(mutableSetOf()) { it.user }
if (lastUsersOnFilter.isEmpty()) return null
val accounts = keys.mapTo(mutableSetOf()) { it.account }
val trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>> =
mapOfSet {
accounts.forEach { account ->
account.outboxRelays.flow.value.map {
add(it, account.userProfile().pubkeyHex)
}
}
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { account ->
if (account != null) {
add(account.relayUrl, account.pubkey)
}
}
}
return groupByRelayPresence(lastUsersOnFilter, latestEOSEs, trustedAccounts.keys)
.map { group ->
val groupIds = group.map { it.pubkeyHex }.toSet()
if (groupIds.isNotEmpty()) {
val minEOSEs = findMinimumEOSEsForUsers(group, latestEOSEs)
filterContactCardsToKeysFromTrusted(groupIds, trustedAccounts, minEOSEs)
} else {
emptyList()
}
}.flatten()
}
fun groupByRelayPresence(
users: Iterable<User>,
eoseCache: EOSEAccountFast<User>,
inRelays: Set<NormalizedRelayUrl>,
): Collection<List<User>> {
if (users.none()) return emptyList()
val relaySnapshot = inRelays.toSet()
return users
.groupBy { user ->
val relaysForUser = eoseCache.sinceRelaySet(user)
if (relaysForUser.isNullOrEmpty() || relaySnapshot.isEmpty()) {
null
} else {
val intersection = relaysForUser.filter { it in relaySnapshot }.sorted()
if (intersection.isEmpty()) {
null
} else {
intersection.hashCode()
}
}
}.values
.map {
// important to keep in order otherwise the Relay thinks the filter has changed and we REQ again
it.sortedBy { it.pubkeyHex }
}
}
fun findMinimumEOSEsForUsers(
users: List<User>,
eoseCache: EOSEAccountFast<User>,
): SincePerRelayMap {
val minLatestEOSEs = mutableMapOf<NormalizedRelayUrl, MutableTime>()
users.forEach {
eoseCache.since(it)?.forEach {
val minEose = minLatestEOSEs[it.key]
if (minEose == null) {
minLatestEOSEs.put(it.key, it.value.copy())
} else {
minEose.updateIfOlder(it.value.time)
}
}
}
return minLatestEOSEs
}
override fun distinct(key: UserFinderQueryState) = key.user
}
@@ -21,19 +21,26 @@
package com.vitorpamplona.amethyst.ui.note package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
@@ -46,6 +53,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font10SP
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
@@ -354,6 +363,12 @@ fun BaseUserPicture(
FollowingIcon(Modifier.size(size.div(3.5f))) FollowingIcon(Modifier.size(size.div(3.5f)))
} }
} }
WatchUserCards(baseUser.pubkeyHex, accountViewModel) { score ->
if (score != null) {
ScoreTag(score, Modifier.align(Alignment.BottomCenter))
}
}
} }
} }
@@ -382,9 +397,41 @@ fun BaseUserPicture(
FollowingIcon(Modifier.size(size.div(3.5f))) FollowingIcon(Modifier.size(size.div(3.5f)))
} }
} }
WatchUserCards(baseUserHex, accountViewModel) { score ->
if (score != null) {
ScoreTag(score, Modifier.align(Alignment.BottomCenter))
}
}
} }
} }
@Preview
@Composable
fun ScoreTagPreview() {
Box(Modifier.size(55.dp), contentAlignment = Alignment.TopEnd) {
ScoreTag(100, Modifier.align(Alignment.BottomCenter))
}
}
@Composable
fun ScoreTag(
score: Int,
modifier: Modifier,
) {
Text(
text = score.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = Font10SP,
modifier =
modifier
.clip(SmallBorder)
.background(Color.Black)
.padding(horizontal = 5.dp),
)
}
@Composable @Composable
fun LoadUserProfilePicture( fun LoadUserProfilePicture(
baseUser: User, baseUser: User,
@@ -458,3 +505,19 @@ fun WatchUserFollows(
onFollowChanges(state.authors.contains(userHex)) onFollowChanges(state.authors.contains(userHex))
} }
} }
@Composable
fun WatchUserCards(
userHex: String,
accountViewModel: AccountViewModel,
onScoreChanges: @Composable (Int?) -> Unit,
) {
val flow =
remember(userHex) {
accountViewModel.account.loadUserCardFlow(userHex)
}
val score by flow.collectAsStateWithLifecycle(null)
onScoreChanges(score)
}
@@ -24,10 +24,12 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag
import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.core.tagArray
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
@@ -49,10 +51,20 @@ class ContactCardEvent(
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
companion object Companion { companion object {
const val KIND = 30382 const val KIND = 30382
const val ALT = "Contact Card" const val ALT = "Contact Card"
fun createAddress(
owner: HexKey,
target: HexKey,
): Address = Address(KIND, owner, target)
fun createAddressTag(
owner: HexKey,
target: HexKey,
): ATag = ATag(KIND, owner, target, null)
suspend fun create( suspend fun create(
targetUser: HexKey, targetUser: HexKey,
petName: String? = null, petName: String? = null,