feat(concord): add mobile foundation enums (membership + view mode)

First slice of the Android integration, mirroring NIP-29's commons model:

- ConcordMembership (OWNER/ADMIN/MEMBER/BANNED/NONE) derived from the folded
  owner-rooted AuthorityResolver + banlist — membership is key possession, roles
  layer moderation power, the banlist removes standing; with isMember/canModerate
- ConcordViewMode (INLINE/GROUPED) for how joined channels surface in Messages

Test classifies owner/admin/member/banned/stranger from a folded control plane.
Green on :commons:jvmTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-10 17:19:36 +00:00
parent b816842e5d
commit 4672b60c9d
3 changed files with 198 additions and 0 deletions
@@ -0,0 +1,87 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A user's standing in a Concord community, derived from the locally-folded
* Control Plane (the owner-rooted [AuthorityResolver] + banlist).
*
* Unlike NIP-29 (where the relay's signed roster is the truth), Concord
* membership is **key possession**: anyone holding the community key is at least a
* [MEMBER]. Roles layer moderation power on top ([ADMIN]/[OWNER]); the banlist
* removes standing ([BANNED]). [NONE] is only for a user we can't place at all.
*/
enum class ConcordMembership {
/** The community founder — supreme, unremovable. */
OWNER,
/** Holds at least one moderation permission (manage roles/channels, kick, ban…). */
ADMIN,
/** Holds the community key, no elevated role. */
MEMBER,
/** On the community banlist — dropped everywhere. */
BANNED,
/** Not placeable in this community. */
NONE,
;
/** True when the user is an active participant (holds the key and isn't banned). */
fun isMember(): Boolean = this == OWNER || this == ADMIN || this == MEMBER
/** True when the user may take moderation actions. */
fun canModerate(): Boolean = this == OWNER || this == ADMIN
companion object {
private val MOD_BITS =
intArrayOf(
ConcordPermissions.MANAGE_ROLES,
ConcordPermissions.MANAGE_CHANNELS,
ConcordPermissions.MANAGE_METADATA,
ConcordPermissions.KICK,
ConcordPermissions.BAN,
ConcordPermissions.MANAGE_MESSAGES,
)
/**
* Classifies [pubKey] against a folded [authority]. [holdsKey] tells us the
* user is a member of this community locally (we joined it / hold its key),
* which distinguishes a plain [MEMBER] from [NONE].
*/
fun of(
authority: AuthorityResolver,
pubKey: HexKey,
holdsKey: Boolean = true,
): ConcordMembership {
if (authority.isBanned(pubKey)) return BANNED
if (authority.isOwner(pubKey)) return OWNER
val perms = authority.effectivePermissions(pubKey)
if (MOD_BITS.any { perms.has(it) }) return ADMIN
return if (holdsKey) MEMBER else NONE
}
}
}
@@ -0,0 +1,38 @@
/*
* 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.commons.model.concord
/**
* How joined Concord Channels surface in the Messages inbox (mirrors
* `RelayGroupViewMode`).
*/
enum class ConcordViewMode {
/** One inbox row per channel across all joined communities. */
INLINE,
/** One inbox row per community, collapsing its channels behind a drill-down. */
GROUPED,
;
companion object {
val DEFAULT = INLINE
}
}
@@ -0,0 +1,73 @@
/*
* 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.commons.model.concord
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import kotlin.test.Test
import kotlin.test.assertEquals
class ConcordMembershipTest {
private val owner = "0f".repeat(32)
private val admin = "a1".repeat(32)
private val member = "b2".repeat(32)
private val banned = "c3".repeat(32)
private val stranger = "d4".repeat(32)
private val adminRole = "11".repeat(32)
private fun ed(
kind: ControlEntityKind,
eid: String,
content: String,
author: String = owner,
) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0)
private val authority =
AuthorityResolver.resolve(
listOf(
ed(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), // KICK|BAN|MANAGE_ROLES
ed(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$admin","role_ids":["$adminRole"]}"""),
ed(ControlEntityKind.BANLIST, "44".repeat(32), """["$banned"]"""),
),
owner,
)
@Test
fun classifiesStandingFromAuthority() {
assertEquals(ConcordMembership.OWNER, ConcordMembership.of(authority, owner))
assertEquals(ConcordMembership.ADMIN, ConcordMembership.of(authority, admin))
assertEquals(ConcordMembership.MEMBER, ConcordMembership.of(authority, member))
assertEquals(ConcordMembership.BANNED, ConcordMembership.of(authority, banned))
// A user we don't hold the key for reads as NONE.
assertEquals(ConcordMembership.NONE, ConcordMembership.of(authority, stranger, holdsKey = false))
}
@Test
fun capabilityHelpers() {
assertEquals(true, ConcordMembership.OWNER.canModerate())
assertEquals(true, ConcordMembership.ADMIN.canModerate())
assertEquals(false, ConcordMembership.MEMBER.canModerate())
assertEquals(true, ConcordMembership.MEMBER.isMember())
assertEquals(false, ConcordMembership.BANNED.isMember())
}
}