diff --git a/ATPROTO.md b/ATPROTO.md index 4a41c1b..88d648f 100644 --- a/ATPROTO.md +++ b/ATPROTO.md @@ -70,6 +70,8 @@ Key strategy: literal rkey `self`. In the lexicon this must be declared as `"key `profileImage`, `partnerImage`, `courseImage`, and `classImage` are uploaded from the SEGA CDN. `trophyPlateImage` and `ratingPlateImage` are bundled Derakkuma maimai UI assets uploaded from `composeResources/files/maimai/`, derived from `titleRarity` and the parsed rating plate name. Consumers should render the blobs when available and can use `titleRarity` as a semantic fallback. +`friendCode` is optional and only published after the user explicitly confirms the separate friend-code publish setting. Consumers must treat it as absent by default. + ## Catalog graph ```text @@ -78,6 +80,8 @@ com.derakkuma.chart --song strongRef-------> com.derakkuma.song com.derakkuma.play --chart strongRef------> com.derakkuma.chart com.derakkuma.best --chart strongRef------> com.derakkuma.chart com.derakkuma.favoriteSong --song strongRef-> com.derakkuma.song +com.derakkuma.circleMember --circle AT URI-> com.derakkuma.circle +com.derakkuma.circleMember --profile AT URI-> com.derakkuma.profile (only when resolved, e.g. self) ``` To avoid circular CID churn, `chart.song` is the canonical strong reference, while `song.charts` is a convenience array of chart AT URIs. Consumers should trust `chart.song` if the two directions ever disagree. @@ -241,6 +245,81 @@ A user's favorite-song list entry. Favorite records do **not** embed song metada } ``` +### `com.derakkuma.circle` + +A snapshot of the user's current maimai DX circle. Circle ranking fields such as rank and total points live directly on this record; there is no separate `com.derakkuma.circleRanking` record. + +Key strategy: deterministic opaque `circle`. The circle code/name may be used locally as stable rkey input, but the hash input is not published as `circleKey`. + +```json +{ + "$type": "com.derakkuma.circle", + "circleCode": "A1B2C3D4", + "name": "でらっくま部", + "ownerName": "でらっくま", + "comment": "よろしくお願いします", + "tags": ["enjoy", "rating"], + "characterImage": { + "$type": "blob", + "ref": { "$link": "..." }, + "mimeType": "image/png", + "size": 12345 + }, + "backgroundImage": { + "$type": "blob", + "ref": { "$link": "..." }, + "mimeType": "image/png", + "size": 12345 + }, + "month": "2026-05", + "totalPoints": 123456, + "daysUntilReset": 12, + "rank": 42, + "nextRewardPoints": 5000, + "festaMessage": "FESTA開催中", + "observedAt": "2026-05-22T00:00:00Z", + "createdAt": "2026-05-22T00:00:00Z", + "updatedAt": "2026-05-22T00:00:00Z" +} +``` + +`circleCode` is optional and only published after the user explicitly confirms the separate circle-code publish setting, mirroring the profile friend-code opt-in. Consumers must treat it as absent by default. + +There is intentionally no `circleKey` field. `circleKey` used to duplicate `circleCode` and should not be published. + +### `com.derakkuma.circleMember` + +A snapshot of a member in a maimai DX circle. Member records link back to the parent circle record via `circle`; they do **not** copy the circle's code or any synthetic circle key. + +Key strategy: `self` for the publisher's own member record, deterministic opaque `cm` for other visible members. + +```json +{ + "$type": "com.derakkuma.circleMember", + "circle": "at://did:plc:userdid/com.derakkuma.circle/circle...", + "subject": "did:plc:userdid", + "profile": "at://did:plc:userdid/com.derakkuma.profile/self", + "displayName": "でらっくま", + "title": "ちほーの旅人", + "rating": 12345, + "points": 1234, + "status": "owner", + "icon": { + "$type": "blob", + "ref": { "$link": "..." }, + "mimeType": "image/png", + "size": 12345 + }, + "observedAt": "2026-05-22T00:00:00Z", + "createdAt": "2026-05-22T00:00:00Z", + "updatedAt": "2026-05-22T00:00:00Z" +} +``` + +`circle` is required and is the AT URI of the `com.derakkuma.circle` record this member belongs to. `profile` is optional and should only be present when the member is resolved to a Derakkuma ATProto profile, such as the publisher's own `self` member. `subject` is the resolved DID when known. + +`circleMember` must never publish `circleCode` or `circleKey`. Circle code visibility is controlled only by the parent `com.derakkuma.circle.circleCode` opt-in. + ## Chart resolution in the app Before publishing `play` or `best`, the app resolves DX NET scraped data to a catalog chart by: @@ -263,6 +342,7 @@ play.chart.uri -> com.derakkuma.chart chart.song.uri -> com.derakkuma.song best.chart.uri -> com.derakkuma.chart chart.song.uri -> com.derakkuma.song +circleMember.circle -> com.derakkuma.circle ``` - `com.derakkuma.getFriendsPlays` returns recent plays from the user's Derakkuma friend graph, enriched with profile/chart/song metadata. diff --git a/README.md b/README.md index 55a2b7f..0653235 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,8 @@ User records: | `com.derakkuma.best` | deterministic opaque `best` | one mutable best per canonical chart; strong-references `com.derakkuma.chart` | | `com.derakkuma.friend` | deterministic opaque `friend` | friend/detail idx is only used as private hash salt; never published raw | | `com.derakkuma.favoriteSong` | deterministic opaque `fav` | current favorite list; strong-references `com.derakkuma.song`; unfavorited songs are deleted remotely | -| `com.derakkuma.circle` | deterministic opaque `circle` | current circle snapshot; includes your circle rank/points | -| `com.derakkuma.circleMember` | `self` for your member record, deterministic opaque member key for others | self member links back to `at:///com.derakkuma.profile/self` | +| `com.derakkuma.circle` | deterministic opaque `circle` | current circle snapshot; includes your circle rank/points; circle code is optional and separately confirmed | +| `com.derakkuma.circleMember` | `self` for your member record, deterministic opaque member key for others | member records link back to their parent `com.derakkuma.circle`; self member also links to `at:///com.derakkuma.profile/self` | The catalog graph is: @@ -234,7 +234,7 @@ HappyView joins user records through the catalog for social queries like friends - [x] Circle Members - [x] Parse - [x] UI - - [x] ATProto (`self` member backlinks to profile) + - [x] ATProto (members backlink to their circle; `self` member backlinks to profile) - [x] Search Circles - [x] Parse - [x] UI diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 203a6b4..bdbe7dc 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -74,8 +74,8 @@ android { applicationId = "com.derakkuma" minSdk = 26 targetSdk = 36 - versionCode = 12 - versionName = "0.4.3" + versionCode = 13 + versionName = "0.4.4" } compileOptions { diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoModels.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoModels.kt index 78a17eb..d4abbed 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoModels.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoModels.kt @@ -135,7 +135,6 @@ data class AtFavoriteSongRecord( @Serializable data class AtCircleRecord( - val circleKey: String = "", val circleCode: String = "", val name: String = "", val ownerName: String = "", @@ -156,8 +155,7 @@ data class AtCircleRecord( @Serializable data class AtCircleMemberRecord( - val circleKey: String = "", - val circleCode: String = "", + val circle: String = "", val subject: String = "", val profile: String = "", val displayName: String = "", @@ -190,6 +188,7 @@ data class AtProtoPublishSettings( val publishFriends: Boolean = false, val publishFavoriteSongs: Boolean = false, val publishCircles: Boolean = false, + val publishCircleCode: Boolean = false, val backgroundSyncEnabled: Boolean = true, ) diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoSettings.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoSettings.kt index b70600b..c5aeda1 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoSettings.kt @@ -124,6 +124,7 @@ fun AtProtoSection( var syncing by remember { mutableStateOf(false) } var syncProgress by remember { mutableStateOf(null) } var showFriendCodePublishConfirm by remember { mutableStateOf(false) } + var showCircleCodePublishConfirm by remember { mutableStateOf(false) } var configExpanded by remember { mutableStateOf(false) } val happyView = remember { HappyViewSocialClient() } var socialLoading by remember { mutableStateOf(false) } @@ -301,9 +302,22 @@ fun AtProtoSection( cacheStore.putAtProtoPublishSettings(settings) } PublishToggle("Circles", "Circle profile, your rank, members", settings.publishCircles) { checked -> - settings = settings.copy(publishCircles = checked) + settings = settings.copy(publishCircles = checked, publishCircleCode = if (checked) settings.publishCircleCode else false) cacheStore.putAtProtoPublishSettings(settings) } + PublishToggle( + "Circle Code", + "Add your maimai circle code to the public circle record", + settings.publishCircleCode && settings.publishCircles, + enabled = settings.publishCircles, + ) { checked -> + if (checked) { + showCircleCodePublishConfirm = true + } else { + settings = settings.copy(publishCircleCode = false) + cacheStore.putAtProtoPublishSettings(settings) + } + } PublishToggle("Background Sync", "Sync enabled ATProto records about every 12 hours", settings.backgroundSyncEnabled) { checked -> settings = settings.copy(backgroundSyncEnabled = checked) @@ -560,6 +574,26 @@ fun AtProtoSection( }, ) } + + if (showCircleCodePublishConfirm) { + ExpressiveAlertDialog( + onDismissRequest = { showCircleCodePublishConfirm = false }, + title = { Text("Publish circle code?") }, + text = { Text("This will add your maimai circle code to your public AT Protocol circle record, which lets other apps or players discover the circle. Even if you remove it later, other apps or indexers may have already copied it.") }, + confirmButton = { + ExpressiveTextButton( + onClick = { + settings = settings.copy(publishCircles = true, publishCircleCode = true) + cacheStore.putAtProtoPublishSettings(settings) + showCircleCodePublishConfirm = false + }, + ) { Text("Publish") } + }, + dismissButton = { + ExpressiveTextButton(onClick = { showCircleCodePublishConfirm = false }) { Text("Cancel") } + }, + ) + } } /** Platform-specific OAuth URL opener. Android uses Chrome Custom Tabs, iOS uses ASWebAuthenticationSession. */ diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoSync.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoSync.kt index a21a807..cc253ac 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoSync.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/atproto/AtProtoSync.kt @@ -266,7 +266,7 @@ class AtProtoSync( suspend fun syncCircle(home: CircleHome): Boolean { if (!settings.publishCircles || client.account == null || home.name.isBlank()) return false - val key = home.circleKey() + val rkey = home.circleRkey() val characterImage = uploadImageBlob(home.characterImageUrl) val backgroundImage = uploadImageBlob(home.backgroundImageUrl) if (!home.characterImageUrl.isNullOrBlank() && characterImage == null) return false @@ -274,8 +274,9 @@ class AtProtoSync( val now = Clock.System.now().toString() val record = buildJsonObject { put("\$type", "com.derakkuma.circle") - put("circleKey", key) - home.code.takeIf { it.isNotBlank() }?.let { put("circleCode", it) } + if (settings.publishCircleCode) { + home.code.takeIf { it.isNotBlank() }?.let { put("circleCode", it) } + } put("name", home.name) home.ownerName.takeIf { it.isNotBlank() }?.let { put("ownerName", it) } home.comment.takeIf { it.isNotBlank() }?.let { put("comment", it) } @@ -292,7 +293,6 @@ class AtProtoSync( put("createdAt", syncState.lastSyncTime.ifEmpty { now }) put("updatedAt", now) } - val rkey = deterministicRkey("circle", key) val ok = client.putRecord("com.derakkuma.circle", rkey, record) if (!ok) cacheStore?.enqueueAtProtoWrite(PendingAtProtoWrite("put", "com.derakkuma.circle", rkey, record)) return ok @@ -301,19 +301,19 @@ class AtProtoSync( suspend fun syncCircleMembers(home: CircleHome, membersPage: CircleMembersPage): Int { val account = client.account ?: return 0 if (!settings.publishCircles) return 0 - val key = home.circleKey() + val circleRkey = home.circleRkey() + val circleUri = "at://${account.did}/com.derakkuma.circle/$circleRkey" val profile = cacheStore?.getProfileForce() var published = 0 - for (member in membersPage.members.distinctBy { it.circleMemberRkey(key, profile) }) { + for (member in membersPage.members.distinctBy { it.circleMemberRkey(circleRkey, profile) }) { val isSelf = profile != null && member.name == profile.name && (member.rating == profile.rating || member.title == profile.title) if ( syncCircleMember( - circleKey = key, - circleCode = home.code, + circleUri = circleUri, member = member, subjectDid = if (isSelf) account.did else null, profileUri = if (isSelf) "at://${account.did}/com.derakkuma.profile/self" else null, - rkey = member.circleMemberRkey(key, profile), + rkey = member.circleMemberRkey(circleRkey, profile), ) ) { published += 1 @@ -323,8 +323,7 @@ class AtProtoSync( } private suspend fun syncCircleMember( - circleKey: String, - circleCode: String, + circleUri: String, member: CircleMember, subjectDid: String? = null, profileUri: String? = null, @@ -334,11 +333,10 @@ class AtProtoSync( val icon = uploadImageBlob(member.iconUrl) if (!member.iconUrl.isNullOrBlank() && icon == null) return false val now = Clock.System.now().toString() - val memberRkey = rkey ?: deterministicRkey("cm", "$circleKey|${member.name}|${member.title}|${member.rating}") + val memberRkey = rkey ?: deterministicRkey("cm", "$circleUri|${member.name}|${member.title}|${member.rating}") val record = buildJsonObject { put("\$type", "com.derakkuma.circleMember") - put("circleKey", circleKey) - circleCode.takeIf { it.isNotBlank() }?.let { put("circleCode", it) } + put("circle", circleUri) subjectDid?.takeIf { it.isNotBlank() }?.let { put("subject", it) } profileUri?.takeIf { it.isNotBlank() }?.let { put("profile", it) } put("displayName", member.name) @@ -356,7 +354,7 @@ class AtProtoSync( return ok } - private fun CircleHome.circleKey(): String = code.ifBlank { name }.ifBlank { "circle" } + private fun CircleHome.circleRkey(): String = deterministicRkey("circle", code.ifBlank { name }.ifBlank { "circle" }) private fun CircleMember.circleMemberIdentityKey(profile: Profile?): String = if (profile != null && name == profile.name && (rating == profile.rating || title == profile.title)) { "self" @@ -367,7 +365,7 @@ class AtProtoSync( name.trim() } - private fun CircleMember.circleMemberRkey(circleKey: String, profile: Profile?): String = if (circleMemberIdentityKey(profile) == "self") "self" else deterministicRkey("cm", "$circleKey|${circleMemberIdentityKey(profile)}") + private fun CircleMember.circleMemberRkey(circleRkey: String, profile: Profile?): String = if (circleMemberIdentityKey(profile) == "self") "self" else deterministicRkey("cm", "$circleRkey|${circleMemberIdentityKey(profile)}") suspend fun playDedupeKey(play: RecentPlay): String? = resolveChartOrReport(play.name, play.difficulty, play.isDx, play.level)?.let { playDedupeKey(play.datetime, it) } diff --git a/lexicons/com.derakkuma.circle.json b/lexicons/com.derakkuma.circle.json index bd37ba7..2d1a0b7 100644 --- a/lexicons/com.derakkuma.circle.json +++ b/lexicons/com.derakkuma.circle.json @@ -9,15 +9,10 @@ "record": { "type": "object", "required": [ - "circleKey", "name", "createdAt" ], "properties": { - "circleKey": { - "type": "string", - "maxLength": 128 - }, "circleCode": { "type": "string", "maxLength": 64 diff --git a/lexicons/com.derakkuma.circleMember.json b/lexicons/com.derakkuma.circleMember.json index 9ead2b6..6baf0ae 100644 --- a/lexicons/com.derakkuma.circleMember.json +++ b/lexicons/com.derakkuma.circleMember.json @@ -4,23 +4,20 @@ "defs": { "main": { "type": "record", - "description": "A member snapshot for a maimai DX circle, optionally resolved to an AT Protocol identity", + "description": "A member snapshot for a maimai DX circle, linked to the circle record it belongs to and optionally resolved to an AT Protocol identity", "key": "any", "record": { "type": "object", "required": [ - "circleKey", + "circle", "displayName", "createdAt" ], "properties": { - "circleKey": { + "circle": { "type": "string", - "maxLength": 128 - }, - "circleCode": { - "type": "string", - "maxLength": 64 + "format": "at-uri", + "description": "AT URI of the com.derakkuma.circle record this member belongs to." }, "subject": { "type": "string", diff --git a/lexicons/schema-records/com.derakkuma.circle.json b/lexicons/schema-records/com.derakkuma.circle.json index 56b58ea..4383fa9 100644 --- a/lexicons/schema-records/com.derakkuma.circle.json +++ b/lexicons/schema-records/com.derakkuma.circle.json @@ -10,15 +10,10 @@ "record": { "type": "object", "required": [ - "circleKey", "name", "createdAt" ], "properties": { - "circleKey": { - "type": "string", - "maxLength": 128 - }, "circleCode": { "type": "string", "maxLength": 64 diff --git a/lexicons/schema-records/com.derakkuma.circleMember.json b/lexicons/schema-records/com.derakkuma.circleMember.json index ab39417..db22f04 100644 --- a/lexicons/schema-records/com.derakkuma.circleMember.json +++ b/lexicons/schema-records/com.derakkuma.circleMember.json @@ -5,23 +5,20 @@ "defs": { "main": { "type": "record", - "description": "A member snapshot for a maimai DX circle, optionally resolved to an AT Protocol identity", + "description": "A member snapshot for a maimai DX circle, linked to the circle record it belongs to and optionally resolved to an AT Protocol identity", "key": "any", "record": { "type": "object", "required": [ - "circleKey", + "circle", "displayName", "createdAt" ], "properties": { - "circleKey": { + "circle": { "type": "string", - "maxLength": 128 - }, - "circleCode": { - "type": "string", - "maxLength": 64 + "format": "at-uri", + "description": "AT URI of the com.derakkuma.circle record this member belongs to." }, "subject": { "type": "string",