From b49e7f8d70daa1ab817737f85447ae1dcc50adaa Mon Sep 17 00:00:00 2001 From: ketsuban Date: Sat, 29 Aug 2026 14:24:12 +0000 Subject: W2/W12/W14/W16: wasm runtime+config; CID block store (JVM+OPFS); msg_type numbering; UX overhaul (set model, pie menu nav, Create/Note/Calendar/Live, card notes, gid multi-membership chat transport); 3-peer E2E proven --- .../jp/orgflow/app/desktop/p2p/P2pDemoMain.kt | 35 ++- .../desktop/runtime/DesktopDistributionRuntime.kt | 69 ++++- .../kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt | 1 + .../jp/orgflow/fsmp/message/FsmpMessageTypes.kt | 58 ++++ .../orgflow/fsmp/message/FsmpMessageTypesTest.kt | 98 +++++++ .../orgflow/transport/mesh/WebRtcMeshTransport.kt | 16 +- .../transport/net/ContentTransferService.kt | 50 +++- .../transport/signaling/ChatMessageSignal.kt | 23 ++ .../orgflow/transport/signaling/PeerJoinSignal.kt | 7 + .../orgflow/transport/signaling/SignalingClient.kt | 2 + .../transport/PeerJoinSignalSerializationTest.kt | 30 +++ .../net/ContentTransferServiceWireTest.kt | 88 +++++++ .../transport/signaling/WsSignalingClient.kt | 13 + .../signaling/server/WsSignalingServer.kt | 133 ++++++---- .../server/WsSignalingServerRoomRoutingTest.kt | 214 ++++++++++++++- .../transport/signaling/WsSignalingClient.kt | 132 ++++++++++ modules/orgflow-content-store/build.gradle.kts | 5 +- .../kotlin/jp/orgflow/contentstore/BlockStore.kt | 53 +++- .../kotlin/jp/orgflow/contentstore/Cid.kt | 3 + .../jp/orgflow/contentstore/ContentAddresser.kt | 14 +- .../jp/orgflow/contentstore/ContentStoreTest.kt | 108 +++++++- .../jp/orgflow/contentstore/jvm/FileBlockStore.kt | 57 ++++ .../contentstore/jvm/NabuBlockStoreAdapter.kt | 37 --- .../jp/orgflow/contentstore/jvm/OkioBlockStore.kt | 38 --- .../jp/orgflow/contentstore/FileBlockStoreTest.kt | 55 ++++ .../jp/orgflow/contentstore/NabuAdapterTest.kt | 19 -- .../jp/orgflow/contentstore/wasm/OpfsBlockStore.kt | 207 ++++++++++++++- .../jp/orgflow/contentstore/OpfsBlockStoreTest.kt | 50 ++++ .../commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt | 193 ++++++++++++-- .../kotlin/jp/orgflow/ui/OrgFlowRoute.kt | 28 +- .../jp/orgflow/ui/breadcrumb/BreadcrumbBar.kt | 48 ++++ .../kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt | 1 + .../jp/orgflow/ui/calendar/CalendarScreen.kt | 107 ++++---- .../jp/orgflow/ui/calendar/CalendarViewModel.kt | 22 +- .../kotlin/jp/orgflow/ui/capture/CaptureScreen.kt | 19 +- .../jp/orgflow/ui/capture/CaptureViewModel.kt | 55 +++- .../kotlin/jp/orgflow/ui/config/ConfigScreen.kt | 38 +++ .../kotlin/jp/orgflow/ui/config/ConfigViewModel.kt | 4 + .../kotlin/jp/orgflow/ui/home/HomeScreen.kt | 183 ++++++++++++- .../kotlin/jp/orgflow/ui/home/HomeViewModel.kt | 66 ++++- .../kotlin/jp/orgflow/ui/live/LiveScreen.kt | 54 ++++ .../kotlin/jp/orgflow/ui/model/DynamicGroup.kt | 32 +++ .../commonMain/kotlin/jp/orgflow/ui/model/Group.kt | 14 + .../kotlin/jp/orgflow/ui/model/GroupColor.kt | 31 +++ .../kotlin/jp/orgflow/ui/model/GroupDirectory.kt | 50 ++++ .../kotlin/jp/orgflow/ui/model/GroupType.kt | 9 + .../kotlin/jp/orgflow/ui/model/JoinButtons.kt | 26 ++ .../kotlin/jp/orgflow/ui/model/Membership.kt | 8 + .../kotlin/jp/orgflow/ui/model/MembershipIcons.kt | 61 +++++ .../kotlin/jp/orgflow/ui/model/MembershipStatus.kt | 8 + .../kotlin/jp/orgflow/ui/model/SetFilterState.kt | 56 ++++ .../commonMain/kotlin/jp/orgflow/ui/model/User.kt | 7 + .../kotlin/jp/orgflow/ui/notes/NoteCards.kt | 292 +++++++++++++++++++++ .../kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt | 209 ++++++++++++++- .../jp/orgflow/ui/notes/NoteEditorViewModel.kt | 155 ++++++++++- .../kotlin/jp/orgflow/ui/notes/NotesScreen.kt | 53 +++- .../commonMain/kotlin/jp/orgflow/ui/pie/PieMenu.kt | 199 ++++++++++++++ .../kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt | 34 +++ .../kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt | 67 ++++- .../jp/orgflow/ui/workspace/WorkspaceScreen.kt | 8 +- .../kotlin/jp/orgflow/ui/AppStoreFlowTest.kt | 102 +++++++ .../kotlin/jp/orgflow/ui/CalendarTest.kt | 30 +++ .../kotlin/jp/orgflow/ui/ConnectionConfigTest.kt | 89 +++++++ .../kotlin/jp/orgflow/ui/NoteCardBoardTest.kt | 221 ++++++++++++++++ .../kotlin/jp/orgflow/ui/model/GroupModelTest.kt | 84 ++++++ .../jp/orgflow/ui/model/SetFilterStateTest.kt | 64 +++++ .../jp/orgflow/ui/runtime/ProvideRuntime.jvm.kt | 3 + .../orgflow/ui/runtime/WasmDistributionRuntime.kt | 183 +++++++++++++ 68 files changed, 4206 insertions(+), 322 deletions(-) create mode 100644 modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypes.kt create mode 100644 modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypesTest.kt create mode 100644 modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/ChatMessageSignal.kt create mode 100644 modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/net/ContentTransferServiceWireTest.kt create mode 100644 modules/fsmp-transport/src/wasmJsMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt create mode 100644 modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/FileBlockStore.kt delete mode 100644 modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/NabuBlockStoreAdapter.kt delete mode 100644 modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/OkioBlockStore.kt create mode 100644 modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/FileBlockStoreTest.kt delete mode 100644 modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/NabuAdapterTest.kt create mode 100644 modules/orgflow-content-store/src/wasmJsTest/kotlin/jp/orgflow/contentstore/OpfsBlockStoreTest.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/breadcrumb/BreadcrumbBar.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/live/LiveScreen.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/DynamicGroup.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Group.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupColor.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupDirectory.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupType.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/JoinButtons.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Membership.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipIcons.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipStatus.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/SetFilterState.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/User.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteCards.kt create mode 100644 modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/pie/PieMenu.kt create mode 100644 modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ConnectionConfigTest.kt create mode 100644 modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/NoteCardBoardTest.kt create mode 100644 modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/GroupModelTest.kt create mode 100644 modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/SetFilterStateTest.kt create mode 100644 modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/runtime/ProvideRuntime.jvm.kt create mode 100644 modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/runtime/WasmDistributionRuntime.kt diff --git a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt index 65b07e7..c7eb57b 100644 --- a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt +++ b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/p2p/P2pDemoMain.kt @@ -12,8 +12,11 @@ import jp.orgflow.pack.model.PackIntegrity import jp.orgflow.pack.model.PackType import kotlin.random.Random import kotlin.system.exitProcess +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicBoolean fun main(args: Array) { val role = args.firstOrNull { it.startsWith("--role=") }?.removePrefix("--role=") ?: "help" @@ -26,8 +29,8 @@ fun main(args: Array) { println("usage: p2p --role=signaling|makepack|sender|receiver [options]") println(" signaling --port=8091") println(" makepack --out=/tmp/pack.kzip --size=524288") - println(" sender --endpoint=127.0.0.1:8091 --name=alice --pack=/tmp/pack.kzip --room=demo-room") - println(" receiver --endpoint=127.0.0.1:8091 --name=bob --out=/tmp/received.kzip --room=demo-room") + println(" sender --endpoint=127.0.0.1:8091 --name=alice --pack=/tmp/pack.kzip --room=demo-room --chat") + println(" receiver --endpoint=127.0.0.1:8091 --name=bob --out=/tmp/received.kzip --room=demo-room --chat") } } } @@ -81,30 +84,44 @@ private fun runMakePack(args: Array) { println("[makepack] wrote $out (${Files.size(Paths.get(out))} bytes)") } +private fun chatEnabled(args: Array): Boolean = args.contains("--chat") || args.any { it.startsWith("--chat=") } + +private fun logChat(name: String): (jp.orgflow.transport.signaling.ChatMessageSignal) -> Unit = { chat -> + println("[chat:$name] from=${chat.senderPeerId} name=${chat.displayName} body=${chat.body}") +} + private fun runSender(args: Array) { val endpoint = arg(args, "endpoint", "127.0.0.1:8091") val name = arg(args, "name", "alice") val room = arg(args, "room", "demo-room") val packPath = arg(args, "pack", "/tmp/fsmp-demo-pack.kzip") + val withChat = chatEnabled(args) + val chatSent = AtomicBoolean(false) val packBytes = Files.readAllBytes(Paths.get(packPath)) val runtime = DesktopDistributionRuntime( selfPeerId = name, signalingEndpoint = "ws://$endpoint/ws", roomId = room, ) + if (withChat) runtime.onChatReceived(logChat(name)) runtime.offerPack("demo-pack-1", packBytes) runBlocking { runtime.start() println("[sender:$name] started room=$room, pack=${packBytes.size} bytes, waiting for peers...") + var expectedPeers = 0 val deadline = System.currentTimeMillis() + 120_000 while (System.currentTimeMillis() < deadline) { val snap = runtime.snapshot.value - if (snap.completedPeers.isNotEmpty() || (snap.peers.isNotEmpty() && snap.peers.all { it.ratio >= 0.999 })) break + if (snap.peers.size > expectedPeers) expectedPeers = snap.peers.size + if (expectedPeers in 1..snap.completedPeers.size) break Thread.sleep(500) } + if (withChat) delay(6_000) + if (withChat && chatSent.compareAndSet(false, true)) runtime.sendChat("hello from $name") val snap = runtime.snapshot.value println("[sender:$name] final peer ratios: ${snap.peers.associate { it.peerId to it.ratio }} sentChunks=${snap.sentChunks}") - println(if (snap.peers.isNotEmpty() && snap.peers.all { it.ratio >= 0.999 }) "RESULT: OK" else "RESULT: INCOMPLETE") + println("[sender:$name] completed peers: ${snap.completedPeers} (expected $expectedPeers)") + println(if (expectedPeers > 0 && snap.completedPeers.size >= expectedPeers) "RESULT: OK" else "RESULT: INCOMPLETE") } runtime.stop() exitProcess(0) @@ -115,6 +132,7 @@ private fun runReceiver(args: Array) { val name = arg(args, "name", "bob") val room = arg(args, "room", "demo-room") val outPath = arg(args, "out", "/tmp/fsmp-demo-received.kzip") + val withChat = chatEnabled(args) var verifiedResult = false val done = kotlinx.coroutines.CompletableDeferred() val runtime = DesktopDistributionRuntime( @@ -122,6 +140,7 @@ private fun runReceiver(args: Array) { signalingEndpoint = "ws://$endpoint/ws", roomId = room, ) + if (withChat) runtime.onChatReceived(logChat(name)) runtime.onPackReceived { packId, bytes, verified -> verifiedResult = verified Files.write(Paths.get(outPath), bytes) @@ -131,7 +150,15 @@ private fun runReceiver(args: Array) { runBlocking { runtime.start() println("[receiver:$name] started, waiting for pack...") + if (withChat) { + launch { + delay(8_000) + runtime.sendChat("hello from $name") + println("[chat:$name] sent body=hello from $name") + } + } withTimeoutOrNull(120_000) { done.await() } + if (withChat) delay(15_000) println(if (done.isCompleted && verifiedResult) "RESULT: OK" else "RESULT: TIMEOUT") } runtime.stop() diff --git a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt index 4f36967..0bc5bbc 100644 --- a/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt +++ b/apps/desktop/src/jvmMain/kotlin/jp/orgflow/app/desktop/runtime/DesktopDistributionRuntime.kt @@ -8,6 +8,8 @@ import jp.orgflow.fsmp.pipeline.DecisionContext import jp.orgflow.fsmp.waterline.WaterlineState import jp.orgflow.transport.mesh.WebRtcMeshTransport import jp.orgflow.transport.net.ContentTransferService +import jp.orgflow.transport.signaling.ChatMessageSignal +import jp.orgflow.transport.signaling.SignalingEnvelope import jp.orgflow.transport.signaling.WsSignalingClient import jp.orgflow.transport.signaling.server.WsSignalingServer import jp.orgflow.transport.webrtc.jvm.OnVoidRtcPlatform @@ -19,6 +21,7 @@ import jp.orgflow.ui.runtime.DistributionSnapshot import jp.orgflow.ui.runtime.UiConfigField import jp.orgflow.ui.runtime.UiPackSync import jp.orgflow.ui.runtime.UiPeerSync +import jp.orgflow.ui.store.OrgFlowAppStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -40,6 +43,12 @@ class DesktopDistributionRuntime( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val configStore = DesktopConfigStore() + @Volatile + private var configSignalingEndpoint: String = signalingEndpoint + + @Volatile + private var configIceServers: List = OrgFlowAppStore.DEFAULT_ICE_SERVERS + @Volatile var configuration: FsmpConfiguration = FsmpConfiguration.DEFAULT private set @@ -58,6 +67,9 @@ class DesktopDistributionRuntime( @Volatile private var signalingServer: WsSignalingServer? = null + @Volatile + private var signalingClient: WsSignalingClient? = null + private val _snapshot = MutableStateFlow(DistributionSnapshot()) override val snapshot: StateFlow = _snapshot @@ -68,6 +80,7 @@ class DesktopDistributionRuntime( private var receivedPacks: Int = 0 private var lastEvent: String = "idle" private var packReceiver: (suspend (String, ByteArray, Boolean) -> Unit)? = null + private var chatHandler: ((ChatMessageSignal) -> Unit)? = null private var advertised = false private val completedPeers = mutableSetOf() @@ -75,6 +88,34 @@ class DesktopDistributionRuntime( packReceiver = handler } + fun onChatReceived(handler: (ChatMessageSignal) -> Unit) { + chatHandler = handler + } + + fun sendChat(body: String) { + val client = signalingClient ?: return + val chat = ChatMessageSignal( + gid = roomId, + senderPeerId = selfPeerId, + displayName = selfPeerId, + body = body, + sentAtMs = System.currentTimeMillis(), + ) + scope.launch { + runCatching { + client.send( + SignalingEnvelope( + type = ChatMessageSignal.TYPE, + senderPeerId = selfPeerId, + nonce = chat.sentAtMs, + timestampMs = chat.sentAtMs, + payloadJson = kotlinx.serialization.json.Json.encodeToString(ChatMessageSignal.serializer(), chat), + ), + ) + } + } + } + fun reloadConfig(): FsmpConfiguration { configuration = configStore.load() currentEngine = FsmpEngine(configuration) @@ -94,6 +135,11 @@ class DesktopDistributionRuntime( } lastEvent = "starting $endpoint room=$roomId" val client = WsSignalingClient(endpoint, selfPeerId, roomId = roomId) + signalingClient = client + client.onChat { chat -> + lastEvent = "chat from ${chat.senderPeerId}" + chatHandler?.invoke(chat) + } val mesh = WebRtcMeshTransport( peerId = PeerId(selfPeerId), signaling = client, @@ -381,6 +427,21 @@ class DesktopDistributionRuntime( refreshConfigSnapshot(System.currentTimeMillis()) } + override fun updateEndpoint(endpoint: String) { + val next = endpoint.trim() + if (next.isBlank()) return + configSignalingEndpoint = next + lastEvent = "config saved: signaling endpoint $next (restart applies)" + refreshConfigSnapshot(System.currentTimeMillis()) + } + + override fun updateIceServers(servers: List) { + val cleaned = servers.map { it.trim() }.filter { it.isNotBlank() } + configIceServers = cleaned + lastEvent = "config saved: ${cleaned.size} ice servers (restart applies)" + refreshConfigSnapshot(System.currentTimeMillis()) + } + private fun refreshConfigSnapshot(savedAt: Long) { val fields = listOf( UiConfigField("frame_size_bytes", "frame size (bytes)", configuration.frameSizeBytes.toDouble(), 4096.0, 65536.0), @@ -390,7 +451,13 @@ class DesktopDistributionRuntime( UiConfigField("w_focus_card", "OB-SCA-V w_focus_card", configuration.obScaWFocusCard, 0.0, 1.0), UiConfigField("evaporation_rate", "ACO evaporation rate", configuration.acoEvaporationRate, 0.0, 1.0), ) - _config.value = ConfigSnapshot(configDir = configStore.summary(), fields = fields, lastSavedAtMs = savedAt) + _config.value = ConfigSnapshot( + configDir = configStore.summary(), + fields = fields, + lastSavedAtMs = savedAt, + signalingEndpoint = configSignalingEndpoint, + iceServers = configIceServers, + ) } private fun levelFor(ratio: Double): UiWaterline = when { diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt index 74fa7c2..e1f7924 100644 --- a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessage.kt @@ -8,6 +8,7 @@ sealed interface FsmpMessage { val streamId: String val flags: Int val bodyHash: String + val protocolVersion: Int get() = FsmpMessageTypes.PROTOCOL_VERSION fun validate(): Boolean = msgType.isNotBlank() && streamId.isNotBlank() } diff --git a/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypes.kt b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypes.kt new file mode 100644 index 0000000..f43c0cb --- /dev/null +++ b/modules/fsmp-core/src/commonMain/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypes.kt @@ -0,0 +1,58 @@ +package jp.orgflow.fsmp.message + +object FsmpMessageTypes { + const val PROTOCOL_VERSION = 1 + + const val RESERVED_FRAME = 1 + const val RESERVED_MESSAGE = 2 + const val PEER_HELLO = 3 + const val PEER_BYE = 4 + const val MANIFEST_REQUEST = 5 + const val MANIFEST_RESPONSE = 6 + const val CHUNK = 7 + const val ACK_BITMAP = 8 + const val REPAIR_REQUEST = 9 + const val REPAIR_RESPONSE = 10 + const val CACHE_ADVERTISE = 11 + const val STATUS_REPORT = 12 + const val ROUTE_FEEDBACK = 13 + const val PACK_ADVERTISE = 14 + const val GIT_POA_METADATA = 15 + const val COMMIT_OBSERVATION_NOTE = 16 + const val SESSION_CONTENT_ADVERTISE = 32 + const val SESSION_CONTENT_CHUNK = 33 + const val SESSION_CONTENT_ACK = 34 + const val SESSION_CONTENT_COMPLETE = 35 + + private val numberByMsgType: Map = linkedMapOf( + "peer.hello" to PEER_HELLO, + "peer.bye" to PEER_BYE, + "content.manifest.request" to MANIFEST_REQUEST, + "content.manifest.response" to MANIFEST_RESPONSE, + "content.chunk" to CHUNK, + "content.ack.bitmap" to ACK_BITMAP, + "repair.request" to REPAIR_REQUEST, + "repair.response" to REPAIR_RESPONSE, + "repair.cache.advertise" to CACHE_ADVERTISE, + "peer.status" to STATUS_REPORT, + "route.feedback" to ROUTE_FEEDBACK, + "content.pack.advertise" to PACK_ADVERTISE, + "gitpoa.metadata" to GIT_POA_METADATA, + "gitpoa.commit.observation" to COMMIT_OBSERVATION_NOTE, + "session.content.advertise" to SESSION_CONTENT_ADVERTISE, + "session.content.chunk" to SESSION_CONTENT_CHUNK, + "session.content.ack" to SESSION_CONTENT_ACK, + "session.content.complete" to SESSION_CONTENT_COMPLETE, + ) + + private val msgTypeByNumber: Map = + numberByMsgType.entries.associate { (msgType, number) -> number to msgType } + + fun numberFor(msgType: String): Int? = numberByMsgType[msgType] + + fun stringFor(number: Int): String? = msgTypeByNumber[number] + + fun allMsgTypes(): Set = numberByMsgType.keys + + fun allNumbers(): Set = msgTypeByNumber.keys +} diff --git a/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypesTest.kt b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypesTest.kt new file mode 100644 index 0000000..8d06d54 --- /dev/null +++ b/modules/fsmp-core/src/commonTest/kotlin/jp/orgflow/fsmp/message/FsmpMessageTypesTest.kt @@ -0,0 +1,98 @@ +package jp.orgflow.fsmp.message + +import jp.orgflow.fsmp.waterline.Waterline +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FsmpMessageTypesTest { + + private fun allMessages(): List = listOf( + PeerHello(streamId = "s", peerId = "p1"), + PeerBye(streamId = "s", peerId = "p1"), + StatusReport(streamId = "s", peerId = "p1", waterline = Waterline.USABLE, missingChunkCount = 0), + ManifestRequest(streamId = "s", packId = "p1"), + ManifestResponse(streamId = "s", packId = "p1", manifestHash = "hash", totalChunks = 1, chunkHashes = listOf("hash")), + ChunkMessage(streamId = "s", packId = "p1", chunkIndex = 0, chunkHash = "hash", payloadSizeBytes = 1), + AckBitmapMessage(streamId = "s", packId = "p1", peerId = "p1", receivedBitmap = listOf(true)), + PackAdvertise(streamId = "s", packId = "p1", manifestHash = "hash", totalChunks = 1), + RepairRequest(streamId = "s", packId = "p1", missingChunkIndexes = listOf(0)), + RepairResponse(streamId = "s", packId = "p1", chunkIndex = 0, chunkHash = "hash"), + CacheAdvertise(streamId = "s", packId = "p1", cachedChunkIndexes = listOf(0)), + RouteFeedback(streamId = "s", fromPeerId = "a", toPeerId = "b", success = true, rttMs = 1.0), + GitPoaMetadata(streamId = "s", commitHash = "commit", manifestHashes = listOf("hash"), committedAtMs = 0L), + CommitObservationNote(streamId = "s", commitHash = "commit", observedByPeerId = "p1", observedAtMs = 0L, consistent = true), + ) + + @Test + fun protocolVersionIsOne() { + assertEquals(1, FsmpMessageTypes.PROTOCOL_VERSION) + } + + @Test + fun everyMessageInstanceCarriesProtocolVersionAndRegisteredType() { + allMessages().forEach { message -> + assertEquals(FsmpMessageTypes.PROTOCOL_VERSION, message.protocolVersion) + assertTrue(message.validate()) + assertNotNull(FsmpMessageTypes.numberFor(message.msgType)) + } + } + + @Test + fun bidiRoundTripForAllTypes() { + FsmpMessageTypes.allMsgTypes().forEach { msgType -> + val number = assertNotNull(FsmpMessageTypes.numberFor(msgType)) + assertEquals(msgType, FsmpMessageTypes.stringFor(number)) + } + FsmpMessageTypes.allNumbers().forEach { number -> + val msgType = assertNotNull(FsmpMessageTypes.stringFor(number)) + assertEquals(number, FsmpMessageTypes.numberFor(msgType)) + } + } + + @Test + fun noDuplicateNumbers() { + assertEquals(FsmpMessageTypes.allMsgTypes().size, FsmpMessageTypes.allNumbers().size) + } + + @Test + fun allNumbersFitU16() { + FsmpMessageTypes.allNumbers().forEach { number -> + assertTrue(number in 0..0xFFFF) + } + } + + @Test + fun stableNumberingContract() { + assertEquals(1, FsmpMessageTypes.RESERVED_FRAME) + assertEquals(2, FsmpMessageTypes.RESERVED_MESSAGE) + assertEquals(3, FsmpMessageTypes.numberFor("peer.hello")) + assertEquals(4, FsmpMessageTypes.numberFor("peer.bye")) + assertEquals(5, FsmpMessageTypes.numberFor("content.manifest.request")) + assertEquals(6, FsmpMessageTypes.numberFor("content.manifest.response")) + assertEquals(7, FsmpMessageTypes.numberFor("content.chunk")) + assertEquals(8, FsmpMessageTypes.numberFor("content.ack.bitmap")) + assertEquals(9, FsmpMessageTypes.numberFor("repair.request")) + assertEquals(10, FsmpMessageTypes.numberFor("repair.response")) + assertEquals(11, FsmpMessageTypes.numberFor("repair.cache.advertise")) + assertEquals(12, FsmpMessageTypes.numberFor("peer.status")) + assertEquals(13, FsmpMessageTypes.numberFor("route.feedback")) + assertEquals(14, FsmpMessageTypes.numberFor("content.pack.advertise")) + assertEquals(15, FsmpMessageTypes.numberFor("gitpoa.metadata")) + assertEquals(16, FsmpMessageTypes.numberFor("gitpoa.commit.observation")) + assertEquals(32, FsmpMessageTypes.numberFor("session.content.advertise")) + assertEquals(33, FsmpMessageTypes.numberFor("session.content.chunk")) + assertEquals(34, FsmpMessageTypes.numberFor("session.content.ack")) + assertEquals(35, FsmpMessageTypes.numberFor("session.content.complete")) + } + + @Test + fun unknownLookupsReturnNull() { + assertNull(FsmpMessageTypes.numberFor("unknown.type")) + assertNull(FsmpMessageTypes.stringFor(0)) + assertNull(FsmpMessageTypes.stringFor(17)) + assertNull(FsmpMessageTypes.stringFor(0xFFFF)) + } +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt index 7be5737..19eb059 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/mesh/WebRtcMeshTransport.kt @@ -48,6 +48,7 @@ class WebRtcMeshTransport( private val sessions = mutableMapOf() private val mutex = Mutex() private val knownPeers = mutableSetOf() + private val earlyIce = mutableMapOf>() private val _incoming = kotlinx.coroutines.flow.MutableSharedFlow>(extraBufferCapacity = 256) private val _events = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 128) private val envelopes = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 128) @@ -107,11 +108,16 @@ class WebRtcMeshTransport( session.peer.acceptAnswer(answer.sdp) session.remoteDescriptionSet = true flushPendingIce(session) + flushEarlyIce(sender, session) session.pending?.complete(Unit) } TYPE_ICE -> { val ice = json.decodeFromString(IceCandidateSignal.serializer(), envelope.payloadJson) - val session = session(sender) ?: return + val session = session(sender) + if (session == null) { + earlyIce.getOrPut(sender) { mutableListOf() }.add(ice) + return + } if (session.remoteDescriptionSet) { runCatching { session.peer.addIceCandidate(ice.candidate, ice.sdpMid, ice.sdpMLineIndex) } } else { @@ -129,6 +135,13 @@ class WebRtcMeshTransport( } } + private fun flushEarlyIce(sender: PeerId, session: MeshSession) { + val buffered = earlyIce.remove(sender) ?: return + buffered.forEach { ice -> + runCatching { session.peer.addIceCandidate(ice.candidate, ice.sdpMid, ice.sdpMLineIndex) } + } + } + private fun initiatorBetween(self: PeerId, remote: PeerId): Boolean = self.value < remote.value private suspend fun session(remote: PeerId): MeshSession? = mutex.withLock { sessions[remote] } @@ -178,6 +191,7 @@ class WebRtcMeshTransport( val answerSdp = session.peer.acceptOffer(offer.sdp) session.remoteDescriptionSet = true flushPendingIce(session) + flushEarlyIce(sender, session) sendSignal( sender, TYPE_ANSWER, diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/net/ContentTransferService.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/net/ContentTransferService.kt index 58c4a9f..14cfa4c 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/net/ContentTransferService.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/net/ContentTransferService.kt @@ -9,6 +9,7 @@ import jp.orgflow.fsmp.session.ContentComplete import jp.orgflow.fsmp.session.ContentReceiverSession import jp.orgflow.fsmp.session.ContentSenderSession import jp.orgflow.fsmp.session.ContentTransferMessage +import jp.orgflow.fsmp.message.FsmpMessageTypes import jp.orgflow.transport.api.FsmpTransport import kotlinx.serialization.Serializable import kotlinx.coroutines.CoroutineScope @@ -62,9 +63,9 @@ class ContentTransferService( while (remaining > 0 || sentToPeer == 0) { val chunk = sender.nextChunkFor(target.value, packId) ?: break val encoded = encode(chunk) - if (sentToPeer > 0 && encoded.size > remaining) return sent + if (sentToPeer > 0 && encoded.size > remaining) break val okSend = transport.send(target, encoded) - if (!okSend) return sent + if (!okSend) break sentAtLock.lock() try { chunkSentAt[Triple(packId, target.value, chunk.index)] = nowMs() @@ -149,23 +150,47 @@ class ContentTransferService( } } - private fun encode(message: ContentTransferMessage): ByteArray = when (message) { - is ContentChunk -> { - @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) - val b64 = kotlin.io.encoding.Base64.encode(message.payload) - json.encodeToString( - ChunkWire.serializer(), - ChunkWire(message.packId, message.index, b64, message.sha256), - ).encodeToByteArray() + private fun wireTypeName(message: ContentTransferMessage): String = when (message) { + is ContentAdvertise -> "session.content.advertise" + is ContentChunk -> "session.content.chunk" + is ContentAck -> "session.content.ack" + is ContentComplete -> "session.content.complete" + } + + private fun encode(message: ContentTransferMessage): ByteArray { + val text = when (message) { + is ContentChunk -> { + @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) + val b64 = kotlin.io.encoding.Base64.encode(message.payload) + json.encodeToString( + ChunkWire.serializer(), + ChunkWire(message.packId, message.index, b64, message.sha256), + ) + } + else -> json.encodeToString(ContentTransferMessage.serializer(), message) + } + val msgTypeNumber = FsmpMessageTypes.numberFor(wireTypeName(message)) + val framed = if (msgTypeNumber == null) { + text + } else { + val obj = json.parseToJsonElement(text) as? kotlinx.serialization.json.JsonObject + if (obj == null) { + text + } else { + val withNumber = obj + ("msgTypeNumber" to kotlinx.serialization.json.JsonPrimitive(msgTypeNumber)) + kotlinx.serialization.json.JsonObject(withNumber).toString() + } } - else -> json.encodeToString(ContentTransferMessage.serializer(), message).encodeToByteArray() + return framed.encodeToByteArray() } private fun decode(bytes: ByteArray): ContentTransferMessage? = try { val text = bytes.decodeToString() val element = json.parseToJsonElement(text) val obj = element as? kotlinx.serialization.json.JsonObject - if (obj != null && obj.containsKey("payloadB64")) { + val msgTypeNumber = + (obj?.get("msgTypeNumber") as? kotlinx.serialization.json.JsonPrimitive)?.content?.toIntOrNull() + val message = if (obj != null && obj.containsKey("payloadB64")) { val wire = json.decodeFromString(ChunkWire.serializer(), text) @OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class) val payload = kotlin.io.encoding.Base64.decode(wire.payloadB64) @@ -173,6 +198,7 @@ class ContentTransferService( } else { json.decodeFromString(ContentTransferMessage.serializer(), text) } + if (msgTypeNumber != null && msgTypeNumber != FsmpMessageTypes.numberFor(wireTypeName(message))) null else message } catch (e: Exception) { null } diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/ChatMessageSignal.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/ChatMessageSignal.kt new file mode 100644 index 0000000..6853b23 --- /dev/null +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/ChatMessageSignal.kt @@ -0,0 +1,23 @@ +package jp.orgflow.transport.signaling + +import kotlinx.serialization.Serializable + +/** + * Classroom chat relayed through signaling. [gid] is the set id (room) the message + * belongs to; the server relays the envelope to peers in that gid, excluding the sender. + * [targetPeerIds] limits delivery to those peers within the gid (empty = all in gid), + * enabling pie-menu 送信先絞り込み (W16). + */ +@Serializable +data class ChatMessageSignal( + val gid: String, + val senderPeerId: String, + val displayName: String = "", + val body: String = "", + val sentAtMs: Long = 0L, + val targetPeerIds: List = emptyList(), +) { + companion object { + const val TYPE = "chat" + } +} diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt index b0d11f0..25f3e90 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/PeerJoinSignal.kt @@ -2,10 +2,17 @@ package jp.orgflow.transport.signaling import kotlinx.serialization.Serializable +/** + * roomId is kept as the wire field name for compatibility, but semantically it is a gid: + * an identifier of a set/group (W16 集合モデル) the peer belongs to. + * A peer may belong to multiple sets at once; [roomIds] lists the additional gids + * (the server merges [roomId] + [roomIds], deduped). + */ @Serializable data class PeerJoinSignal( val peerId: String, val displayName: String = "", val capabilitiesJson: String = "{}", val roomId: String = "default", + val roomIds: List = emptyList(), ) diff --git a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt index b5dd5d1..29c522b 100644 --- a/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt +++ b/modules/fsmp-transport/src/commonMain/kotlin/jp/orgflow/transport/signaling/SignalingClient.kt @@ -14,4 +14,6 @@ interface SignalingClient { suspend fun close() fun outgoing(): Flow + + fun onChat(handler: (ChatMessageSignal) -> Unit) = Unit } diff --git a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt index 1811ad8..f6f975e 100644 --- a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt +++ b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/PeerJoinSignalSerializationTest.kt @@ -2,8 +2,11 @@ package jp.orgflow.transport import jp.orgflow.transport.signaling.PeerJoinSignal import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull class PeerJoinSignalSerializationTest { @@ -26,4 +29,31 @@ class PeerJoinSignalSerializationTest { fun defaultRoomIdIsDefault() { assertEquals("default", PeerJoinSignal(peerId = "peer-1").roomId) } + + @Test + fun roomIdsRoundTrip() { + val signal = PeerJoinSignal(peerId = "peer-1", roomId = "room-a", roomIds = listOf("room-a", "room-b")) + val encoded = Json.encodeToString(PeerJoinSignal.serializer(), signal) + val decoded = Json.decodeFromString(PeerJoinSignal.serializer(), encoded) + assertEquals("room-a", decoded.roomId) + assertEquals(listOf("room-a", "room-b"), decoded.roomIds) + } + + @Test + fun legacyPayloadWithoutRoomIdsDecodesToEmpty() { + val decoded = Json.decodeFromString(PeerJoinSignal.serializer(), "{\"peerId\":\"peer-1\",\"roomId\":\"room-a\"}") + assertEquals(emptyList(), decoded.roomIds) + } + + @Test + fun wireFieldNamesUnchanged() { + val encoded = Json.encodeToString( + PeerJoinSignal.serializer(), + PeerJoinSignal(peerId = "peer-1", roomId = "room-a", roomIds = listOf("room-b")), + ) + val obj = Json.parseToJsonElement(encoded).jsonObject + assertEquals("peer-1", obj["peerId"]!!.jsonPrimitive.content) + assertEquals("room-a", obj["roomId"]!!.jsonPrimitive.content) + assertNotNull(obj["roomIds"]) + } } diff --git a/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/net/ContentTransferServiceWireTest.kt b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/net/ContentTransferServiceWireTest.kt new file mode 100644 index 0000000..ecf0551 --- /dev/null +++ b/modules/fsmp-transport/src/commonTest/kotlin/jp/orgflow/transport/net/ContentTransferServiceWireTest.kt @@ -0,0 +1,88 @@ +package jp.orgflow.transport.net + +import jp.orgflow.domain.identity.PeerId +import jp.orgflow.transport.api.FsmpTransport +import jp.orgflow.transport.api.FsmpTransportEvent +import jp.orgflow.transport.api.FsmpTransportState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ContentTransferServiceWireTest { + + private class FakeTransport(override val incoming: Flow>) : FsmpTransport { + override val peerId = PeerId("self") + override val state = FsmpTransportState.CONNECTED + override val events: Flow = emptyFlow() + val sent = mutableListOf>() + + override suspend fun connect(target: PeerId) {} + override suspend fun disconnect(target: PeerId) {} + override suspend fun send(target: PeerId, bytes: ByteArray): Boolean { + sent.add(target to bytes) + return true + } + override suspend fun close() {} + } + + private fun newService(transport: FakeTransport, scope: CoroutineScope) = + ContentTransferService(transport, scope, nowMs = { 0L }) + + private fun TestScope.newServiceWithEagerCollector(transport: FakeTransport) = + newService(transport, CoroutineScope(backgroundScope.coroutineContext + UnconfinedTestDispatcher(testScheduler))) + + @Test + fun advertiseWireCarriesRegistryNumberAndRoundTrips() = runTest { + val incoming = MutableSharedFlow>(replay = 16, extraBufferCapacity = 64) + val transport = FakeTransport(incoming) + val service = newServiceWithEagerCollector(transport) + service.start() + val bytes = ByteArray(2048) { (it % 7).toByte() } + assertTrue(service.distribute("pack-w1", bytes, listOf(PeerId("self")))) + val advertiseJson = transport.sent.single().second.decodeToString() + assertTrue(advertiseJson.contains("\"msgTypeNumber\":32")) + + incoming.tryEmit(PeerId("self") to transport.sent.single().second) + testScheduler.advanceUntilIdle() + assertEquals(2, transport.sent.size) + val ackJson = transport.sent.last().second.decodeToString() + assertTrue(ackJson.contains("\"msgTypeNumber\":34")) + assertTrue(ackJson.contains("ContentAck")) + } + + @Test + fun legacyWireWithoutNumberIsAccepted() = runTest { + val incoming = MutableSharedFlow>(replay = 16, extraBufferCapacity = 64) + val transport = FakeTransport(incoming) + val service = newServiceWithEagerCollector(transport) + service.start() + val legacy = + """{"type":"jp.orgflow.fsmp.session.ContentAdvertise","packId":"pack-w2","manifestSha256":"legacy",""" + + """"totalChunks":1,"chunkSizeBytes":1024,"totalBytes":1024,"chunkSha256List":["legacy"]}""" + incoming.tryEmit(PeerId("self") to legacy.encodeToByteArray()) + testScheduler.advanceUntilIdle() + assertEquals(1, transport.sent.size) + assertTrue(transport.sent.single().second.decodeToString().contains("\"msgTypeNumber\":34")) + } + + @Test + fun mismatchedMsgTypeNumberIsDropped() = runTest { + val incoming = MutableSharedFlow>(replay = 16, extraBufferCapacity = 64) + val transport = FakeTransport(incoming) + val service = newServiceWithEagerCollector(transport) + service.start() + val forged = + """{"type":"jp.orgflow.fsmp.session.ContentAdvertise","packId":"pack-w3","manifestSha256":"forged",""" + + """"totalChunks":1,"chunkSizeBytes":1024,"totalBytes":1024,"chunkSha256List":["forged"],"msgTypeNumber":999}""" + incoming.tryEmit(PeerId("self") to forged.encodeToByteArray()) + testScheduler.advanceUntilIdle() + assertEquals(0, transport.sent.size) + } +} diff --git a/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt b/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt index 8994e2b..0c78b71 100644 --- a/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt +++ b/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt @@ -31,6 +31,9 @@ class WsSignalingClient( ) private val _outgoing = MutableSharedFlow(extraBufferCapacity = 128) + @Volatile + private var chatHandler: ((ChatMessageSignal) -> Unit)? = null + private var http: HttpClient? = null private var socket: WebSocket? = null private val sendMutex = kotlinx.coroutines.sync.Mutex() @@ -41,6 +44,15 @@ class WsSignalingClient( override fun outgoing(): Flow = _outgoing + override fun onChat(handler: (ChatMessageSignal) -> Unit) { + chatHandler = handler + } + + private fun dispatchChat(envelope: SignalingEnvelope) { + val chat = runCatching { json.decodeFromString(ChatMessageSignal.serializer(), envelope.payloadJson) }.getOrNull() ?: return + chatHandler?.invoke(chat) + } + override suspend fun connect() { val uri = normalize(endpoint) val client = HttpClient.newHttpClient() @@ -57,6 +69,7 @@ class WsSignalingClient( val envelope = json.decodeFromString(SignalingEnvelope.serializer(), text) if (envelope.isFor(selfPeerId)) { _incoming.tryEmit(envelope) + if (envelope.type == ChatMessageSignal.TYPE) dispatchChat(envelope) } } return null diff --git a/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt b/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt index 7342efa..06bd597 100644 --- a/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt +++ b/modules/fsmp-transport/src/jvmMain/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServer.kt @@ -13,6 +13,7 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong import kotlin.concurrent.thread import kotlinx.serialization.json.Json +import jp.orgflow.transport.signaling.ChatMessageSignal import jp.orgflow.transport.signaling.PeerJoinSignal import jp.orgflow.transport.signaling.SignalingEnvelope @@ -21,7 +22,9 @@ class WsSignalingServer( ) { private val json = Json { ignoreUnknownKeys = true } private val rooms = ConcurrentHashMap>() + private val peerGids = ConcurrentHashMap>() private val writeLocks = ConcurrentHashMap() + private val membershipLock = Any() private val nonce = AtomicLong(0) @Volatile @@ -87,70 +90,93 @@ class WsSignalingServer( val joinEnvelope = parse(firstMessage) ?: return if (joinEnvelope.type != TYPE_JOIN) return val peerId = joinEnvelope.senderPeerId - val roomId = joinRoomId(joinEnvelope) - val room = roomFor(roomId) - val roster: List - val joinTargets: List - synchronized(room) { - room[peerId] = output + val gids = joinGids(joinEnvelope) + synchronized(membershipLock) { + gids.forEach { gid -> roomFor(gid)[peerId] = output } writeLocks[peerId] = Any() - roster = rosterPayload(room, roomId, peerId) - joinTargets = room.keys.filter { it != peerId } - } - System.err.println("[signaling] registered $peerId in room $roomId from ${socket.remoteSocketAddress}") - - roster.forEach { payload -> sendText(peerId, payload) } - joinTargets.forEach { other -> - sendText( - other, - json.encodeToString( - SignalingEnvelope.serializer(), - SignalingEnvelope( - type = TYPE_PEER_JOINED, - senderPeerId = peerId, - nonce = nonce.incrementAndGet(), - timestampMs = nowMs(), - payloadJson = joinEnvelope.payloadJson, - ), - ), - ) + peerGids[peerId] = gids + gids.flatMap { gid -> rosterPayload(roomFor(gid), gid, peerId) } + .forEach { payload -> sendText(peerId, payload) } + gids.flatMap { gid -> roomFor(gid).keys.filter { it != peerId } } + .distinct() + .forEach { other -> + sendText( + other, + json.encodeToString( + SignalingEnvelope.serializer(), + SignalingEnvelope( + type = TYPE_PEER_JOINED, + senderPeerId = peerId, + nonce = nonce.incrementAndGet(), + timestampMs = nowMs(), + payloadJson = joinEnvelope.payloadJson, + ), + ), + ) + } } + System.err.println("[signaling] registered $peerId in rooms ${gids.joinToString(",")} from ${socket.remoteSocketAddress}") try { while (true) { val text = readTextFrame(input) ?: break val envelope = parse(text) ?: continue - val target = envelope.targetPeerId val payload = json.encodeToString(SignalingEnvelope.serializer(), envelope) - if (target == null) { - room.keys.filter { it != peerId }.forEach { other -> sendText(other, payload) } - } else { - System.err.println("[signaling] relay ${envelope.type} $peerId -> $target (${writerFor(target) != null})") - sendText(target, payload) + val target = envelope.targetPeerId + when { + envelope.type == ChatMessageSignal.TYPE -> relayChat(peerId, envelope, payload) + target != null -> { + System.err.println("[signaling] relay ${envelope.type} $peerId -> $target (${writerFor(target) != null})") + sendText(target, payload) + } + else -> broadcastFrom(peerId, payload) } } } finally { - room.remove(peerId) - writeLocks.remove(peerId) - room.keys.forEach { other -> - sendText( - other, - json.encodeToString( - SignalingEnvelope.serializer(), - SignalingEnvelope( - type = TYPE_PEER_LEFT, - senderPeerId = peerId, - nonce = nonce.incrementAndGet(), - timestampMs = nowMs(), - payloadJson = "{}", - ), - ), - ) - } + leave(peerId) runCatching { socket.close() } } } + private fun broadcastFrom(peerId: String, payload: String) { + val targets = LinkedHashSet() + peerGids[peerId]?.forEach { gid -> rooms[gid]?.keys?.let { targets.addAll(it) } } + targets.remove(peerId) + targets.forEach { other -> sendText(other, payload) } + } + + private fun relayChat(peerId: String, envelope: SignalingEnvelope, payload: String) { + val chat = runCatching { json.decodeFromString(ChatMessageSignal.serializer(), envelope.payloadJson) }.getOrNull() ?: return + val room = rooms[chat.gid] ?: return + val targets = if (chat.targetPeerIds.isEmpty()) room.keys else chat.targetPeerIds + targets.filter { it != peerId && room.containsKey(it) }.forEach { other -> sendText(other, payload) } + } + + private fun leave(peerId: String) { + synchronized(membershipLock) { + val notified = LinkedHashSet() + val formerGids = peerGids.remove(peerId) ?: emptyList() + formerGids.forEach { gid -> + val room = rooms[gid] + room?.remove(peerId) + room?.keys?.let { notified.addAll(it) } + } + writeLocks.remove(peerId) + if (notified.isEmpty()) return + val payload = json.encodeToString( + SignalingEnvelope.serializer(), + SignalingEnvelope( + type = TYPE_PEER_LEFT, + senderPeerId = peerId, + nonce = nonce.incrementAndGet(), + timestampMs = nowMs(), + payloadJson = "{}", + ), + ) + notified.forEach { other -> sendText(other, payload) } + } + } + private fun sendText(peerId: String, text: String) { val output = writerFor(peerId) ?: return val lock = writeLocks[peerId] ?: return @@ -182,10 +208,13 @@ class WsSignalingServer( private fun roomFor(roomId: String): ConcurrentHashMap = rooms.computeIfAbsent(roomId) { ConcurrentHashMap() } - private fun joinRoomId(envelope: SignalingEnvelope): String = try { - json.decodeFromString(PeerJoinSignal.serializer(), envelope.payloadJson).roomId.ifBlank { DEFAULT_ROOM } + private fun joinGids(envelope: SignalingEnvelope): List = try { + val signal = json.decodeFromString(PeerJoinSignal.serializer(), envelope.payloadJson) + (listOf(signal.roomId) + signal.roomIds) + .map { gid -> gid.ifBlank { DEFAULT_ROOM } } + .distinct() } catch (e: Exception) { - DEFAULT_ROOM + listOf(DEFAULT_ROOM) } private fun writerFor(peerId: String): OutputStream? { diff --git a/modules/fsmp-transport/src/jvmTest/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServerRoomRoutingTest.kt b/modules/fsmp-transport/src/jvmTest/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServerRoomRoutingTest.kt index c16c799..3c7468c 100644 --- a/modules/fsmp-transport/src/jvmTest/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServerRoomRoutingTest.kt +++ b/modules/fsmp-transport/src/jvmTest/kotlin/jp/orgflow/transport/signaling/server/WsSignalingServerRoomRoutingTest.kt @@ -11,6 +11,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlin.test.fail +import jp.orgflow.transport.signaling.ChatMessageSignal import jp.orgflow.transport.signaling.PeerJoinSignal import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject @@ -20,6 +21,10 @@ import kotlinx.serialization.json.put class WsSignalingServerRoomRoutingTest { + private companion object { + const val JOIN_BARRIER_MS = 200L + } + private class TestClient(endpoint: String) { private val received = LinkedBlockingQueue() private val connected = CompletableFuture() @@ -40,8 +45,15 @@ class WsSignalingServerRoomRoutingTest { }, ).get() - fun join(peerId: String, roomId: String) { - socket.sendText(envelope("join", peerId, payloadJson = json.encodeToString(PeerJoinSignal.serializer(), PeerJoinSignal(peerId, roomId = roomId))), true).get() + fun join(peerId: String, roomId: String, roomIds: List = emptyList()) { + socket.sendText( + envelope( + "join", + peerId, + payloadJson = json.encodeToString(PeerJoinSignal.serializer(), PeerJoinSignal(peerId, roomId = roomId, roomIds = roomIds)), + ), + true, + ).get() } fun joinLegacy(peerId: String) { @@ -52,6 +64,13 @@ class WsSignalingServerRoomRoutingTest { socket.sendText(envelope(type, senderPeerId, targetPeerId, payloadJson = "{}"), true).get() } + fun sendChat(senderPeerId: String, chat: ChatMessageSignal) { + socket.sendText( + envelope(ChatMessageSignal.TYPE, senderPeerId, payloadJson = json.encodeToString(ChatMessageSignal.serializer(), chat)), + true, + ).get() + } + fun abort() { socket.abort() } @@ -79,6 +98,9 @@ class WsSignalingServerRoomRoutingTest { private fun field(envelope: JsonObject, key: String): String = envelope[key]!!.jsonPrimitive.content + private fun payloadField(envelope: JsonObject, key: String): String = + field(Json.parseToJsonElement(field(envelope, "payloadJson")).jsonObject, key) + @Test fun peersInSameRoomSeeEachOtherButOtherRoomsDoNot() { val server = WsSignalingServer(port = 0) @@ -87,15 +109,15 @@ class WsSignalingServerRoomRoutingTest { try { val alice = TestClient(endpoint) alice.join("alice", "room-a") + Thread.sleep(JOIN_BARRIER_MS) val bob = TestClient(endpoint) bob.join("bob", "room-a") - val carol = TestClient(endpoint) - carol.join("carol", "room-b") - val rosterForBob = bob.awaitMessage() assertEquals("peer-joined", field(rosterForBob, "type")) assertEquals("alice", field(rosterForBob, "senderPeerId")) assertEquals("room-a", field(Json.parseToJsonElement(field(rosterForBob, "payloadJson")).jsonObject, "roomId")) + val carol = TestClient(endpoint) + carol.join("carol", "room-b") val peerJoinedForAlice = alice.awaitMessage() assertEquals("peer-joined", field(peerJoinedForAlice, "type")) @@ -136,6 +158,7 @@ class WsSignalingServerRoomRoutingTest { try { val dave = TestClient(endpoint) dave.joinLegacy("dave") + Thread.sleep(JOIN_BARRIER_MS) val eve = TestClient(endpoint) eve.joinLegacy("eve") @@ -146,4 +169,185 @@ class WsSignalingServerRoomRoutingTest { server.stop() } } + + @Test + fun peerInMultipleGidsReceivesEveryGidEvents() { + val server = WsSignalingServer(port = 0) + server.start() + val endpoint = "ws://127.0.0.1:${server.actualPort()}/ws" + try { + val alice = TestClient(endpoint) + alice.join("alice", "room-a", roomIds = listOf("room-a", "room-b")) + Thread.sleep(JOIN_BARRIER_MS) + val bob = TestClient(endpoint) + bob.join("bob", "room-a") + + val rosterForBob = bob.awaitMessage() + assertEquals("peer-joined", field(rosterForBob, "type")) + assertEquals("alice", field(rosterForBob, "senderPeerId")) + assertEquals("room-a", payloadField(rosterForBob, "roomId")) + + val carol = TestClient(endpoint) + carol.join("carol", "room-b") + + val bobJoinedForAlice = alice.awaitMessage() + assertEquals("bob", field(bobJoinedForAlice, "senderPeerId")) + + val rosterForCarol = carol.awaitMessage() + assertEquals("peer-joined", field(rosterForCarol, "type")) + assertEquals("alice", field(rosterForCarol, "senderPeerId")) + assertEquals("room-b", payloadField(rosterForCarol, "roomId")) + + val carolJoinedForAlice = alice.awaitMessage() + assertEquals("carol", field(carolJoinedForAlice, "senderPeerId")) + + carol.send("note", "carol") + val carolNoteForAlice = alice.awaitMessage() + assertEquals("note", field(carolNoteForAlice, "type")) + assertEquals("carol", field(carolNoteForAlice, "senderPeerId")) + assertTrue(bob.expectSilence(), "bob in room-a must not receive room-b broadcasts") + + bob.send("note", "bob") + val bobNoteForAlice = alice.awaitMessage() + assertEquals("note", field(bobNoteForAlice, "type")) + assertEquals("bob", field(bobNoteForAlice, "senderPeerId")) + assertTrue(carol.expectSilence(), "carol in room-b must not receive room-a broadcasts") + } finally { + server.stop() + } + } + + @Test + fun broadcastReachesPeersSharingAnyGidExactlyOnce() { + val server = WsSignalingServer(port = 0) + server.start() + val endpoint = "ws://127.0.0.1:${server.actualPort()}/ws" + try { + val alice = TestClient(endpoint) + alice.join("alice", "room-a", roomIds = listOf("room-a", "room-b")) + Thread.sleep(JOIN_BARRIER_MS) + val bob = TestClient(endpoint) + bob.join("bob", "room-a", roomIds = listOf("room-a", "room-b")) + + assertEquals("room-a", payloadField(bob.awaitMessage(), "roomId")) + assertEquals("room-b", payloadField(bob.awaitMessage(), "roomId")) + assertEquals("bob", field(alice.awaitMessage(), "senderPeerId")) + + val carol = TestClient(endpoint) + carol.join("carol", "room-b") + + assertEquals("room-b", payloadField(carol.awaitMessage(), "roomId")) + assertEquals("room-b", payloadField(carol.awaitMessage(), "roomId")) + assertEquals("carol", field(alice.awaitMessage(), "senderPeerId")) + assertEquals("carol", field(bob.awaitMessage(), "senderPeerId")) + + alice.send("note", "alice") + val noteForBob = bob.awaitMessage() + assertEquals("note", field(noteForBob, "type")) + assertEquals("alice", field(noteForBob, "senderPeerId")) + assertTrue(bob.expectSilence(), "bob shares two gids with alice but must receive the broadcast once") + val noteForCarol = carol.awaitMessage() + assertEquals("note", field(noteForCarol, "type")) + assertTrue(alice.expectSilence(), "sender must not receive its own broadcast") + } finally { + server.stop() + } + } + + @Test + fun chatRelaysWithinGidAndExcludesSender() { + val server = WsSignalingServer(port = 0) + server.start() + val endpoint = "ws://127.0.0.1:${server.actualPort()}/ws" + try { + val alice = TestClient(endpoint) + alice.join("alice", "room-a") + Thread.sleep(JOIN_BARRIER_MS) + val bob = TestClient(endpoint) + bob.join("bob", "room-a") + val carol = TestClient(endpoint) + carol.join("carol", "room-b") + + assertEquals("alice", field(bob.awaitMessage(), "senderPeerId")) + assertEquals("bob", field(alice.awaitMessage(), "senderPeerId")) + + alice.sendChat( + "alice", + ChatMessageSignal(gid = "room-a", senderPeerId = "alice", displayName = "Alice", body = "hello", sentAtMs = 42L), + ) + val chatForBob = bob.awaitMessage() + assertEquals("chat", field(chatForBob, "type")) + assertEquals("alice", field(chatForBob, "senderPeerId")) + assertEquals("hello", payloadField(chatForBob, "body")) + assertEquals("room-a", payloadField(chatForBob, "gid")) + assertEquals("Alice", payloadField(chatForBob, "displayName")) + assertEquals("42", payloadField(chatForBob, "sentAtMs")) + assertTrue(alice.expectSilence(), "sender must not receive its own chat") + assertTrue(carol.expectSilence(), "chat must not leak into other gids") + + alice.sendChat( + "alice", + ChatMessageSignal(gid = "room-z", senderPeerId = "alice", body = "ghost", sentAtMs = 43L), + ) + assertTrue(bob.expectSilence(), "chat to unknown gid must be dropped") + assertTrue(carol.expectSilence(), "chat to unknown gid must be dropped") + } finally { + server.stop() + } + } + + @Test + fun targetedChatRelaysOnlyToTargetsInGid() { + val server = WsSignalingServer(port = 0) + server.start() + val endpoint = "ws://127.0.0.1:${server.actualPort()}/ws" + try { + val alice = TestClient(endpoint) + alice.join("alice", "room-a") + Thread.sleep(JOIN_BARRIER_MS) + val bob = TestClient(endpoint) + bob.join("bob", "room-a") + assertEquals("bob", field(alice.awaitMessage(), "senderPeerId")) + val carol = TestClient(endpoint) + carol.join("carol", "room-a") + val dave = TestClient(endpoint) + dave.join("dave", "room-b") + + assertEquals("room-a", payloadField(bob.awaitMessage(), "roomId")) + assertEquals("room-a", payloadField(carol.awaitMessage(), "roomId")) + assertEquals("room-a", payloadField(carol.awaitMessage(), "roomId")) + assertEquals("carol", field(alice.awaitMessage(), "senderPeerId")) + assertEquals("carol", field(bob.awaitMessage(), "senderPeerId")) + + alice.sendChat( + "alice", + ChatMessageSignal(gid = "room-a", senderPeerId = "alice", body = "for-bob-and-carol", sentAtMs = 44L, targetPeerIds = listOf("bob", "carol")), + ) + val chatForBob = bob.awaitMessage() + assertEquals("chat", field(chatForBob, "type")) + assertEquals("for-bob-and-carol", payloadField(chatForBob, "body")) + val chatForCarol = carol.awaitMessage() + assertEquals("for-bob-and-carol", payloadField(chatForCarol, "body")) + assertTrue(dave.expectSilence(), "targeted chat must not reach other gids") + assertTrue(alice.expectSilence(), "sender must not receive its own targeted chat") + + alice.sendChat( + "alice", + ChatMessageSignal(gid = "room-a", senderPeerId = "alice", body = "only-bob", sentAtMs = 45L, targetPeerIds = listOf("bob")), + ) + val onlyBob = bob.awaitMessage() + assertEquals("only-bob", payloadField(onlyBob, "body")) + assertTrue(carol.expectSilence(), "untargeted gid member must not receive targeted chat") + + alice.sendChat( + "alice", + ChatMessageSignal(gid = "room-a", senderPeerId = "alice", body = "outside-gid", sentAtMs = 46L, targetPeerIds = listOf("dave")), + ) + assertTrue(dave.expectSilence(), "targets outside the gid must not receive chat") + assertTrue(bob.expectSilence(), "non-targeted members must stay silent") + assertTrue(carol.expectSilence(), "non-targeted members must stay silent") + } finally { + server.stop() + } + } } diff --git a/modules/fsmp-transport/src/wasmJsMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt b/modules/fsmp-transport/src/wasmJsMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt new file mode 100644 index 0000000..4fe6cca --- /dev/null +++ b/modules/fsmp-transport/src/wasmJsMain/kotlin/jp/orgflow/transport/signaling/WsSignalingClient.kt @@ -0,0 +1,132 @@ +package jp.orgflow.transport.signaling + +import jp.orgflow.transport.mesh.WebRtcMeshTransport +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.serialization.json.Json + +class WsSignalingClient( + private val endpoint: String, + private val selfPeerId: String, + override val roomId: String = "default", +) : SignalingClient { + + private val json = Json { ignoreUnknownKeys = true } + private val socketId = "orgflow-ws-" + nextSocketId() + private val _incoming = MutableSharedFlow( + replay = 32, + extraBufferCapacity = 128, + onBufferOverflow = BufferOverflow.SUSPEND, + ) + + override val incoming: SharedFlow = _incoming + override var connected: Boolean = false + private set + + private var opened = false + private var chatHandler: ((ChatMessageSignal) -> Unit)? = null + + override fun onChat(handler: (ChatMessageSignal) -> Unit) { + chatHandler = handler + } + + override suspend fun connect() { + wsOpen(socketId, normalize(endpoint)) + opened = true + } + + override suspend fun send(envelope: SignalingEnvelope) { + wsSend(socketId, json.encodeToString(SignalingEnvelope.serializer(), envelope)) + } + + override suspend fun close() { + connected = false + if (opened) wsClose(socketId) + opened = false + } + + override fun outgoing(): Flow = emptyFlow() + + fun poll() { + if (!opened) return + while (true) { + val event = wsPollEvent() + if (event.isEmpty()) return + val separator = event.indexOf('|') + if (separator < 0) continue + if (event.substring(0, separator) != socketId) continue + val payload = event.substring(separator + 1) + when { + payload == EVENT_OPEN -> { + connected = true + sendJoin() + } + payload == EVENT_CLOSE -> connected = false + payload == EVENT_ERROR -> connected = false + else -> onMessage(payload) + } + } + } + + private fun onMessage(text: String) { + val envelope = runCatching { json.decodeFromString(SignalingEnvelope.serializer(), text) }.getOrNull() ?: return + if (!envelope.isFor(selfPeerId)) return + _incoming.tryEmit(envelope) + if (envelope.type == ChatMessageSignal.TYPE) { + val chat = runCatching { json.decodeFromString(ChatMessageSignal.serializer(), envelope.payloadJson) }.getOrNull() ?: return + chatHandler?.invoke(chat) + } + } + + private fun sendJoin() { + val join = PeerJoinSignal(selfPeerId, roomId = roomId) + wsSend( + socketId, + json.encodeToString( + SignalingEnvelope.serializer(), + SignalingEnvelope( + type = WebRtcMeshTransport.TYPE_JOIN, + senderPeerId = selfPeerId, + nonce = 0L, + timestampMs = 0L, + payloadJson = json.encodeToString(PeerJoinSignal.serializer(), join), + ), + ), + ) + } + + private fun normalize(endpoint: String): String { + val raw = if (endpoint.startsWith("ws://") || endpoint.startsWith("wss://")) endpoint else "ws://$endpoint" + val withoutScheme = raw.substringAfter("://") + return if (withoutScheme.contains('/')) raw else "$raw/ws" + } + + private companion object { + const val EVENT_OPEN = "@open" + const val EVENT_CLOSE = "@close" + const val EVENT_ERROR = "@error" + } +} + +private var socketCounter = 0 + +private fun nextSocketId(): Int = ++socketCounter + +private fun wsOpen(id: String, url: String): Boolean = js( + "(() => { try { const reg = (globalThis.__orgflowWsSockets = globalThis.__orgflowWsSockets || {}); const q = (globalThis.__orgflowWsQueue = globalThis.__orgflowWsQueue || []); const ws = new WebSocket(url); reg[id] = ws; ws.onopen = () => q.push(id + '|@open'); ws.onmessage = (e) => q.push(id + '|' + e.data); ws.onclose = () => q.push(id + '|@close'); ws.onerror = () => q.push(id + '|@error'); return true; } catch (e) { (globalThis.__orgflowWsQueue = globalThis.__orgflowWsQueue || []).push(id + '|@error'); return false; } })()", +) + +private fun wsPollEvent(): String = js( + "(() => { const q = globalThis.__orgflowWsQueue; if (q && q.length > 0) { return '' + q.shift(); } return ''; })()", +) + +private fun wsSend(id: String, text: String): Boolean = js( + "(() => { try { const ws = (globalThis.__orgflowWsSockets || {})[id]; if (ws && ws.readyState === 1) { ws.send(text); return true; } } catch (e) { return false; } return false; })()", +) + +private fun wsClose(id: String): Boolean = js( + "(() => { try { const reg = (globalThis.__orgflowWsSockets || {}); const ws = reg[id]; if (ws) { ws.onclose = null; ws.onerror = null; ws.close(); delete reg[id]; } } catch (e) { return false; } return true; })()", +) diff --git a/modules/orgflow-content-store/build.gradle.kts b/modules/orgflow-content-store/build.gradle.kts index a6a9fdd..0669b22 100644 --- a/modules/orgflow-content-store/build.gradle.kts +++ b/modules/orgflow-content-store/build.gradle.kts @@ -16,9 +16,10 @@ kotlin { sourceSets { commonMain.dependencies { implementation(libs.okio) + implementation(project(":modules:orgflow-crypto")) } - jvmMain.dependencies { - implementation(project(":modules:vendor-nabu")) + wasmJsMain.dependencies { + implementation(libs.coroutines.core) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/BlockStore.kt b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/BlockStore.kt index 7d1e984..12d2053 100644 --- a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/BlockStore.kt +++ b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/BlockStore.kt @@ -1,18 +1,57 @@ package jp.orgflow.contentstore interface BlockStore { - suspend fun put(block: ContentBlock) - suspend fun get(cid: Cid): ContentBlock? + suspend fun put(bytes: ByteArray): Cid + + suspend fun put(block: ContentBlock): Cid { + check(block.cid == ContentAddresser.cidOf(block.bytes)) { + "block content does not match cid: ${block.cid}" + } + return put(block.bytes) + } + + suspend fun get(cid: Cid): ByteArray? + + suspend fun getBlock(cid: Cid): ContentBlock? = get(cid)?.let { ContentBlock(cid, it) } + + suspend fun contains(cid: Cid): Boolean + + suspend fun clear() + + fun size(): Int + + fun stats(): BlockStoreStats + + fun verify(bytes: ByteArray, cid: Cid): Boolean = ContentAddresser.verify(bytes, cid) } +data class BlockStoreStats(val blockCount: Int, val totalBytes: Long) + class MemoryBlockStore : BlockStore { - private val blocks = mutableMapOf() + private val blocks = LinkedHashMap() + + override suspend fun put(bytes: ByteArray): Cid { + val cid = ContentAddresser.cidOf(bytes) + if (cid !in blocks) blocks[cid] = bytes.copyOf() + return cid + } + + override suspend fun get(cid: Cid): ByteArray? = blocks[cid]?.copyOf() - override suspend fun put(block: ContentBlock) { - blocks[block.cid] = block + override suspend fun contains(cid: Cid): Boolean = blocks.containsKey(cid) + + override suspend fun clear() { + blocks.clear() } - override suspend fun get(cid: Cid): ContentBlock? = blocks[cid] + override fun size(): Int = blocks.size - fun size(): Int = blocks.size + override fun stats(): BlockStoreStats = + BlockStoreStats(blocks.size, blocks.values.sumOf { it.size.toLong() }) } + +suspend fun BlockStore.store(bytes: ByteArray): Cid = put(bytes) + +fun BlockStore.linkFor(cid: Cid): String = "attachment:$cid" + +suspend fun BlockStore.linkFor(bytes: ByteArray): String = linkFor(put(bytes)) diff --git a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/Cid.kt b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/Cid.kt index 2b12829..6e8ac0a 100644 --- a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/Cid.kt +++ b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/Cid.kt @@ -1,5 +1,8 @@ package jp.orgflow.contentstore +// CID string form: ":" (lowercase hex), e.g. sha256:2cf24dba... +// This is the token recorded in org-mode attachment properties (":ATTACHMENTS:"). + data class Cid(val codec: String, val hashHex: String) { override fun toString(): String = "$codec:$hashHex" diff --git a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/ContentAddresser.kt b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/ContentAddresser.kt index 8cb3733..de1f641 100644 --- a/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/ContentAddresser.kt +++ b/modules/orgflow-content-store/src/commonMain/kotlin/jp/orgflow/contentstore/ContentAddresser.kt @@ -1,14 +1,10 @@ package jp.orgflow.contentstore -import okio.Buffer -import okio.HashingSink -import okio.blackholeSink +import jp.orgflow.crypto.HashingService object ContentAddresser { - fun cidOf(bytes: ByteArray): Cid { - val source = Buffer().apply { write(bytes) } - val sink = HashingSink.sha256(blackholeSink()) - source.readAll(sink) - return Cid.fromSha256(sink.hash.toByteArray()) - } + fun cidOf(bytes: ByteArray): Cid = + Cid.fromSha256(HashingService.sha256(bytes).toByteArray()) + + fun verify(bytes: ByteArray, cid: Cid): Boolean = cidOf(bytes) == cid } diff --git a/modules/orgflow-content-store/src/commonTest/kotlin/jp/orgflow/contentstore/ContentStoreTest.kt b/modules/orgflow-content-store/src/commonTest/kotlin/jp/orgflow/contentstore/ContentStoreTest.kt index d3e1fd3..429612b 100644 --- a/modules/orgflow-content-store/src/commonTest/kotlin/jp/orgflow/contentstore/ContentStoreTest.kt +++ b/modules/orgflow-content-store/src/commonTest/kotlin/jp/orgflow/contentstore/ContentStoreTest.kt @@ -2,9 +2,18 @@ package jp.orgflow.contentstore import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest class ContentStoreTest { + private fun pseudoRandom(size: Int, seed: Long = 42): ByteArray { + var s = seed + return ByteArray(size) { s = s * 6364136223846793005L + 1442695040888963407L; (s shr 33).toByte() } + } + @Test fun cidMatchesSha256Vector() { val cid = ContentAddresser.cidOf("hello".encodeToByteArray()) @@ -15,17 +24,100 @@ class ContentStoreTest { } @Test - fun blockStoreDeduplicatesByContent() = kotlinx.coroutines.test.runTest { + fun cidParseRoundTripsToString() { + val raw = "sha256:2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + assertEquals(raw, Cid.parse(raw).toString()) + } + + @Test + fun putThenGetRoundTrips() = runTest { + val store = MemoryBlockStore() + val bytes = "round-trip".encodeToByteArray() + val cid = store.put(bytes) + assertTrue(store.contains(cid)) + assertTrue(bytes.contentEquals(store.get(cid)!!)) + assertEquals(bytes.toList(), store.getBlock(cid)!!.bytes.toList()) + } + + @Test + fun sameBytesDedupeIntoOneBlock() = runTest { + val store = MemoryBlockStore() + val bytes = ByteArray(4096) { (it % 251).toByte() } + val first = store.put(bytes) + val second = store.put(bytes.copyOf()) + assertEquals(first, second) + assertEquals(1, store.size()) + } + + @Test + fun differentBytesYieldDifferentCids() = runTest { val store = MemoryBlockStore() - val bytes = "same".encodeToByteArray() - val b1 = ContentBlock(ContentAddresser.cidOf(bytes), bytes) - val b2 = ContentBlock(ContentAddresser.cidOf(bytes), bytes) + val a = store.put("alpha".encodeToByteArray()) + val b = store.put("beta".encodeToByteArray()) + assertFalse(a == b) + assertEquals(2, store.size()) + } - store.put(b1) - store.put(b2) + @Test + fun verifyAcceptsOnlyMatchingContent() = runTest { + val store = MemoryBlockStore() + val bytes = "verify me".encodeToByteArray() + val cid = store.put(bytes) + assertTrue(store.verify(bytes, cid)) + assertFalse(store.verify("verify me!".encodeToByteArray(), cid)) + assertFalse(ContentAddresser.verify(ByteArray(1), cid)) + } + @Test + fun emptyBytesRoundTrip() = runTest { + val store = MemoryBlockStore() + val cid = store.put(ByteArray(0)) + assertEquals( + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + cid.toString() + ) + assertEquals(0, store.get(cid)!!.size) + } + + @Test + fun largeBinaryRoundTripsAndDedupes() = runTest { + val data = pseudoRandom(256 * 1024) + val store = MemoryBlockStore() + val cid = store.put(data) + assertTrue(data.contentEquals(store.get(cid)!!)) + assertEquals(cid, store.put(data.copyOf())) assertEquals(1, store.size()) - assertEquals(b1, store.get(b1.cid)) - assertNull(store.get(Cid.parse("sha256:00"))) + assertEquals(256 * 1024L, store.stats().totalBytes) + } + + @Test + fun statsAndClearReportStoreState() = runTest { + val store = MemoryBlockStore() + store.put("one".encodeToByteArray()) + store.put("two!".encodeToByteArray()) + assertEquals(2, store.stats().blockCount) + assertEquals(("one".length + "two!".length).toLong(), store.stats().totalBytes) + store.clear() + assertEquals(0, store.size()) + assertEquals(0L, store.stats().totalBytes) + assertNull(store.get(ContentAddresser.cidOf("one".encodeToByteArray()))) + } + + @Test + fun putRejectsBlockWhoseCidDoesNotMatchContent() = runTest { + val store = MemoryBlockStore() + val mismatch = ContentBlock(Cid.parse("sha256:00"), "content".encodeToByteArray()) + assertFailsWith { store.put(mismatch) } + } + + @Test + fun linkForRecordsCidForOrgAttachments() = runTest { + val store = MemoryBlockStore() + val bytes = "attachment bytes".encodeToByteArray() + val cid = store.store(bytes) + val link = store.linkFor(bytes) + assertEquals("attachment:$cid", link) + assertEquals(cid, Cid.parse(link.removePrefix("attachment:"))) + assertEquals("attachment:$cid", store.linkFor(cid)) } } diff --git a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/FileBlockStore.kt b/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/FileBlockStore.kt new file mode 100644 index 0000000..bff916a --- /dev/null +++ b/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/FileBlockStore.kt @@ -0,0 +1,57 @@ +package jp.orgflow.contentstore.jvm + +import jp.orgflow.contentstore.BlockStore +import jp.orgflow.contentstore.BlockStoreStats +import jp.orgflow.contentstore.Cid +import jp.orgflow.contentstore.ContentAddresser +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath + +// ch.20a realization: directory-backed store, one block per file named by CID hash hex +// with a 2-char fanout (git object layout): //. +// 経路を信頼せず、読み出し時に内容をCID検証する (ch.14 principle applied to storage). + +class FileBlockStore( + private val root: String, + private val fs: FileSystem = FileSystem.SYSTEM, +) : BlockStore { + + private fun pathFor(cid: Cid): Path = + "$root/${cid.hashHex.take(2)}/${cid.hashHex}".toPath() + + override suspend fun put(bytes: ByteArray): Cid { + val cid = ContentAddresser.cidOf(bytes) + val p = pathFor(cid) + fs.createDirectories(p.parent!!) + if (!fs.exists(p)) fs.write(p) { write(bytes) } + return cid + } + + override suspend fun get(cid: Cid): ByteArray? { + val p = pathFor(cid) + if (!fs.exists(p)) return null + val bytes = fs.read(p) { readByteArray() } + check(ContentAddresser.cidOf(bytes) == cid) { "stored block failed verification: $cid" } + return bytes + } + + override suspend fun contains(cid: Cid): Boolean = fs.exists(pathFor(cid)) + + override suspend fun clear() { + fs.deleteRecursively(root.toPath(), mustExist = false) + } + + override fun size(): Int = blockFiles().size + + override fun stats(): BlockStoreStats { + val files = blockFiles() + return BlockStoreStats(files.size, files.sumOf { fs.metadata(it).size ?: 0L }) + } + + private fun blockFiles(): List { + val rootPath = root.toPath() + if (!fs.exists(rootPath)) return emptyList() + return fs.listRecursively(rootPath).filter { fs.metadata(it).isRegularFile }.toList() + } +} diff --git a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/NabuBlockStoreAdapter.kt b/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/NabuBlockStoreAdapter.kt deleted file mode 100644 index dcec946..0000000 --- a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/NabuBlockStoreAdapter.kt +++ /dev/null @@ -1,37 +0,0 @@ -package jp.orgflow.contentstore.jvm - -import io.ipfs.multihash.Multihash -import jp.orgflow.contentstore.BlockStore -import jp.orgflow.contentstore.ContentBlock -import io.ipfs.cid.Cid as NabuCid -import org.peergos.blockstore.RamBlockstore -import java.util.concurrent.TimeUnit - -// ch.20a: adapter exposing the vendored nabu blockstore as our BlockStore. -// Nabu is JVM-only, hence this adapter lives in jvmMain. - -class NabuBlockStoreAdapter( - private val backing: RamBlockstore = RamBlockstore(), -) : BlockStore { - - private fun nabuMultihash(hex: String): Multihash = - Multihash(Multihash.Type.sha2_256, hexToBytes(hex)) - - private fun nabuCid(hex: String): NabuCid = - NabuCid.build(1, NabuCid.Codec.Raw, nabuMultihash(hex)) - - override suspend fun put(block: ContentBlock) { - val stored = backing.put(block.bytes, NabuCid.Codec.Raw).get(30, TimeUnit.SECONDS) - check(stored.bareMultihash() == nabuMultihash(block.cid.hashHex)) { "cid mismatch after put" } - } - - override suspend fun get(cid: jp.orgflow.contentstore.Cid): ContentBlock? { - val bytes = backing.get(nabuCid(cid.hashHex)).get(30, TimeUnit.SECONDS).orElse(null) - ?: return null - return ContentBlock(cid, bytes) - } -} - -private fun hexToBytes(hex: String): ByteArray = ByteArray(hex.length / 2) { i -> - ((Character.digit(hex[i * 2], 16) shl 4) + Character.digit(hex[i * 2 + 1], 16)).toByte() -} diff --git a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/OkioBlockStore.kt b/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/OkioBlockStore.kt deleted file mode 100644 index df0928d..0000000 --- a/modules/orgflow-content-store/src/jvmMain/kotlin/jp/orgflow/contentstore/jvm/OkioBlockStore.kt +++ /dev/null @@ -1,38 +0,0 @@ -package jp.orgflow.contentstore.jvm - -import jp.orgflow.contentstore.BlockStore -import jp.orgflow.contentstore.Cid -import jp.orgflow.contentstore.ContentAddresser -import jp.orgflow.contentstore.ContentBlock -import okio.FileSystem -import okio.Path -import okio.Path.Companion.toPath - -// ch.20a realization: directory-backed block store with on-read CID verification. -// Layout: // - -class OkioBlockStore( - private val root: String, - private val fs: FileSystem = FileSystem.SYSTEM, -) : BlockStore { - - private fun pathFor(cid: Cid): Path = - "$root/${cid.hashHex.take(2)}/${cid.hashHex}".toPath() - - override suspend fun put(block: ContentBlock) { - val recomputed = ContentAddresser.cidOf(block.bytes) - check(recomputed == block.cid) { "block content does not match cid" } - val p = pathFor(block.cid) - fs.createDirectories(p.parent!!) - if (!fs.exists(p)) fs.write(p) { write(block.bytes) } - } - - override suspend fun get(cid: Cid): ContentBlock? { - val p = pathFor(cid) - if (!fs.exists(p)) return null - val bytes = fs.read(p) { readByteArray() } - // 経路を信頼せず内容を検証する (ch.14 principle applied to storage) - check(ContentAddresser.cidOf(bytes) == cid) { "stored block failed verification: $cid" } - return ContentBlock(cid, bytes) - } -} diff --git a/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/FileBlockStoreTest.kt b/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/FileBlockStoreTest.kt new file mode 100644 index 0000000..277eac3 --- /dev/null +++ b/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/FileBlockStoreTest.kt @@ -0,0 +1,55 @@ +package jp.orgflow.contentstore + +import jp.orgflow.contentstore.jvm.FileBlockStore +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FileBlockStoreTest { + @Test + fun blocksPersistAcrossStoreInstances() = runTest { + val root = kotlin.io.path.createTempDirectory("content-store").toFile().absolutePath + val bytes = "persisted attachment".encodeToByteArray() + val cid = FileBlockStore(root).put(bytes) + + val reopened = FileBlockStore(root) + assertTrue(reopened.contains(cid)) + assertTrue(bytes.contentEquals(reopened.get(cid)!!)) + assertEquals(1, reopened.size()) + } + + @Test + fun dedupesOnDiskAndReportsStats() = runTest { + val root = kotlin.io.path.createTempDirectory("content-store").toFile().absolutePath + val store = FileBlockStore(root) + val a = store.put("duplicate".encodeToByteArray()) + val b = store.put("duplicate".encodeToByteArray()) + val c = store.put("other".encodeToByteArray()) + assertEquals(a, b) + assertFalse(a == c) + assertEquals(2, store.size()) + assertEquals(2, store.stats().blockCount) + assertEquals(("duplicate".length + "other".length).toLong(), store.stats().totalBytes) + } + + @Test + fun getReturnsNullForUnknownCid() = runTest { + val root = kotlin.io.path.createTempDirectory("content-store").toFile().absolutePath + val store = FileBlockStore(root) + assertNull(store.get(ContentAddresser.cidOf("missing".encodeToByteArray()))) + assertFalse(store.contains(ContentAddresser.cidOf("missing".encodeToByteArray()))) + } + + @Test + fun clearRemovesAllBlocks() = runTest { + val root = kotlin.io.path.createTempDirectory("content-store").toFile().absolutePath + val store = FileBlockStore(root) + store.put("gone".encodeToByteArray()) + store.clear() + assertEquals(0, store.size()) + assertNull(store.get(ContentAddresser.cidOf("gone".encodeToByteArray()))) + } +} diff --git a/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/NabuAdapterTest.kt b/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/NabuAdapterTest.kt deleted file mode 100644 index 957612b..0000000 --- a/modules/orgflow-content-store/src/jvmTest/kotlin/jp/orgflow/contentstore/NabuAdapterTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package jp.orgflow.contentstore - -import jp.orgflow.contentstore.jvm.NabuBlockStoreAdapter -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class NabuAdapterTest { - @Test - fun putGetRoundTripThroughNabu() = runTest { - val adapter = NabuBlockStoreAdapter() - val bytes = "nabu vendored core works".encodeToByteArray() - val cid = ContentAddresser.cidOf(bytes) - val block = ContentBlock(cid, bytes) - - adapter.put(block) - assertEquals(block, adapter.get(cid)) - } -} diff --git a/modules/orgflow-content-store/src/wasmJsMain/kotlin/jp/orgflow/contentstore/wasm/OpfsBlockStore.kt b/modules/orgflow-content-store/src/wasmJsMain/kotlin/jp/orgflow/contentstore/wasm/OpfsBlockStore.kt index 19ecb5a..6cd935b 100644 --- a/modules/orgflow-content-store/src/wasmJsMain/kotlin/jp/orgflow/contentstore/wasm/OpfsBlockStore.kt +++ b/modules/orgflow-content-store/src/wasmJsMain/kotlin/jp/orgflow/contentstore/wasm/OpfsBlockStore.kt @@ -1,16 +1,209 @@ package jp.orgflow.contentstore.wasm import jp.orgflow.contentstore.BlockStore +import jp.orgflow.contentstore.BlockStoreStats import jp.orgflow.contentstore.Cid -import jp.orgflow.contentstore.ContentBlock +import jp.orgflow.contentstore.ContentAddresser import jp.orgflow.contentstore.MemoryBlockStore +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine -// ch.19 stub policy (submission scope): web persistence is in-memory; full OPFS adapter -// is out of scope. Interface stays identical so the real OPFS store can drop in later. +// ch.20a wasm realization: blocks persist in OPFS with the same git object layout as the +// JVM store (//) and are CID-verified on read. +// navigator.storage.getDirectory() が使えない環境 (insecure context / node等) では +// MemoryBlockStore に縮退する. size()/stats() は初回使用時に OPFS から構築する index に基づく. -class OpfsBlockStore : BlockStore { - private val delegate = MemoryBlockStore() +internal fun bytesToBinaryString(bytes: ByteArray): String { + val out = StringBuilder(bytes.size) + for (b in bytes) out.append((b.toInt() and 0xff).toChar()) + return out.toString() +} + +internal fun binaryStringToBytes(binary: String): ByteArray = + ByteArray(binary.length) { binary[it].code.toByte() } + +class OpfsBlockStore(private val root: String = DEFAULT_ROOT) : BlockStore { + + private val memory = MemoryBlockStore() + private val index = HashMap() + private var probePromise: JsAny? = null + private var usingOpfs = false + private var indexed = false + + override suspend fun put(bytes: ByteArray): Cid { + val cid = ContentAddresser.cidOf(bytes) + val dir = backend() ?: return memory.put(bytes) + val fan = awaitAny(getDirectoryHandlePromise(dir, cid.hashHex.take(2), true)) + ?: throw IllegalStateException("opfs: cannot open fanout dir ${cid.hashHex.take(2)} for $cid") + if (awaitAny(getFileHandlePromise(fan, cid.hashHex, false)) != null) return cid + val handle = awaitAny(getFileHandlePromise(fan, cid.hashHex, true)) + ?: throw IllegalStateException("opfs: cannot create block file for $cid") + awaitUnit(writeBlockPromise(handle, bytesToBinaryString(bytes))) + index[cid.hashHex] = bytes.size + return cid + } + + override suspend fun get(cid: Cid): ByteArray? { + val dir = backend() ?: return memory.get(cid) + val fan = awaitAny(getDirectoryHandlePromise(dir, cid.hashHex.take(2), false)) ?: return null + val handle = awaitAny(getFileHandlePromise(fan, cid.hashHex, false)) ?: return null + val file = awaitAny(filePromise(handle)) ?: return null + val binary = awaitString(fileBinaryStringPromise(file)) ?: return null + val bytes = binaryStringToBytes(binary) + check(ContentAddresser.cidOf(bytes) == cid) { "stored block failed verification: $cid" } + return bytes + } + + override suspend fun contains(cid: Cid): Boolean { + val dir = backend() ?: return memory.contains(cid) + val fan = awaitAny(getDirectoryHandlePromise(dir, cid.hashHex.take(2), false)) ?: return false + return awaitAny(getFileHandlePromise(fan, cid.hashHex, false)) != null + } + + override suspend fun clear() { + val dir = backend() + if (dir == null) { + memory.clear() + return + } + for (name in awaitStringList(listEntriesPromise(dir))) { + awaitAny(removeEntryPromise(dir, name)) + } + index.clear() + } + + override fun size(): Int = if (usingOpfs) index.size else memory.size() + + override fun stats(): BlockStoreStats = + if (usingOpfs) BlockStoreStats(index.size, index.values.sumOf { it.toLong() }) else memory.stats() - override suspend fun put(block: ContentBlock) = delegate.put(block) - override suspend fun get(cid: Cid): ContentBlock? = delegate.get(cid) + private suspend fun backend(): JsAny? { + val promise = probePromise ?: probe().also { probePromise = it } + val dir = awaitAny(promise) + usingOpfs = dir != null + if (dir != null && !indexed) { + indexed = true + loadIndex(dir) + } + return dir + } + + private fun probe(): JsAny = if (opfsSupported()) openRootPromise(root) else resolvedNullPromise() + + private suspend fun loadIndex(dir: JsAny) { + index.clear() + val entries = awaitAny(collectSizesPromise(dir)) ?: return + repeat(jsArrayLength(entries)) { i -> + val item = jsArrayItemAt(entries, i) ?: return@repeat + val hashHex = jsItemName(item) ?: return@repeat + index[hashHex] = jsItemSize(item) + } + } + + private companion object { + const val DEFAULT_ROOT = "orgflow-content" + } +} + +private suspend fun awaitAny(promise: JsAny): JsAny? = + suspendCancellableCoroutine { cont -> + bridgeThen(promise, { value -> cont.resume(value) }, { cont.resume(null) }) + } + +private suspend fun awaitUnit(promise: JsAny) { + suspendCancellableCoroutine { cont -> + bridgeThen(promise, { cont.resume(Unit) }, { error -> cont.resumeWithException(opfsFailure(error)) }) + } } + +private suspend fun awaitString(promise: JsAny): String? = + suspendCancellableCoroutine { cont -> + bridgeThen(promise, { value -> cont.resume(jsValueToString(value)) }, { cont.resume(null) }) + } + +private suspend fun awaitStringList(promise: JsAny): List { + val array = awaitAny(promise) ?: return emptyList() + val out = ArrayList(jsArrayLength(array)) + repeat(jsArrayLength(array)) { i -> jsArrayStringAt(array, i)?.let { out += it } } + return out +} + +private fun opfsFailure(error: JsAny): IllegalStateException = + IllegalStateException("opfs failure: ${jsErrorMessage(error)}") + +private fun opfsSupported(): Boolean = js( + "(() => { try { const s = globalThis.navigator && globalThis.navigator.storage; const fh = globalThis.FileSystemFileHandle; const dh = globalThis.FileSystemDirectoryHandle; return !!(s && typeof s.getDirectory === 'function' && fh && fh.prototype && typeof fh.prototype.createWritable === 'function' && dh && dh.prototype && typeof dh.prototype.getDirectoryHandle === 'function' && typeof dh.prototype.getFileHandle === 'function'); } catch (e) { return false; } })()", +) + +private fun openRootPromise(name: String): JsAny = js( + "(() => { return globalThis.navigator.storage.getDirectory().then((base) => base.getDirectoryHandle(name, { create: true })); })()", +) + +private fun getDirectoryHandlePromise(parent: JsAny, name: String, create: Boolean): JsAny = js( + "(() => { return parent.getDirectoryHandle(name, { create: create }).then((handle) => handle, () => null); })()", +) + +private fun getFileHandlePromise(parent: JsAny, name: String, create: Boolean): JsAny = js( + "(() => { return parent.getFileHandle(name, { create: create }).then((handle) => handle, () => null); })()", +) + +private fun filePromise(handle: JsAny): JsAny = js( + "(() => { return handle.getFile(); })()", +) + +private fun fileBinaryStringPromise(file: JsAny): JsAny = js( + "(() => { return file.arrayBuffer().then((buffer) => { const u8 = new Uint8Array(buffer); let out = ''; const chunk = 0x8000; for (let i = 0; i < u8.length; i += chunk) { out += String.fromCharCode.apply(null, u8.subarray(i, i + chunk)); } return out; }); })()", +) + +private fun writeBlockPromise(handle: JsAny, binary: String): JsAny = js( + "(() => { const u8 = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { u8[i] = binary.charCodeAt(i); } return handle.createWritable().then((writable) => writable.write(u8).then(() => writable.close())); })()", +) + +private fun listEntriesPromise(parent: JsAny): JsAny = js( + "(() => { try { if (!parent || typeof parent.entries !== 'function') { return Promise.resolve([]); } return new Promise((resolve, reject) => { const names = []; const it = parent.entries(); const step = () => { it.next().then((r) => { if (r.done) { resolve(names); } else { names.push(String(r.value[0])); step(); } }, (e) => reject(e)); }; step(); }); } catch (e) { return Promise.resolve([]); } })()", +) + +private fun collectSizesPromise(parent: JsAny): JsAny = js( + "(() => { try { if (!parent || typeof parent.entries !== 'function') { return Promise.resolve([]); } return new Promise((resolve, reject) => { const out = []; const outer = parent.entries(); const stepOuter = () => { outer.next().then((r) => { if (r.done) { resolve(out); return; } const fan = r.value[1]; if (!fan || fan.kind !== 'directory') { stepOuter(); return; } const inner = fan.entries(); const stepInner = () => { inner.next().then((r2) => { if (r2.done) { stepOuter(); return; } const fh = r2.value[1]; if (!fh || fh.kind !== 'file') { stepInner(); return; } fh.getFile().then((f) => { out.push([r2.value[0], f.size | 0]); stepInner(); }, () => stepInner()); }, (e) => reject(e)); }; stepInner(); }, (e) => reject(e)); }; stepOuter(); }); } catch (e) { return Promise.resolve([]); } })()", +) + +private fun removeEntryPromise(parent: JsAny, name: String): JsAny = js( + "(() => { try { return parent.removeEntry(name, { recursive: true }).then(() => true, () => false); } catch (e) { return Promise.resolve(false); } })()", +) + +private fun bridgeThen(promise: JsAny, onOk: (JsAny?) -> Unit, onErr: (JsAny) -> Unit): Unit = js( + "(() => { try { promise.then(onOk, onErr); } catch (e) { onErr(e); } })()", +) + +private fun resolvedNullPromise(): JsAny = js( + "(() => { return Promise.resolve(null); })()", +) + +private fun jsArrayLength(array: JsAny): Int = js( + "(() => { return array.length | 0; })()", +) + +private fun jsArrayItemAt(array: JsAny, index: Int): JsAny? = js( + "(() => { const v = array[index]; return (v === undefined || v === null) ? null : v; })()", +) + +private fun jsArrayStringAt(array: JsAny, index: Int): String? = js( + "(() => { const v = array[index]; return (typeof v === 'string') ? v : null; })()", +) + +private fun jsItemName(item: JsAny): String? = js( + "(() => { return (item && item.length > 0 && typeof item[0] === 'string') ? item[0] : null; })()", +) + +private fun jsItemSize(item: JsAny): Int = js( + "(() => { return (item && item.length > 1) ? (item[1] | 0) : 0; })()", +) + +private fun jsValueToString(value: JsAny?): String? = js( + "(() => { return (value === null || value === undefined) ? null : String(value); })()", +) + +private fun jsErrorMessage(error: JsAny): String = js( + "(() => { try { return (error && typeof error.message === 'string') ? error.message : String(error); } catch (e) { return 'unknown'; } })()", +) diff --git a/modules/orgflow-content-store/src/wasmJsTest/kotlin/jp/orgflow/contentstore/OpfsBlockStoreTest.kt b/modules/orgflow-content-store/src/wasmJsTest/kotlin/jp/orgflow/contentstore/OpfsBlockStoreTest.kt new file mode 100644 index 0000000..43b6a1a --- /dev/null +++ b/modules/orgflow-content-store/src/wasmJsTest/kotlin/jp/orgflow/contentstore/OpfsBlockStoreTest.kt @@ -0,0 +1,50 @@ +package jp.orgflow.contentstore + +import jp.orgflow.contentstore.wasm.OpfsBlockStore +import jp.orgflow.contentstore.wasm.binaryStringToBytes +import jp.orgflow.contentstore.wasm.bytesToBinaryString +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +// wasmJsNodeTest は nodejs で走るため OPFS が無く、ここでは MemoryBlockStore 縮退経路を検証する. +// アサーションは backend 非依存なので browser + OPFS でも同じ結果になる. + +class OpfsBlockStoreTest { + @Test + fun storeRoundTripsAndDedupes() = runTest { + val store = OpfsBlockStore() + val bytes = "wasm block".encodeToByteArray() + val cid = store.put(bytes) + assertTrue(store.contains(cid)) + assertTrue(bytes.contentEquals(store.get(cid)!!)) + assertEquals(cid, store.put(bytes.copyOf())) + assertEquals(1, store.size()) + assertEquals(1, store.stats().blockCount) + assertEquals(bytes.size.toLong(), store.stats().totalBytes) + assertNull(store.get(ContentAddresser.cidOf("missing".encodeToByteArray()))) + assertFalse(store.contains(ContentAddresser.cidOf("missing".encodeToByteArray()))) + } + + @Test + fun clearRemovesAllBlocks() = runTest { + val store = OpfsBlockStore() + store.put("one".encodeToByteArray()) + store.put("two!".encodeToByteArray()) + store.clear() + assertEquals(0, store.size()) + assertEquals(0L, store.stats().totalBytes) + assertNull(store.get(ContentAddresser.cidOf("one".encodeToByteArray()))) + } + + @Test + fun binaryStringCodecIsLosslessForAllByteValues() { + val bytes = ByteArray(256) { it.toByte() } + "orgflow".encodeToByteArray() + assertTrue(bytes.contentEquals(binaryStringToBytes(bytesToBinaryString(bytes)))) + assertEquals(0, binaryStringToBytes(bytesToBinaryString(ByteArray(0))).size) + assertEquals(bytes.size, bytesToBinaryString(bytes).length) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt index b84abaa..36299a8 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowApp.kt @@ -1,59 +1,216 @@ package jp.orgflow.ui +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField import androidx.compose.material.Scaffold +import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import jp.orgflow.ui.breadcrumb.BreadcrumbBar +import jp.orgflow.ui.model.SetFilterState +import jp.orgflow.ui.pie.PieMenu import jp.orgflow.ui.runtime.OrgFlowRuntime +import jp.orgflow.ui.runtime.provideRuntime import jp.orgflow.ui.store.OrgFlowAppStore +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin @Composable fun OrgFlowApp( runtime: OrgFlowRuntime = OrgFlowRuntime.UNAVAILABLE, navigation: OrgFlowNavigation = remember { OrgFlowNavigation() }, store: OrgFlowAppStore = remember { OrgFlowAppStore() }, + filter: SetFilterState = remember { SetFilterState() }, ) { + val effectiveRuntime = if (runtime === OrgFlowRuntime.UNAVAILABLE) remember { provideRuntime() } else runtime + LaunchedEffect(effectiveRuntime) { + if (runtime === OrgFlowRuntime.UNAVAILABLE) effectiveRuntime.distribution.start() + effectiveRuntime.config.config.collect { snapshot -> + store.updateConnectionConfig(snapshot.signalingEndpoint, snapshot.iceServers) + } + } + var showRoomDialog by remember { mutableStateOf(false) } OrgFlowTheme { Scaffold { padding -> Row(modifier = Modifier.fillMaxSize().padding(padding)) { - Column(modifier = Modifier.fillMaxHeight().width(160.dp)) { - Text("kukuri", fontSize = 18.sp, modifier = Modifier.padding(12.dp)) - OrgFlowRoute.all.forEach { route -> - Text( - text = route.id, - fontSize = 13.sp, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - .clickable { navigation.navigate(route) }, - color = if (route == navigation.current) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface, - ) - } - } - Column(modifier = Modifier.fillMaxSize()) { + LeftPanel( + navigation = navigation, + filter = filter, + onAddRoom = { showRoomDialog = true }, + modifier = Modifier.fillMaxHeight().width(140.dp).padding(8.dp), + ) + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + BreadcrumbBar(filter) when (navigation.current) { OrgFlowRoute.Onboarding -> jp.orgflow.ui.onboarding.OnboardingScreen() OrgFlowRoute.Home -> jp.orgflow.ui.home.HomeScreen(store) + OrgFlowRoute.Create -> jp.orgflow.ui.capture.CaptureScreen(store) OrgFlowRoute.Notes -> jp.orgflow.ui.notes.NotesScreen(store) - OrgFlowRoute.Capture -> jp.orgflow.ui.capture.CaptureScreen(store) - OrgFlowRoute.Agenda -> jp.orgflow.ui.agenda.AgendaScreen(store) OrgFlowRoute.Calendar -> jp.orgflow.ui.calendar.CalendarScreen(store) + OrgFlowRoute.Live -> jp.orgflow.ui.live.LiveScreen(effectiveRuntime.distribution, filter) OrgFlowRoute.Experiment -> jp.orgflow.ui.experiment.ExperimentTableScreen() OrgFlowRoute.Presentation -> jp.orgflow.ui.presentation.PresentationBuilderScreen() - OrgFlowRoute.Distribution -> jp.orgflow.ui.distribution.DistributionScreen(runtime.distribution) - OrgFlowRoute.Workspace -> jp.orgflow.ui.workspace.WorkspaceScreen() - OrgFlowRoute.Config -> jp.orgflow.ui.config.ConfigScreen(runtime.config) + OrgFlowRoute.Config -> jp.orgflow.ui.config.ConfigScreen(effectiveRuntime.config) } } + RightNav(modifier = Modifier.fillMaxHeight().width(140.dp), navigation = navigation) + } + } + } + if (showRoomDialog) { + RoomEntryDialog(onDismiss = { showRoomDialog = false }) + } +} + +@Composable +private fun LeftPanel( + navigation: OrgFlowNavigation, + filter: SetFilterState, + onAddRoom: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + PieMenu(filter = filter, modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.weight(1f)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + ConfigIconButton(navigation = navigation) + AddRoomButton(onClick = onAddRoom) + } + } +} + +@Composable +private fun ConfigIconButton(navigation: OrgFlowNavigation) { + IconButton(onClick = { navigation.navigate(OrgFlowRoute.Config) }) { + val tint = MaterialTheme.colors.onSurface + Canvas(modifier = Modifier.size(20.dp)) { + val w = this.size.width + val h = this.size.height + val cx = w / 2f + val cy = h / 2f + drawCircle(color = tint, radius = w * 0.26f, style = Stroke(width = 1.5.dp.toPx())) + drawCircle(color = tint, radius = w * 0.1f, style = Stroke(width = 1.5.dp.toPx())) + repeat(8) { i -> + val angle = (i * 45f) * PI.toFloat() / 180f + val cosA = cos(angle) + val sinA = sin(angle) + drawLine( + color = tint, + start = Offset(cx + cosA * w * 0.3f, cy + sinA * h * 0.3f), + end = Offset(cx + cosA * w * 0.42f, cy + sinA * h * 0.42f), + strokeWidth = 1.5.dp.toPx(), + cap = StrokeCap.Round, + ) + } + } + } +} + +@Composable +private fun AddRoomButton(onClick: () -> Unit) { + IconButton(onClick = onClick) { + val tint = MaterialTheme.colors.onSurface + Canvas(modifier = Modifier.size(20.dp)) { + val w = this.size.width + val h = this.size.height + drawLine(tint, Offset(w * 0.5f, h * 0.15f), Offset(w * 0.5f, h * 0.85f), strokeWidth = 2.dp.toPx(), cap = StrokeCap.Round) + drawLine(tint, Offset(w * 0.15f, h * 0.5f), Offset(w * 0.85f, h * 0.5f), strokeWidth = 2.dp.toPx(), cap = StrokeCap.Round) + } + } +} + +@Composable +private fun RightNav(navigation: OrgFlowNavigation, modifier: Modifier = Modifier) { + Column(modifier = modifier.padding(vertical = 12.dp)) { + Text("kukuri", fontSize = 18.sp, modifier = Modifier.padding(horizontal = 12.dp)) + Spacer(modifier = Modifier.height(8.dp)) + OrgFlowRoute.primary.forEach { route -> NavItem(navigation, route, compact = false) } + Spacer(modifier = Modifier.height(16.dp)) + OrgFlowRoute.secondary.forEach { route -> NavItem(navigation, route, compact = true) } + } +} + +@Composable +private fun NavItem(navigation: OrgFlowNavigation, route: OrgFlowRoute, compact: Boolean) { + val selected = navigation.current == route + Row( + modifier = Modifier + .fillMaxWidth() + .background( + color = if (selected) MaterialTheme.colors.primary.copy(alpha = 0.16f) else Color.Transparent, + ) + .clickable { navigation.navigate(route) } + .padding(horizontal = 12.dp, vertical = if (compact) 4.dp else 8.dp), + ) { + Text( + text = route.label, + fontSize = if (compact) 11.sp else 14.sp, + color = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface, + ) + } +} + +@Composable +private fun RoomEntryDialog(onDismiss: () -> Unit) { + var name by remember { mutableStateOf("") } + var payload by remember { mutableStateOf("") } + Dialog(onDismissRequest = onDismiss) { + Surface(shape = RoundedCornerShape(12.dp), elevation = 8.dp) { + Column(modifier = Modifier.padding(16.dp).width(320.dp)) { + Text("ルーム作成 / QR参加", fontSize = 15.sp) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("room name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + OutlinedTextField( + value = payload, + onValueChange = { payload = it }, + label = { Text("FSMP1 payload") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text("cancel") } + Button(onClick = onDismiss) { Text("confirm") } + } } } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt index 076fe54..cb706ab 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/OrgFlowRoute.kt @@ -1,21 +1,19 @@ package jp.orgflow.ui -sealed class OrgFlowRoute(val id: String) { - data object Onboarding : OrgFlowRoute("onboarding") - data object Home : OrgFlowRoute("home") - data object Notes : OrgFlowRoute("notes") - data object Capture : OrgFlowRoute("capture") - data object Agenda : OrgFlowRoute("agenda") - data object Calendar : OrgFlowRoute("calendar") - data object Experiment : OrgFlowRoute("experiment") - data object Presentation : OrgFlowRoute("presentation") - data object Distribution : OrgFlowRoute("distribution") - data object Workspace : OrgFlowRoute("workspace") - data object Config : OrgFlowRoute("config") +sealed class OrgFlowRoute(val id: String, val label: String = id) { + data object Onboarding : OrgFlowRoute("onboarding", "Onboarding") + data object Home : OrgFlowRoute("home", "Home") + data object Create : OrgFlowRoute("create", "Create") + data object Notes : OrgFlowRoute("note", "Note") + data object Calendar : OrgFlowRoute("calendar", "Calendar") + data object Live : OrgFlowRoute("live", "Live") + data object Experiment : OrgFlowRoute("experiment", "Experiment") + data object Presentation : OrgFlowRoute("presentation", "Presentation") + data object Config : OrgFlowRoute("config", "Config") companion object { - val all: List by lazy { - listOf(Home, Notes, Capture, Agenda, Calendar, Experiment, Presentation, Distribution, Workspace, Config) - } + val primary: List = listOf(Home, Create, Notes, Calendar, Live) + val secondary: List = listOf(Experiment, Presentation) + val all: List = primary + secondary } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/breadcrumb/BreadcrumbBar.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/breadcrumb/BreadcrumbBar.kt new file mode 100644 index 0000000..0cbcdb8 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/breadcrumb/BreadcrumbBar.kt @@ -0,0 +1,48 @@ +package jp.orgflow.ui.breadcrumb + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.ui.model.SetFilterState + +@Composable +fun BreadcrumbBar(filter: SetFilterState, modifier: Modifier = Modifier) { + val segments = filter.segments() + Row( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colors.surface) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + segments.forEachIndexed { index, segment -> + if (index > 0) { + Text( + text = " > ", + fontSize = 12.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + ) + } + val last = index == segments.lastIndex + Text( + text = segment.label, + fontSize = 13.sp, + fontWeight = if (last) FontWeight.Bold else FontWeight.Normal, + color = if (last) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface, + modifier = Modifier + .clickable { filter.select(segment.filter) } + .padding(horizontal = 2.dp), + ) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt index 5120b89..f3a72b9 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarEvent.kt @@ -30,6 +30,7 @@ data class CalendarEvent( val color: EventColor = colorOf(source), val roomName: String? = null, val time: LocalTime? = null, + val linkedNoteId: String? = null, ) { fun startsAt(): LocalDateTime = LocalDateTime(date, time ?: defaultStartTime) diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarScreen.kt index 6238908..76f4546 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarScreen.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.AlertDialog @@ -32,6 +34,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import jp.orgflow.ui.component.EmptyContent import jp.orgflow.ui.store.OrgFlowAppStore import kotlin.math.abs import kotlinx.datetime.LocalDate @@ -42,7 +45,6 @@ fun CalendarScreen( roomEventProvider: RoomEventProvider = RoomEventProvider.Empty, viewModel: CalendarViewModel = remember(store) { CalendarViewModel(store, roomEventProvider) }, ) { - var showAddDialog by remember { mutableStateOf(false) } var showSettingsDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -55,6 +57,36 @@ fun CalendarScreen( Text("Calendar", fontSize = 17.sp, modifier = Modifier.weight(1f)) TextButton(onClick = { showSettingsDialog = true }) { Text("Settings") } } + Row(verticalAlignment = Alignment.CenterVertically) { + CalendarTab.entries.forEach { tab -> + val selected = viewModel.calendarTab == tab + Text( + text = if (tab == CalendarTab.Tasks && viewModel.openTaskCount() > 0) { + "${tab.label} (${viewModel.openTaskCount()})" + } else { + tab.label + }, + fontSize = 14.sp, + fontWeight = if (selected) FontWeight.Bold else null, + color = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(end = 12.dp).clickable { viewModel.calendarTab = tab }, + ) + } + } + when (viewModel.calendarTab) { + CalendarTab.Calendar -> CalendarTabContent(viewModel) + CalendarTab.Tasks -> TaskList(viewModel) + } + } + + if (showSettingsDialog) { + NotificationSettingsDialog(viewModel, onDismiss = { showSettingsDialog = false }) + } +} + +@Composable +private fun CalendarTabContent(viewModel: CalendarViewModel) { + Column { Row(verticalAlignment = Alignment.CenterVertically) { TextButton(onClick = { viewModel.previous() }) { Text("<") } Text(viewModel.headerLabel(), fontSize = 14.sp, modifier = Modifier.weight(1f)) @@ -62,7 +94,6 @@ fun CalendarScreen( TextButton(onClick = { viewModel.toggleViewMode() }) { Text(if (viewModel.viewMode == CalendarViewMode.Month) "Week" else "Month") } - TextButton(onClick = { showAddDialog = true }) { Text("+ Add") } } Row(modifier = Modifier.padding(vertical = 4.dp)) { CalendarFilter.entries.forEach { candidate -> @@ -83,16 +114,27 @@ fun CalendarScreen( Spacer(modifier = Modifier.height(8.dp)) DayEventList(viewModel) } +} - if (showAddDialog) { - AddEventDialog( - defaultDate = viewModel.selectedDate.toString(), - onAdd = { title, date, time -> viewModel.addEvent(title, date, time) }, - onDismiss = { showAddDialog = false }, - ) - } - if (showSettingsDialog) { - NotificationSettingsDialog(viewModel, onDismiss = { showSettingsDialog = false }) +@Composable +private fun TaskList(viewModel: CalendarViewModel) { + if (viewModel.tasks.isEmpty()) { + EmptyContent("No tasks") + } else { + LazyColumn { + items(viewModel.tasks, key = { it.id }) { item -> + Row( + modifier = Modifier.padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = item.done, onCheckedChange = { viewModel.toggleTaskDone(item.id) }) + Column { + Text(item.title, fontSize = 14.sp) + Text(item.scheduledAt.toString(), fontSize = 11.sp) + } + } + } + } } } @@ -192,7 +234,7 @@ private fun DayEventList(viewModel: CalendarViewModel) { Text("Events on ${viewModel.selectedDate}", fontSize = 14.sp) if (events.isEmpty()) { Text( - "No events", + "No events — create them in the Create screen", fontSize = 12.sp, color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), modifier = Modifier.padding(top = 4.dp), @@ -221,47 +263,6 @@ private fun DayEventList(viewModel: CalendarViewModel) { } } -@Composable -private fun AddEventDialog( - defaultDate: String, - onAdd: (title: String, date: String, time: String) -> Boolean, - onDismiss: () -> Unit, -) { - var title by remember { mutableStateOf("") } - var date by remember { mutableStateOf(defaultDate) } - var time by remember { mutableStateOf("") } - var errorMessage by remember { mutableStateOf(null) } - AlertDialog( - title = { Text("Add event") }, - text = { - Column { - OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Title") }, singleLine = true) - OutlinedTextField( - value = date, - onValueChange = { date = it }, - label = { Text("Date (YYYY-MM-DD, today, tomorrow)") }, - singleLine = true, - ) - OutlinedTextField(value = time, onValueChange = { time = it }, label = { Text("Time (HH:MM, optional)") }, singleLine = true) - errorMessage?.let { - Text(it, fontSize = 11.sp, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 4.dp)) - } - } - }, - confirmButton = { - TextButton(onClick = { - if (onAdd(title, date, time)) { - onDismiss() - } else { - errorMessage = "Enter a valid date (YYYY-MM-DD) and optional time (HH:MM)" - } - }) { Text("Add") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - onDismissRequest = onDismiss, - ) -} - @Composable private fun NotificationSettingsDialog(viewModel: CalendarViewModel, onDismiss: () -> Unit) { val settings = viewModel.notificationSettings diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarViewModel.kt index 92b51d7..058825e 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/calendar/CalendarViewModel.kt @@ -24,6 +24,11 @@ enum class CalendarViewMode(val label: String) { Week("Week"), } +enum class CalendarTab(val label: String) { + Calendar("Calendar"), + Tasks("Tasks"), +} + class CalendarViewModel( private val store: OrgFlowAppStore = OrgFlowAppStore(), private val roomEventProvider: RoomEventProvider = RoomEventProvider.Empty, @@ -33,6 +38,7 @@ class CalendarViewModel( val today: LocalDate = now().date var viewMode: CalendarViewMode by mutableStateOf(CalendarViewMode.Month) + var calendarTab: CalendarTab by mutableStateOf(CalendarTab.Calendar) var filter: CalendarFilter by mutableStateOf(CalendarFilter.All) var month: LocalDate by mutableStateOf(today.withDay(1)) var selectedDate: LocalDate by mutableStateOf(today) @@ -51,6 +57,20 @@ class CalendarViewModel( val visibleEvents: List get() = events.filter { filter.visible(it) } + val tasks: List + get() = store.agendaItems + + fun tasksOn(date: LocalDate): List = + store.agendaItems.filter { it.scheduledAt.date == date }.sortedBy { it.scheduledAt.toString() } + + fun overdueTasks(): List = store.agendaItems.filter { !it.done && it.scheduledAt < now() } + + fun openTaskCount(): Int = store.agendaItems.count { !it.done } + + fun toggleTaskDone(id: String) { + store.toggleAgendaDone(id) + } + fun eventsOn(date: LocalDate): List = visibleEvents.filter { it.date == date } fun personalEvents(): List = buildList { @@ -141,7 +161,7 @@ class CalendarViewModel( store.updateNotificationSettings(notificationSettings.copy(targets = updated)) } - private fun agendaEvent(item: AgendaItem): CalendarEvent = CalendarEvent( + fun agendaEvent(item: AgendaItem): CalendarEvent = CalendarEvent( id = "agenda-${item.id}", title = item.title, date = item.scheduledAt.date, diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt index d7b4f05..13f4c48 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.material.FilterChip import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -22,7 +23,7 @@ fun CaptureScreen( viewModel: CaptureViewModel = remember(store) { CaptureViewModel(store) }, ) { Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { - Text("Capture", fontSize = 17.sp) + Text("Create", fontSize = 17.sp) Row(modifier = Modifier.padding(vertical = 6.dp)) { QuickCaptureShortcuts.options.forEach { option -> FilterChip( @@ -38,6 +39,18 @@ fun CaptureScreen( state = viewModel.form, onFieldChange = { field, value -> viewModel.updateField(field, value) }, ) + Row(modifier = Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Post to:", fontSize = 13.sp, modifier = Modifier.padding(end = 6.dp)) + PostTarget.entries.forEach { target -> + FilterChip( + selected = viewModel.postTarget == target, + onClick = { viewModel.selectPostTarget(target) }, + modifier = Modifier.padding(end = 6.dp), + ) { + Text(target.label, fontSize = 12.sp) + } + } + } Row(modifier = Modifier.padding(top = 8.dp)) { Button( onClick = { viewModel.submit() }, @@ -48,6 +61,10 @@ fun CaptureScreen( Text("org preview:", fontSize = 12.sp, modifier = Modifier.padding(top = 12.dp)) Text(result.orgSnippet, fontSize = 12.sp) result.activityId?.let { Text("saved as ${it.value}", fontSize = 11.sp) } + Row(modifier = Modifier.padding(top = 4.dp)) { + if (result.noteId != null) Text("Note: ${result.noteId} ", fontSize = 11.sp) + if (result.eventId != null) Text("Calendar: ${result.eventId}", fontSize = 11.sp) + } } } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt index 329720b..7a9a123 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/capture/CaptureViewModel.kt @@ -1,21 +1,43 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + package jp.orgflow.ui.capture import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import jp.orgflow.domain.identity.ActivityId +import jp.orgflow.ui.calendar.WhenTextParser import jp.orgflow.ui.store.OrgFlowAppStore +import kotlin.time.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +enum class PostTarget(val label: String) { + Note("Note"), + Calendar("Calendar"), + Both("Both"), +} data class CaptureUiResult( val orgSnippet: String, val templateType: String, val activityId: ActivityId? = null, + val target: PostTarget? = null, + val noteId: String? = null, + val eventId: String? = null, ) -class CaptureViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) { +class CaptureViewModel( + private val store: OrgFlowAppStore = OrgFlowAppStore(), + private val today: () -> LocalDate = { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date }, +) { var form: CaptureFormState by mutableStateOf(CaptureFormState(templateType = QuickCaptureShortcuts.defaultType())) private set + var postTarget: PostTarget by mutableStateOf(PostTarget.Note) + private set + var lastResult: CaptureUiResult? by mutableStateOf(null) private set @@ -23,6 +45,10 @@ class CaptureViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) { form = form.copy(templateType = type) } + fun selectPostTarget(target: PostTarget) { + postTarget = target + } + fun updateField(field: FiveW1HField, value: String) { form = form.copy(fields = form.fields + (field to value)) } @@ -31,17 +57,36 @@ class CaptureViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) { form = form.copy(attachments = form.attachments + cardId) } - fun submit(): Boolean { + fun submit(): Boolean = submit(postTarget) + + fun submit(target: PostTarget): Boolean { if (!form.isSubmittable()) return false + val whenText = form.fields[FiveW1HField.WHEN] ?: "" + val schedule = if (target == PostTarget.Note) null else WhenTextParser.parse(whenText, today()) + if (target != PostTarget.Note && schedule == null) return false + val what = form.fields[FiveW1HField.WHAT] ?: "" val activityId = store.addCapture(form.templateType, form.fields, form.attachments) - val whenText = form.fields[FiveW1HField.WHAT] ?: "" + var noteId: String? = null + var eventId: String? = null + when (target) { + PostTarget.Note -> noteId = store.addNote(what, what).id + PostTarget.Calendar -> schedule?.let { eventId = store.addCalendarEvent(what, it.first, it.second).id } + PostTarget.Both -> { + val note = store.addNote(what, what) + val event = schedule?.let { store.addCalendarEvent(what, it.first, it.second, linkedNoteId = note.id) } + if (event != null) store.linkNoteToEvent(note.id, event.id) + noteId = note.id + eventId = event?.id + } + } val snippet = buildString { - append("* ").append(form.templateType).append(": ").append(whenText.take(40)).appendLine() + append("* ").append(form.templateType).append(": ").append(what.take(40)).appendLine() form.fields[FiveW1HField.WHEN]?.takeIf { it.isNotBlank() }?.let { append(" SCHEDULED: <").append(it).appendLine(">") } form.fields[FiveW1HField.WHY]?.takeIf { it.isNotBlank() }?.let { append(" :WHY: ").append(it).appendLine(" :END:") } if (form.attachments.isNotEmpty()) append(" attachments: ").append(form.attachments.joinToString { it.value }).appendLine() + append(" posted to: ").append(target.label).appendLine() } - lastResult = CaptureUiResult(snippet, form.templateType, activityId) + lastResult = CaptureUiResult(snippet, form.templateType, activityId, target, noteId, eventId) return true } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigScreen.kt index 8f36191..c370d90 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigScreen.kt @@ -7,13 +7,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.Button +import androidx.compose.material.OutlinedTextField import androidx.compose.material.Slider import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -26,10 +29,45 @@ fun ConfigScreen( ) { val snapshot by runtime.config.collectAsState() val drafts = remember { mutableStateMapOf() } + var endpointDraft by remember { mutableStateOf(null) } + var iceDraft by remember { mutableStateOf(null) } Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { Text("Config (FsmpConfiguration)", fontSize = 17.sp) Text("config dir: ${snapshot.configDir}", fontSize = 12.sp) Text("saved at: ${snapshot.lastSavedAtMs}", fontSize = 11.sp) + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text("signaling endpoint: ${snapshot.signalingEndpoint}", fontSize = 12.sp) + OutlinedTextField( + value = endpointDraft ?: snapshot.signalingEndpoint, + onValueChange = { endpointDraft = it }, + label = { Text("ws://host:port/ws") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + viewModel.updateEndpoint(endpointDraft ?: snapshot.signalingEndpoint) + endpointDraft = null + }, + modifier = Modifier.padding(top = 4.dp), + ) { Text("save endpoint") } + } + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text("ice servers: ${snapshot.iceServers.joinToString()}", fontSize = 12.sp) + OutlinedTextField( + value = iceDraft ?: snapshot.iceServers.joinToString("\n"), + onValueChange = { iceDraft = it }, + label = { Text("one STUN/TURN url per line") }, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { + viewModel.updateIceServers((iceDraft ?: snapshot.iceServers.joinToString("\n")).lines()) + iceDraft = null + }, + modifier = Modifier.padding(top = 4.dp), + ) { Text("save ice servers") } + } snapshot.fields.forEach { field -> val draft = drafts[field.key] ?: field.value Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigViewModel.kt index 8e7f806..2ca5cd4 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/config/ConfigViewModel.kt @@ -9,4 +9,8 @@ class ConfigViewModel(private val runtime: ConfigRuntime) { private set fun update(fieldKey: String, value: Double) = runtime.update(fieldKey, value) + + fun updateEndpoint(endpoint: String) = runtime.updateEndpoint(endpoint) + + fun updateIceServers(servers: List) = runtime.updateIceServers(servers) } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt index f959875..9a49033 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeScreen.kt @@ -1,25 +1,47 @@ package jp.orgflow.ui.home +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import jp.orgflow.ui.store.Affiliation +import jp.orgflow.ui.store.JoinableRoom import jp.orgflow.ui.store.OrgFlowAppStore @Composable fun HomeScreen( store: OrgFlowAppStore = remember { OrgFlowAppStore() }, + onOpenCalendar: () -> Unit = {}, viewModel: HomeViewModel = remember(store) { HomeViewModel(store) }, ) { viewModel.refresh() - Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + LaunchedEffect(Unit) { + viewModel.checkDueNotifications() + } + Column(modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp)) { Text(viewModel.quickGreeting(), fontSize = 20.sp) Row(modifier = Modifier.padding(vertical = 8.dp)) { StatCard("Activities", viewModel.summary.activityCount) @@ -29,7 +51,10 @@ fun HomeScreen( viewModel.lastActivityId?.let { Text("Last activity: ${it.value}", fontSize = 12.sp) } - Text("Quick actions: capture a note, review today's agenda, open the experiment table.", fontSize = 13.sp) + AffiliationsSection(viewModel) + DueSoonSection(viewModel, onOpenCalendar) + WeekStripSection(viewModel) + RoomsSection(viewModel) } } @@ -42,3 +67,157 @@ private fun StatCard(label: String, count: Int) { } } } + +@Composable +private fun SectionHeader(title: String) { + Text( + title, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) +} + +@Composable +private fun AffiliationsSection(viewModel: HomeViewModel) { + SectionHeader("Affiliations") + if (viewModel.affiliations.isEmpty()) { + Text( + "No affiliations yet", + fontSize = 12.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + } else { + Row { + viewModel.affiliations.forEach { affiliation -> AffiliationChip(affiliation) } + } + } +} + +@Composable +private fun AffiliationChip(affiliation: Affiliation) { + Box( + modifier = Modifier + .padding(end = 6.dp) + .background(Color(affiliation.colorArgb), RoundedCornerShape(10.dp)) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text(affiliation.name, fontSize = 12.sp, color = Color.White) + } +} + +@Composable +private fun DueSoonSection(viewModel: HomeViewModel, onOpenCalendar: () -> Unit) { + SectionHeader("Due soon") + val items = viewModel.dueSoonItems() + if (items.isEmpty()) { + Text( + "Nothing due", + fontSize = 12.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + } else { + Column { + items.forEach { item -> DueSoonRow(item, onOpenCalendar) } + } + } +} + +@Composable +private fun DueSoonRow(item: DueSoonItem, onOpenCalendar: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onOpenCalendar).padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .padding(end = 6.dp) + .size(8.dp) + .background(MaterialTheme.colors.primary, CircleShape), + ) + Column(modifier = Modifier.weight(1f)) { + Text(item.title, fontSize = 14.sp) + Text( + "${item.at} · ${dueLabel(item.daysUntil)}", + fontSize = 11.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + } + } +} + +@Composable +private fun WeekStripSection(viewModel: HomeViewModel) { + SectionHeader("Next 7 days") + Row(modifier = Modifier.fillMaxWidth()) { + viewModel.weekDays().forEach { day -> + Column( + modifier = Modifier.weight(1f).padding(horizontal = 1.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + day.dayOfWeek.name.take(3), + fontSize = 10.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + Text( + day.dayOfMonth.toString(), + fontSize = 13.sp, + fontWeight = if (day == viewModel.today()) FontWeight.Bold else null, + color = if (day == viewModel.today()) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface, + ) + viewModel.entriesOn(day).take(2).forEach { event -> + Text( + event.title, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.8f), + ) + } + } + } + } +} + +@Composable +private fun RoomsSection(viewModel: HomeViewModel) { + SectionHeader("Rooms to join") + if (viewModel.joinableRooms.isEmpty()) { + Text( + "No rooms available", + fontSize = 12.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + return + } + viewModel.joinableRooms.forEach { room -> RoomCard(viewModel, room) } +} + +@Composable +private fun RoomCard(viewModel: HomeViewModel, room: JoinableRoom) { + Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 2.dp) { + Row(modifier = Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(room.name, fontSize = 14.sp) + Text( + "${room.memberCount} members", + fontSize = 11.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + ) + } + if (room.id in viewModel.joinedRoomIds) { + Text("Joined", fontSize = 12.sp, color = MaterialTheme.colors.primary) + } else { + TextButton(onClick = { viewModel.joinRoom(room.id) }) { Text("参加") } + } + TextButton(onClick = {}) { Text("見る") } + } + } +} + +private fun dueLabel(daysUntil: Int): String = when (daysUntil) { + 0 -> "Today" + 1 -> "Tomorrow" + else -> "in $daysUntil days" +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt index f5588b7..a90497a 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/home/HomeViewModel.kt @@ -1,10 +1,23 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + package jp.orgflow.ui.home import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import jp.orgflow.domain.identity.ActivityId +import jp.orgflow.ui.calendar.CalendarEvent +import jp.orgflow.ui.calendar.CalendarViewModel +import jp.orgflow.ui.notification.Notifier +import jp.orgflow.ui.notification.defaultNotifier +import jp.orgflow.ui.store.Affiliation +import jp.orgflow.ui.store.JoinableRoom import jp.orgflow.ui.store.OrgFlowAppStore +import kotlin.time.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime data class HomeSummary( val activityCount: Int, @@ -12,12 +25,25 @@ data class HomeSummary( val taskOpen: Int, ) -class HomeViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) { +data class DueSoonItem( + val id: String, + val title: String, + val at: LocalDateTime, + val daysUntil: Int, +) + +class HomeViewModel( + private val store: OrgFlowAppStore = OrgFlowAppStore(), + private val notifier: Notifier = defaultNotifier(), + private val now: () -> LocalDateTime = { Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()) }, +) { var summary: HomeSummary by mutableStateOf(HomeSummary(0, 0, 0)) private set var lastActivityId: ActivityId? by mutableStateOf(null) private set + private val calendar = CalendarViewModel(store, notifier = notifier, now = now) + fun refresh(activities: Int? = null, notes: Int? = null, openTasks: Int? = null) { summary = HomeSummary( activities ?: store.activities.size, @@ -28,4 +54,42 @@ class HomeViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore()) { } fun quickGreeting(): String = "Welcome to OrgFlow" + + fun today(): LocalDate = now().date + + fun dueSoonItems(): List { + val todayDays = today().toEpochDays() + val items = buildList { + store.agendaItems.filter { !it.done }.forEach { item -> + add(DueSoonItem("agenda-${item.id}", item.title, item.scheduledAt, (item.scheduledAt.date.toEpochDays() - todayDays).toInt())) + } + store.calendarEvents.forEach { event -> + val at = event.startsAt() + add(DueSoonItem(event.id, event.title, at, (at.date.toEpochDays() - todayDays).toInt())) + } + } + return items.filter { it.daysUntil >= 0 }.sortedWith(compareBy({ it.at }, { it.title })) + } + + fun weekDays(): List = (0..6).map { LocalDate.fromEpochDays(today().toEpochDays() + it) } + + fun entriesOn(date: LocalDate): List { + val fromAgenda = store.agendaItems.filter { it.scheduledAt.date == date }.map(calendar::agendaEvent) + return (store.calendarEvents.filter { it.date == date } + fromAgenda).sortedBy { it.startsAt() } + } + + fun checkDueNotifications(): List = calendar.checkDueNotifications() + + val joinableRooms: List + get() = store.joinableRooms + + val joinedRoomIds: Set + get() = store.joinedRoomIds + + val affiliations: List + get() = store.affiliations + + fun joinRoom(id: String) { + store.joinRoom(id) + } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/live/LiveScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/live/LiveScreen.kt new file mode 100644 index 0000000..86789ea --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/live/LiveScreen.kt @@ -0,0 +1,54 @@ +package jp.orgflow.ui.live + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Checkbox +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import jp.orgflow.ui.distribution.DistributionScreen +import jp.orgflow.ui.distribution.DistributionViewModel +import jp.orgflow.ui.model.SetFilterState +import jp.orgflow.ui.runtime.DistributionRuntime + +@Composable +fun LiveScreen( + runtime: DistributionRuntime, + filter: SetFilterState, + viewModel: DistributionViewModel = remember(runtime) { DistributionViewModel(runtime) }, +) { + var screenShare by remember { mutableStateOf(false) } + var camera by remember { mutableStateOf(false) } + Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { + Text("Live", fontSize = 17.sp) + Row( + modifier = Modifier.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("source:", fontSize = 12.sp) + SourceToggle("画面共有", screenShare) { screenShare = it } + SourceToggle("カメラ", camera) { camera = it } + Text("配信先: ${filter.label()}", fontSize = 12.sp) + } + DistributionScreen(runtime = runtime, viewModel = viewModel) + } +} + +@Composable +private fun SourceToggle(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = checked, onCheckedChange = onCheckedChange) + Text(label, fontSize = 13.sp) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/DynamicGroup.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/DynamicGroup.kt new file mode 100644 index 0000000..f19925d --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/DynamicGroup.kt @@ -0,0 +1,32 @@ +package jp.orgflow.ui.model + +sealed interface MembershipRule { + data object All : MembershipRule + data class Grade(val grade: String) : MembershipRule + data class Classroom(val grade: String, val className: String) : MembershipRule +} + +data class DynamicGroup( + val gid: String, + val rule: MembershipRule, +) + +object DynamicMembership { + const val SEPARATOR = "-" + + fun gradeOf(user: User): String? = segmentOf(user, 0) + + fun classOf(user: User): String? = segmentOf(user, 1) + + fun resolve(group: DynamicGroup, user: User): Membership? { + val matched = when (val rule = group.rule) { + MembershipRule.All -> true + is MembershipRule.Grade -> gradeOf(user) == rule.grade + is MembershipRule.Classroom -> gradeOf(user) == rule.grade && classOf(user) == rule.className + } + return if (matched) Membership(user.uid, group.gid, "member", MembershipStatus.AUTO_JOIN) else null + } + + private fun segmentOf(user: User, index: Int): String? = + user.schoolId.split(SEPARATOR).getOrNull(index)?.takeIf { it.isNotBlank() } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Group.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Group.kt new file mode 100644 index 0000000..a143614 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Group.kt @@ -0,0 +1,14 @@ +package jp.orgflow.ui.model + +enum class GroupVisibility(val label: String) { + OPEN("公開"), + CLOSED("非公開"), +} + +data class Group( + val gid: String, + val name: String, + val type: GroupType, + val parent: String? = null, + val visibility: GroupVisibility = GroupVisibility.OPEN, +) diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupColor.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupColor.kt new file mode 100644 index 0000000..7a0208d --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupColor.kt @@ -0,0 +1,31 @@ +package jp.orgflow.ui.model + +import androidx.compose.ui.graphics.Color + +object GroupColor { + val paletteSize: Int get() = palette.size + + fun colorOf(gid: String): Color = palette[indexFor(gid)] + + fun indexFor(gid: String): Int { + var hash = FNV_OFFSET_BASIS + gid.forEach { char -> + hash = (hash xor char.code.toLong()) * FNV_PRIME + } + return hash.mod(palette.size) + } + + private val palette = listOf( + Color(0xFF3F51B5), + Color(0xFF00897B), + Color(0xFFF57C00), + Color(0xFF7B1FA2), + Color(0xFFC62828), + Color(0xFF1976D2), + Color(0xFF388E3C), + Color(0xFF5D4037), + ) + + private const val FNV_OFFSET_BASIS = -0x340d631b7bdddcdbL + private const val FNV_PRIME = 0x100000001b3L +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupDirectory.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupDirectory.kt new file mode 100644 index 0000000..ccda75c --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupDirectory.kt @@ -0,0 +1,50 @@ +package jp.orgflow.ui.model + +object GroupDirectory { + val currentUser = User("u-self", "自分", "2-A-15") + + val users = listOf( + currentUser, + User("u-tanaka", "田中", "2-A-03"), + User("u-suzuki", "鈴木", "2-B-11"), + User("u-sato", "佐藤", "2-A-07"), + User("u-yamada", "山田", "3-C-02"), + ) + + val groups = listOf( + Group("g-whole", "全体", GroupType.WHOLE), + Group("g-class-2a", "クラス2-A", GroupType.CLASS, parent = "g-whole"), + Group("g-class-2b", "クラス2-B", GroupType.CLASS, parent = "g-whole"), + Group("g-club-art", "美術部", GroupType.CLUB, parent = "g-whole"), + Group("g-club-jazz", "軽音部", GroupType.CLUB, parent = "g-whole"), + Group("g-committee-culture", "文化実行委員会", GroupType.COMMITTEE, parent = "g-whole", visibility = GroupVisibility.CLOSED), + ) + + val dynamicGroups = listOf( + DynamicGroup("g-dyn-grade2", MembershipRule.Grade("2")), + DynamicGroup("g-dyn-class-2a", MembershipRule.Classroom("2", "A")), + ) + + val memberships = listOf( + Membership(currentUser.uid, "g-whole", "member", MembershipStatus.AUTO_JOIN), + Membership(currentUser.uid, "g-class-2a", "member", MembershipStatus.AUTO_JOIN), + Membership(currentUser.uid, "g-club-art", "member", MembershipStatus.JOIN), + Membership(currentUser.uid, "g-committee-culture", "member", MembershipStatus.VIEW_ONLY), + Membership("u-tanaka", "g-class-2a", "member", MembershipStatus.AUTO_JOIN), + Membership("u-suzuki", "g-class-2b", "leader", MembershipStatus.AUTO_JOIN), + Membership("u-sato", "g-class-2a", "member", MembershipStatus.AUTO_JOIN), + Membership("u-yamada", "g-club-jazz", "member", MembershipStatus.JOIN), + ) + + fun groupOf(gid: String): Group? = groups.firstOrNull { it.gid == gid } + + fun groupsOf(type: GroupType): List = groups.filter { it.type == type } + + fun membershipOf(uid: String, gid: String): Membership? = + memberships.firstOrNull { it.uid == uid && it.gid == gid } + + fun statusOf(uid: String, gid: String): MembershipStatus? = membershipOf(uid, gid)?.status + + fun dynamicMemberships(user: User): List = + dynamicGroups.mapNotNull { DynamicMembership.resolve(it, user) } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupType.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupType.kt new file mode 100644 index 0000000..06eae35 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/GroupType.kt @@ -0,0 +1,9 @@ +package jp.orgflow.ui.model + +enum class GroupType(val label: String) { + WHOLE("全体"), + CLASS("クラス"), + CLUB("部活動"), + COMMITTEE("委員会"), + PERSONAL("自分"), +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/JoinButtons.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/JoinButtons.kt new file mode 100644 index 0000000..e6068e5 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/JoinButtons.kt @@ -0,0 +1,26 @@ +package jp.orgflow.ui.model + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Button +import androidx.compose.material.OutlinedButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun JoinButtons( + joined: Boolean, + onView: () -> Unit, + onJoin: () -> Unit, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + OutlinedButton(onClick = onView) { Text("見る", fontSize = 12.sp) } + Button(onClick = onJoin, enabled = !joined) { + Text(if (joined) "参加済み" else "参加", fontSize = 12.sp) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Membership.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Membership.kt new file mode 100644 index 0000000..b8365d3 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/Membership.kt @@ -0,0 +1,8 @@ +package jp.orgflow.ui.model + +data class Membership( + val uid: String, + val gid: String, + val role: String, + val status: MembershipStatus, +) diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipIcons.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipIcons.kt new file mode 100644 index 0000000..1ed3e3e --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipIcons.kt @@ -0,0 +1,61 @@ +package jp.orgflow.ui.model + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +enum class MembershipIcon { FULL_MOON, HALF_MOON, CIRCLE, KEY, CHECK } + +fun MembershipStatus.toIcon(): MembershipIcon = when (this) { + MembershipStatus.JOIN -> MembershipIcon.CIRCLE + MembershipStatus.AUTO_JOIN -> MembershipIcon.FULL_MOON + MembershipStatus.VIEW_ONLY -> MembershipIcon.HALF_MOON + MembershipStatus.HIDDEN -> MembershipIcon.KEY +} + +fun iconFor(status: MembershipStatus?): MembershipIcon = + status?.toIcon() ?: MembershipIcon.CIRCLE + +fun Group.iconForVisibility(): MembershipIcon? = + if (visibility == GroupVisibility.CLOSED) MembershipIcon.KEY else null + +@Composable +fun MembershipIconView(icon: MembershipIcon, color: Color, iconSize: Dp = 16.dp) { + Canvas(modifier = Modifier.size(iconSize)) { + val stroke = Stroke(width = 1.5.dp.toPx(), cap = StrokeCap.Round) + when (icon) { + MembershipIcon.FULL_MOON -> drawCircle(color) + MembershipIcon.HALF_MOON -> { + drawCircle(color, style = stroke) + drawArc(color, startAngle = -90f, sweepAngle = 180f, useCenter = true) + } + MembershipIcon.CIRCLE -> drawCircle(color, style = stroke) + MembershipIcon.KEY -> { + val w = this.size.width + val h = this.size.height + drawCircle( + color = color, + radius = w * 0.18f, + center = Offset(w * 0.32f, h * 0.5f), + style = stroke, + ) + drawLine(color, Offset(w * 0.5f, h * 0.5f), Offset(w * 0.85f, h * 0.5f), strokeWidth = stroke.width, cap = StrokeCap.Round) + drawLine(color, Offset(w * 0.68f, h * 0.5f), Offset(w * 0.68f, h * 0.7f), strokeWidth = stroke.width, cap = StrokeCap.Round) + drawLine(color, Offset(w * 0.85f, h * 0.5f), Offset(w * 0.85f, h * 0.7f), strokeWidth = stroke.width, cap = StrokeCap.Round) + } + MembershipIcon.CHECK -> { + val w = this.size.width + val h = this.size.height + drawLine(color, Offset(w * 0.2f, h * 0.52f), Offset(w * 0.42f, h * 0.74f), strokeWidth = stroke.width, cap = StrokeCap.Round) + drawLine(color, Offset(w * 0.42f, h * 0.74f), Offset(w * 0.8f, h * 0.26f), strokeWidth = stroke.width, cap = StrokeCap.Round) + } + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipStatus.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipStatus.kt new file mode 100644 index 0000000..2a60d9c --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/MembershipStatus.kt @@ -0,0 +1,8 @@ +package jp.orgflow.ui.model + +enum class MembershipStatus(val label: String) { + JOIN("参加"), + AUTO_JOIN("自動参加"), + VIEW_ONLY("閲覧のみ"), + HIDDEN("非表示"), +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/SetFilterState.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/SetFilterState.kt new file mode 100644 index 0000000..0325f63 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/SetFilterState.kt @@ -0,0 +1,56 @@ +package jp.orgflow.ui.model + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +sealed interface SetFilter { + data object Whole : SetFilter + data object Self : SetFilter + data class Type(val type: GroupType) : SetFilter + data class Group(val gid: String) : SetFilter +} + +data class FilterSegment(val label: String, val filter: SetFilter) + +class SetFilterState(initial: SetFilter = SetFilter.Whole) { + var current: SetFilter by mutableStateOf(initial) + private set + + fun select(filter: SetFilter) { + current = filter + } + + fun reset() { + current = SetFilter.Whole + } + + fun groupId(): String? = (current as? SetFilter.Group)?.gid + + fun label(): String = when (val filter = current) { + SetFilter.Whole -> GroupType.WHOLE.label + SetFilter.Self -> GroupType.PERSONAL.label + is SetFilter.Type -> filter.type.label + is SetFilter.Group -> GroupDirectory.groupOf(filter.gid)?.name ?: filter.gid + } + + fun segments(): List = when (val filter = current) { + SetFilter.Whole -> listOf(FilterSegment(GroupType.WHOLE.label, SetFilter.Whole)) + SetFilter.Self -> listOf( + FilterSegment(GroupType.WHOLE.label, SetFilter.Whole), + FilterSegment(GroupType.PERSONAL.label, SetFilter.Self), + ) + is SetFilter.Type -> listOf( + FilterSegment(GroupType.WHOLE.label, SetFilter.Whole), + FilterSegment(filter.type.label, filter), + ) + is SetFilter.Group -> { + val group = GroupDirectory.groupOf(filter.gid) + listOf( + FilterSegment(GroupType.WHOLE.label, SetFilter.Whole), + FilterSegment(group?.type?.label ?: "", SetFilter.Type(group?.type ?: GroupType.WHOLE)), + FilterSegment(group?.name ?: filter.gid, filter), + ) + } + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/User.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/User.kt new file mode 100644 index 0000000..063488c --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/model/User.kt @@ -0,0 +1,7 @@ +package jp.orgflow.ui.model + +data class User( + val uid: String, + val displayName: String, + val schoolId: String, +) diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteCards.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteCards.kt new file mode 100644 index 0000000..efe044a --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteCards.kt @@ -0,0 +1,292 @@ +package jp.orgflow.ui.notes + +enum class CardType(val id: String) { + TEXT("text"), + INK("ink"), + GRAPH("graph"); + + companion object { + fun fromId(id: String?): CardType = entries.firstOrNull { it.id == id } ?: TEXT + } +} + +data class CardModel( + val id: String, + val type: CardType, + val x: Int, + val y: Int, + val w: Int, + val h: Int, + val text: String, + val color: String, +) + +enum class NoteScope(val label: String, val colorHex: String) { + SELF("自分", "#7E57C2"), + EVERYONE("全体", "#43A047"), + CLASS("クラス", "#1E88E5"), + CLUB("部活動", "#FB8C00"), + COMMITTEE("委員会", "#D81B60"); + + companion object { + fun fromLabel(label: String?): NoteScope = entries.firstOrNull { it.label == label } ?: SELF + } +} + +data class NoteDocument( + val scope: NoteScope = NoteScope.SELF, + val collaborationActive: Boolean = false, + val cards: List = emptyList(), +) { + fun card(id: String): CardModel? = cards.firstOrNull { it.id == id } + + companion object { + const val DEFAULT_CARD_WIDTH = 170 + const val DEFAULT_CARD_HEIGHT = 100 + const val DEFAULT_CARD_COLOR = "#FFF59D" + const val CARD_ORIGIN = 16 + + fun fromPlainBody(body: String): NoteDocument = NoteDocument( + cards = listOf( + CardModel( + id = "card-1", + type = CardType.TEXT, + x = CARD_ORIGIN, + y = CARD_ORIGIN, + w = DEFAULT_CARD_WIDTH, + h = DEFAULT_CARD_HEIGHT, + text = body, + color = DEFAULT_CARD_COLOR, + ), + ), + ) + } +} + +data class NoteBundle( + val title: String, + val document: NoteDocument, + val remaining: NoteDocument, +) + +fun NoteDocument.bundleSelected(selectedIds: Set): NoteBundle? { + if (selectedIds.size < 2) return null + val selected = cards.filter { it.id in selectedIds } + if (selected.size < 2) return null + val title = selected.first().text.lineSequence().firstOrNull { it.isNotBlank() }?.take(24)?.trim().orEmpty() + return NoteBundle( + title = title.ifBlank { "クリップノート" }, + document = copy(cards = selected, collaborationActive = false), + remaining = copy(cards = cards - selected.toSet()), + ) +} + +object NoteDocumentJson { + fun encode(document: NoteDocument): String = buildString { + append("{\"v\":1,\"scope\":") + appendQuoted(document.scope.label) + append(",\"collab\":") + append(document.collaborationActive) + append(",\"cards\":[") + document.cards.forEachIndexed { index, card -> + if (index > 0) append(",") + appendCard(card) + } + append("]}") + } + + fun decode(body: String): NoteDocument? { + val trimmed = body.trim() + if (!trimmed.startsWith("{")) return null + return try { + val root = NoteJsonParser(trimmed).parse() as? Map<*, *> ?: return null + val cardList = root["cards"] as? List<*> ?: return null + NoteDocument( + scope = NoteScope.fromLabel(root["scope"] as? String), + collaborationActive = root["collab"] as? Boolean ?: false, + cards = cardList.mapNotNull { entry -> + val map = entry as? Map<*, *> ?: return@mapNotNull null + val id = map["id"] as? String ?: return@mapNotNull null + CardModel( + id = id, + type = CardType.fromId(map["type"] as? String), + x = (map["x"] as? Number)?.toInt() ?: 0, + y = (map["y"] as? Number)?.toInt() ?: 0, + w = (map["w"] as? Number)?.toInt() ?: NoteDocument.DEFAULT_CARD_WIDTH, + h = (map["h"] as? Number)?.toInt() ?: NoteDocument.DEFAULT_CARD_HEIGHT, + text = map["text"] as? String ?: "", + color = map["color"] as? String ?: NoteDocument.DEFAULT_CARD_COLOR, + ) + }, + ) + } catch (_: Exception) { + null + } + } + + private fun StringBuilder.appendCard(card: CardModel) { + append("{\"id\":") + appendQuoted(card.id) + append(",\"type\":") + appendQuoted(card.type.id) + append(",\"x\":") + append(card.x) + append(",\"y\":") + append(card.y) + append(",\"w\":") + append(card.w) + append(",\"h\":") + append(card.h) + append(",\"text\":") + appendQuoted(card.text) + append(",\"color\":") + appendQuoted(card.color) + append("}") + } + + private fun StringBuilder.appendQuoted(value: String) { + append('"') + value.forEach { c -> + when { + c == '"' -> append("\\\"") + c == '\\' -> append("\\\\") + c == '\n' -> append("\\n") + c == '\r' -> append("\\r") + c == '\t' -> append("\\t") + c == '\b' -> append("\\b") + c == '\u000C' -> append("\\f") + c < ' ' -> { + append("\\u") + append(c.code.toString(16).padStart(4, '0')) + } + else -> append(c) + } + } + append('"') + } +} + +private class NoteJsonException : Exception() + +private class NoteJsonParser(private val text: String) { + private var index = 0 + + fun parse(): Any? { + val value = parseValue() + skipWhitespace() + if (index != text.length) throw NoteJsonException() + return value + } + + private fun parseValue(): Any? { + skipWhitespace() + return when (text.getOrNull(index)) { + '{' -> parseObject() + '[' -> parseArray() + '"' -> parseString() + 't' -> parseLiteral("true", true) + 'f' -> parseLiteral("false", false) + 'n' -> parseLiteral("null", null) + else -> parseNumber() + } + } + + private fun parseObject(): Map { + index++ + val result = mutableMapOf() + skipWhitespace() + if (consumeIf('}')) return result + while (true) { + skipWhitespace() + val key = parseString() + skipWhitespace() + expect(':') + result[key] = parseValue() + skipWhitespace() + if (consumeIf(',')) continue + expect('}') + return result + } + } + + private fun parseArray(): List { + index++ + val result = mutableListOf() + skipWhitespace() + if (consumeIf(']')) return result + while (true) { + result.add(parseValue()) + skipWhitespace() + if (consumeIf(',')) continue + expect(']') + return result + } + } + + private fun parseString(): String { + expect('"') + val builder = StringBuilder() + while (true) { + val c = text.getOrNull(index) ?: throw NoteJsonException() + index++ + when (c) { + '"' -> return builder.toString() + '\\' -> builder.append(parseEscape()) + else -> builder.append(c) + } + } + } + + private fun parseEscape(): Char { + val c = text.getOrNull(index) ?: throw NoteJsonException() + index++ + return when (c) { + '"' -> '"' + '\\' -> '\\' + '/' -> '/' + 'n' -> '\n' + 't' -> '\t' + 'r' -> '\r' + 'b' -> '\b' + 'f' -> '\u000C' + 'u' -> { + if (index + 4 > text.length) throw NoteJsonException() + val token = text.substring(index, index + 4) + index += 4 + token.toIntOrNull(16)?.toChar() ?: throw NoteJsonException() + } + else -> throw NoteJsonException() + } + } + + private fun parseNumber(): Any { + val start = index + if (text.getOrNull(index) == '-') index++ + while (index < text.length && (text[index].isDigit() || text[index] in ".eE+-")) index++ + val token = text.substring(start, index) + return token.toLongOrNull() ?: token.toDoubleOrNull() ?: throw NoteJsonException() + } + + private fun parseLiteral(literal: String, value: Any?): Any? { + if (!text.startsWith(literal, index)) throw NoteJsonException() + index += literal.length + return value + } + + private fun skipWhitespace() { + while (index < text.length && text[index] in " \t\n\r") index++ + } + + private fun consumeIf(expected: Char): Boolean { + if (text.getOrNull(index) == expected) { + index++ + return true + } + return false + } + + private fun expect(expected: Char) { + if (text.getOrNull(index) != expected) throw NoteJsonException() + index++ + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt index 5e88413..ae53cb7 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorScreen.kt @@ -1,17 +1,45 @@ package jp.orgflow.ui.notes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import jp.orgflow.ui.component.SourceCommitBadge +import kotlin.math.roundToInt @Composable fun NoteEditorScreen(viewModel: NoteEditorViewModel = remember { NoteEditorViewModel() }) { @@ -22,13 +50,184 @@ fun NoteEditorScreen(viewModel: NoteEditorViewModel = remember { NoteEditorViewM SourceCommitBadge(null) } if (note != null) { - OrgRichTextEditor( - state = viewModel.editor, - onContentChanged = { }, - ) - TextButton(onClick = { viewModel.saveCurrent() }) { Text("Save (commit candidate)") } + NoteDetailHeader(viewModel) + NoteCardBoard(viewModel) } else { Text("Select a note from the list first.", fontSize = 13.sp) } } } + +@Composable +private fun NoteDetailHeader(viewModel: NoteEditorViewModel) { + var scopeMenuOpen by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box { + ScopeBadge(scope = viewModel.document.scope, onClick = { if (viewModel.editable) scopeMenuOpen = true }) + DropdownMenu(expanded = scopeMenuOpen, onDismissRequest = { scopeMenuOpen = false }) { + NoteScope.entries.forEach { scope -> + DropdownMenuItem(onClick = { + viewModel.setScope(scope) + scopeMenuOpen = false + }) { + ScopeBadge(scope) + } + } + } + } + if (viewModel.document.collaborationActive) { + CollabBadge() + } else { + TextButton(onClick = { viewModel.startCollaboration() }) { Text("共同編集を開始") } + } + TextButton(onClick = { viewModel.toggleEditable() }) { + Text(if (viewModel.editable) "編集中" else "閲覧") + } + TextButton(onClick = { viewModel.toggleSelectMode() }, enabled = viewModel.editable) { + Text(if (viewModel.selectMode) "選択中" else "選択", color = if (viewModel.selectMode) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface) + } + TextButton(onClick = { viewModel.saveCurrent() }) { Text("Save (commit candidate)") } + } +} + +@Composable +private fun NoteCardBoard(viewModel: NoteEditorViewModel) { + var boardSize by remember { mutableStateOf(IntSize.Zero) } + Column(modifier = Modifier.fillMaxSize().padding(top = 4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TextButton(onClick = { viewModel.addCard() }, enabled = viewModel.editable) { + Text("+ テキストカード") + } + if (viewModel.selectedCardIds.size >= 2) { + TextButton(onClick = { viewModel.bundleSelectedCards() }, enabled = viewModel.editable) { + Text("クリップ (${viewModel.selectedCardIds.size})") + } + TextButton(onClick = { viewModel.clearCardSelection() }) { Text("解除") } + } + } + Box( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { boardSize = it } + .background(Color(0xFFF6F4EE)) + .pointerInput(Unit) { + detectTapGestures(onTap = { + viewModel.endCardEdit() + viewModel.clearCardSelection() + }) + }, + ) { + if (viewModel.document.cards.isEmpty()) { + Text( + "カードがありません — 編集モードで「+ テキストカード」を追加", + fontSize = 12.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + modifier = Modifier.align(Alignment.Center).padding(12.dp), + ) + } + viewModel.document.cards.forEach { card -> + NoteCardView(viewModel, card, boardSize) + } + } + } +} + +@Composable +private fun NoteCardView(viewModel: NoteEditorViewModel, card: CardModel, boardSize: IntSize) { + val selected = card.id in viewModel.selectedCardIds + val editing = viewModel.editingCardId == card.id + Box( + modifier = Modifier + .offset { IntOffset(card.x, card.y) } + .size(card.w.dp, card.h.dp) + .clip(RoundedCornerShape(6.dp)) + .background(parseHexColor(card.color)) + .border( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colors.primary else Color(0xFF9E9E9E), + shape = RoundedCornerShape(6.dp), + ) + .pointerInput(card.id, viewModel.selectMode, viewModel.editable) { + detectTapGestures( + onTap = { + when { + viewModel.selectMode -> viewModel.toggleCardSelection(card.id) + viewModel.editable -> viewModel.beginCardEdit(card.id) + else -> Unit + } + }, + onLongPress = { viewModel.toggleCardSelection(card.id) }, + ) + } + .pointerInput(card.id, viewModel.editable) { + if (viewModel.editable) { + detectDragGestures( + onDragStart = { viewModel.endCardEdit() }, + onDrag = { change, dragAmount -> + change.consume() + viewModel.dragCard( + cardId = card.id, + dx = dragAmount.x.roundToInt(), + dy = dragAmount.y.roundToInt(), + boardWidth = boardSize.width, + boardHeight = boardSize.height, + ) + }, + onDragEnd = { viewModel.endDrag() }, + onDragCancel = { viewModel.endDrag() }, + ) + } + } + .padding(6.dp), + ) { + if (editing && card.type == CardType.TEXT) { + OutlinedTextField( + value = viewModel.editor.text, + onValueChange = { viewModel.updateEditingCardText(it) }, + modifier = Modifier.fillMaxSize(), + textStyle = TextStyle(fontSize = 12.sp), + ) + } else { + Text( + text = card.text.ifBlank { "(空のカード)" }, + fontSize = 12.sp, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +internal fun ScopeBadge(scope: NoteScope, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null) { + Text( + text = scope.label, + fontSize = 11.sp, + color = Color.White, + modifier = modifier + .background(parseHexColor(scope.colorHex)) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + +@Composable +internal fun CollabBadge() { + Text( + text = "共同編集中", + fontSize = 11.sp, + color = Color.White, + modifier = Modifier + .background(Color(0xFF546E7A)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + +internal fun parseHexColor(hex: String): Color { + val rgb = hex.removePrefix("#").toLongOrNull(16) ?: 0L + return Color(0xFF000000L or rgb) +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt index 0595766..34fad49 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NoteEditorViewModel.kt @@ -19,15 +19,46 @@ class NoteEditorViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore() val editor = SaveableEditorState() var selectedId: String? by mutableStateOf(null) + private set + var document: NoteDocument by mutableStateOf(NoteDocument()) + private set + var editable: Boolean by mutableStateOf(false) + private set + var selectMode: Boolean by mutableStateOf(false) + private set + var selectedCardIds: Set by mutableStateOf(emptySet()) + private set + var editingCardId: String? by mutableStateOf(null) + private set + + private var isCardDocument: Boolean = false fun select(id: String) { selectedId = id - store.notes.firstOrNull { it.id == id }?.let { editor.edit(it.body) } + editable = false + selectMode = false + selectedCardIds = emptySet() + editingCardId = null + val note = store.notes.firstOrNull { it.id == id } ?: return + val decoded = NoteDocumentJson.decode(note.body) + if (decoded == null) { + isCardDocument = false + document = NoteDocument.fromPlainBody(note.body) + editor.edit(note.body) + } else { + isCardDocument = true + document = decoded + editor.edit("") + } } fun saveCurrent(): Boolean { val id = selectedId ?: return false - return store.updateNote(id, editor.text) + return if (isCardDocument) { + store.updateNote(id, NoteDocumentJson.encode(document)) + } else { + store.updateNote(id, editor.text) + } } fun addNote(title: String): String { @@ -35,4 +66,124 @@ class NoteEditorViewModel(private val store: OrgFlowAppStore = OrgFlowAppStore() select(entry.id) return entry.id } + + fun toggleEditable() { + editable = !editable + if (!editable) { + editingCardId = null + selectMode = false + selectedCardIds = emptySet() + } + } + + fun toggleSelectMode() { + selectMode = !selectMode + if (!selectMode) selectedCardIds = emptySet() + } + + fun toggleCardSelection(cardId: String) { + selectedCardIds = if (cardId in selectedCardIds) selectedCardIds - cardId else selectedCardIds + cardId + } + + fun clearCardSelection() { + selectedCardIds = emptySet() + } + + fun addCard(): String? { + if (!editable || selectedId == null) return null + val nextNumber = (document.cards.maxOfOrNull { cardNumber(it.id) } ?: 0) + 1 + val card = CardModel( + id = "card-$nextNumber", + type = CardType.TEXT, + x = NoteDocument.CARD_ORIGIN + (nextNumber % 6) * 16, + y = NoteDocument.CARD_ORIGIN + (nextNumber % 6) * 16, + w = NoteDocument.DEFAULT_CARD_WIDTH, + h = NoteDocument.DEFAULT_CARD_HEIGHT, + text = "", + color = NoteDocument.DEFAULT_CARD_COLOR, + ) + mutate { it.copy(cards = it.cards + card) } + editingCardId = card.id + selectMode = false + selectedCardIds = emptySet() + editor.edit("") + return card.id + } + + fun beginCardEdit(cardId: String) { + if (!editable) return + val card = document.card(cardId) ?: return + if (card.type != CardType.TEXT) return + editingCardId = cardId + selectedCardIds = emptySet() + selectMode = false + editor.edit(card.text) + } + + fun updateEditingCardText(text: String) { + val id = editingCardId ?: return + editor.edit(text) + mutate { doc -> doc.copy(cards = doc.cards.map { if (it.id == id) it.copy(text = text) else it }) } + } + + fun endCardEdit() { + editingCardId = null + } + + fun dragCard(cardId: String, dx: Int, dy: Int, boardWidth: Int, boardHeight: Int) { + if (!editable) return + if (document.card(cardId) == null) return + document = document.copy( + cards = document.cards.map { card -> + if (card.id != cardId) { + card + } else { + card.copy( + x = (card.x + dx).coerceIn(0, (boardWidth - card.w).coerceAtLeast(0)), + y = (card.y + dy).coerceIn(0, (boardHeight - card.h).coerceAtLeast(0)), + ) + } + }, + ) + isCardDocument = true + } + + fun endDrag(): Boolean { + return persist() + } + + fun setScope(scope: NoteScope) { + if (selectedId == null) return + mutate { it.copy(scope = scope) } + } + + fun startCollaboration() { + if (selectedId == null) return + mutate { it.copy(collaborationActive = true) } + } + + fun bundleSelectedCards(): Boolean { + if (!editable) return false + val bundle = document.bundleSelected(selectedCardIds) ?: return false + val sourceId = selectedId ?: return false + if (!store.updateNote(sourceId, NoteDocumentJson.encode(bundle.remaining))) return false + val created = store.addNote(bundle.title) + store.updateNote(created.id, NoteDocumentJson.encode(bundle.document)) + select(created.id) + return true + } + + private fun cardNumber(id: String): Int = id.removePrefix("card-").toIntOrNull() ?: 0 + + private fun mutate(transform: (NoteDocument) -> NoteDocument) { + document = transform(document) + isCardDocument = true + persist() + } + + private fun persist(): Boolean { + val id = selectedId ?: return false + if (!isCardDocument) return false + return store.updateNote(id, NoteDocumentJson.encode(document)) + } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt index c22c458..18ea42d 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/notes/NotesScreen.kt @@ -8,10 +8,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -25,24 +27,14 @@ fun NotesScreen( ) { Row(modifier = Modifier.fillMaxSize().padding(12.dp)) { Column(modifier = Modifier.weight(1f)) { - Text("Notes", fontSize = 17.sp) + Text("Note", fontSize = 17.sp) TextButton(onClick = { viewModel.addNote("New note") }) { Text("+ New note") } if (viewModel.notes.isEmpty()) { EmptyContent("No notes yet — capture your first observation") } else { LazyColumn { items(viewModel.notes, key = { it.id }) { note -> - Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) { - Column(modifier = Modifier.padding(10.dp)) { - Text(note.title, fontSize = 14.sp) - Text( - note.body.lineSequence().firstOrNull() ?: "(empty)", - fontSize = 11.sp, - maxLines = 1, - ) - TextButton(onClick = { viewModel.select(note.id) }) { Text("Open") } - } - } + NoteListCard(viewModel, note) } } } @@ -52,3 +44,40 @@ fun NotesScreen( } } } + +@Composable +private fun NoteListCard(viewModel: NoteEditorViewModel, note: NoteEntry) { + val document = NoteDocumentJson.decode(note.body) + Card( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + elevation = 1.dp, + backgroundColor = if (note.id == viewModel.selectedId) { + MaterialTheme.colors.primary.copy(alpha = 0.08f) + } else { + MaterialTheme.colors.surface + }, + ) { + Column(modifier = Modifier.padding(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + ScopeBadge(scope = document?.scope ?: NoteScope.SELF, modifier = Modifier.padding(end = 6.dp)) + if (document?.collaborationActive == true) CollabBadge() + } + Text(note.title, fontSize = 14.sp) + Text( + text = previewText(note, document), + fontSize = 11.sp, + maxLines = 1, + ) + TextButton(onClick = { viewModel.select(note.id) }) { Text("Open") } + } + } +} + +private fun previewText(note: NoteEntry, document: NoteDocument?): String = + if (document == null) { + note.body.lineSequence().firstOrNull { it.isNotBlank() } ?: "(empty)" + } else { + val firstText = document.cards + .firstNotNullOfOrNull { card -> card.text.lineSequence().firstOrNull { it.isNotBlank() } } + if (firstText == null) "カード ${document.cards.size}枚" else "カード ${document.cards.size}枚 · $firstText" + } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/pie/PieMenu.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/pie/PieMenu.kt new file mode 100644 index 0000000..145f318 --- /dev/null +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/pie/PieMenu.kt @@ -0,0 +1,199 @@ +package jp.orgflow.ui.pie + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import jp.orgflow.ui.model.GroupDirectory +import jp.orgflow.ui.model.GroupType +import jp.orgflow.ui.model.SetFilter +import jp.orgflow.ui.model.SetFilterState +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.roundToInt +import kotlin.math.sin +import kotlin.math.sqrt + +private data class PieEntry( + val label: String, + val filter: SetFilter, + val nextStage: PieStage? = null, +) + +private sealed interface PieStage { + data object Root : PieStage + data class Groups(val type: GroupType) : PieStage +} + +private val pieDiameter = 280.dp + +@Composable +fun PieMenu(filter: SetFilterState, modifier: Modifier = Modifier) { + var open by remember { mutableStateOf(false) } + var stage by remember { mutableStateOf(PieStage.Root) } + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + PieTrigger(filter = filter, onClick = { + stage = PieStage.Root + open = true + }) + } + if (open) { + Dialog(onDismissRequest = { open = false }) { + Surface(shape = CircleShape, color = MaterialTheme.colors.surface, elevation = 8.dp) { + PieRadial( + stage = stage, + filter = filter, + onPick = { entry -> + filter.select(entry.filter) + val next = entry.nextStage + if (next == null) open = false else stage = next + }, + onCenter = { + when (stage) { + PieStage.Root -> open = false + is PieStage.Groups -> stage = PieStage.Root + } + }, + ) + } + } + } +} + +@Composable +private fun PieTrigger(filter: SetFilterState, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(56.dp) + .background(MaterialTheme.colors.primary.copy(alpha = 0.12f), CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Text( + text = filter.label(), + fontSize = 10.sp, + color = MaterialTheme.colors.primary, + maxLines = 2, + ) + } +} + +@Composable +private fun PieRadial( + stage: PieStage, + filter: SetFilterState, + onPick: (PieEntry) -> Unit, + onCenter: () -> Unit, +) { + val entries = remember(stage) { pieEntries(stage) } + val onSurface = MaterialTheme.colors.onSurface + val primary = MaterialTheme.colors.primary + val surface = MaterialTheme.colors.surface + Box(modifier = Modifier.size(pieDiameter), contentAlignment = Alignment.Center) { + Canvas( + modifier = Modifier + .size(pieDiameter) + .pointerInput(entries) { + detectTapGestures { offset -> + val cx = size.width / 2f + val cy = size.height / 2f + val dx = offset.x - cx + val dy = offset.y - cy + val radius = sqrt(dx * dx + dy * dy) + val outer = size.width / 2f + val inner = outer * 0.3f + if (radius <= inner) { + onCenter() + } else if (radius <= outer && entries.isNotEmpty()) { + val sweep = 360f / entries.size + val degrees = ((atan2(dy, dx) * 180f / PI.toFloat()) + 450f) % 360f + val index = (degrees / sweep).toInt().coerceIn(0, entries.size - 1) + onPick(entries[index]) + } + } + }, + ) { + val sweep = 360f / entries.size + entries.forEachIndexed { index, entry -> + val start = -90f + index * sweep + drawArc( + color = if (entry.filter == filter.current) primary.copy(alpha = 0.45f) else onSurface.copy(alpha = 0.08f), + startAngle = start, + sweepAngle = sweep, + useCenter = true, + ) + drawArc( + color = onSurface.copy(alpha = 0.25f), + startAngle = start, + sweepAngle = sweep, + useCenter = false, + style = Stroke(width = 1.dp.toPx()), + ) + } + val innerRadius = this.size.minDimension / 2f * 0.3f + drawCircle(color = surface, radius = innerRadius) + drawCircle(color = onSurface.copy(alpha = 0.25f), radius = innerRadius, style = Stroke(width = 1.dp.toPx())) + } + entries.forEachIndexed { index, entry -> + val sweep = 360f / entries.size + val mid = (-90f + (index + 0.5f) * sweep) * PI.toFloat() / 180f + val labelRadius = (pieDiameter.value / 2f) * 0.64f + Text( + text = entry.label, + fontSize = 11.sp, + maxLines = 1, + color = if (entry.filter == filter.current) primary else MaterialTheme.colors.onSurface, + modifier = Modifier.offset { + IntOffset( + (cos(mid) * labelRadius * density).roundToInt(), + (sin(mid) * labelRadius * density).roundToInt(), + ) + }, + ) + } + Text( + text = when (stage) { + PieStage.Root -> "閉じる" + is PieStage.Groups -> "戻る" + }, + fontSize = 11.sp, + color = primary, + modifier = Modifier.clickable(onClick = onCenter), + ) + } +} + +private fun pieEntries(stage: PieStage): List = when (stage) { + PieStage.Root -> listOf( + PieEntry(GroupType.WHOLE.label, SetFilter.Whole), + PieEntry(GroupType.CLASS.label, SetFilter.Type(GroupType.CLASS), PieStage.Groups(GroupType.CLASS)), + PieEntry(GroupType.CLUB.label, SetFilter.Type(GroupType.CLUB), PieStage.Groups(GroupType.CLUB)), + PieEntry(GroupType.COMMITTEE.label, SetFilter.Type(GroupType.COMMITTEE), PieStage.Groups(GroupType.COMMITTEE)), + PieEntry(GroupType.PERSONAL.label, SetFilter.Self), + ) + is PieStage.Groups -> GroupDirectory.groupsOf(stage.type).map { group -> + PieEntry(group.name, SetFilter.Group(group.gid)) + } +} diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt index d0b49e1..af7258b 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/runtime/OrgFlowRuntime.kt @@ -3,6 +3,8 @@ package jp.orgflow.ui.runtime import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import jp.orgflow.transport.mesh.WebRtcMeshTransport +import jp.orgflow.transport.signaling.SignalingEnvelope import jp.orgflow.ui.component.UiWaterline import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -51,6 +53,8 @@ data class ConfigSnapshot( val configDir: String = "", val fields: List = emptyList(), val lastSavedAtMs: Long = 0, + val signalingEndpoint: String = "", + val iceServers: List = emptyList(), ) interface DistributionRuntime { @@ -67,6 +71,10 @@ interface ConfigRuntime { val config: StateFlow fun update(fieldKey: String, value: Double) + + fun updateEndpoint(endpoint: String) + + fun updateIceServers(servers: List) } object UnavailableDistributionRuntime : DistributionRuntime { @@ -87,8 +95,34 @@ object UnavailableConfigRuntime : ConfigRuntime { ) override fun update(fieldKey: String, value: Double) {} + + override fun updateEndpoint(endpoint: String) {} + + override fun updateIceServers(servers: List) {} +} + +class SignalingPeerRoster(private val selfPeerId: String) { + + private val joined = LinkedHashSet() + + val peers: List + get() = joined.toList() + + fun onEnvelope(envelope: SignalingEnvelope) { + when (envelope.type) { + WebRtcMeshTransport.TYPE_PEER_JOINED -> if (envelope.senderPeerId != selfPeerId) joined.add(envelope.senderPeerId) + WebRtcMeshTransport.TYPE_PEER_LEFT -> joined.remove(envelope.senderPeerId) + else -> Unit + } + } + + fun clear() { + joined.clear() + } } +expect fun provideRuntime(): OrgFlowRuntime + data class OrgFlowRuntime( val distribution: DistributionRuntime, val config: ConfigRuntime, diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt index 1ebb309..4b730f7 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/store/OrgFlowAppStore.kt @@ -26,6 +26,17 @@ data class CaptureEntry( val capturedAtMs: Long, ) +data class JoinableRoom( + val id: String, + val name: String, + val memberCount: Int = 0, +) + +data class Affiliation( + val name: String, + val colorArgb: Long, +) + class OrgFlowAppStore { var activities: List by mutableStateOf(emptyList()) private set @@ -39,6 +50,18 @@ class OrgFlowAppStore { private set var notifiedEventIds: Set by mutableStateOf(emptySet()) private set + var signalingEndpoint: String by mutableStateOf(DEFAULT_SIGNALING_ENDPOINT) + private set + var iceServers: List by mutableStateOf(DEFAULT_ICE_SERVERS) + private set + var joinableRooms: List by mutableStateOf(sampleJoinableRooms()) + private set + var joinedRoomIds: Set by mutableStateOf(emptySet()) + private set + var affiliations: List by mutableStateOf(emptyList()) + private set + var noteEventLinks: Map by mutableStateOf(emptyMap()) + private set fun addCapture( templateType: String, @@ -57,8 +80,8 @@ class OrgFlowAppStore { return entry.id } - fun addNote(title: String): NoteEntry { - val entry = NoteEntry("n${notes.size + 1}", title.ifBlank { "Untitled" }, "") + fun addNote(title: String, body: String = ""): NoteEntry { + val entry = NoteEntry("n${notes.size + 1}", title.ifBlank { "Untitled" }, body) notes = notes + entry return entry } @@ -69,22 +92,44 @@ class OrgFlowAppStore { return true } + fun linkNoteToEvent(noteId: String, eventId: String) { + noteEventLinks = noteEventLinks + (noteId to eventId) + } + + fun linkedEventId(noteId: String): String? = noteEventLinks[noteId] + + fun addAgendaItem(title: String, scheduledAt: LocalDateTime): AgendaItem { + val entry = AgendaItem("a${agendaItems.size + 1}", title, scheduledAt) + agendaItems = agendaItems + entry + return entry + } + fun toggleAgendaDone(id: String) { agendaItems = agendaItems.map { if (it.id == id) it.copy(done = !it.done) else it } } - fun addCalendarEvent(title: String, date: LocalDate, time: LocalTime? = null): CalendarEvent { + fun addCalendarEvent( + title: String, + date: LocalDate, + time: LocalTime? = null, + linkedNoteId: String? = null, + ): CalendarEvent { val entry = CalendarEvent( id = "cal-${calendarEvents.size + 1}", title = title.ifBlank { "Untitled" }, date = date, source = EventSource.Personal, time = time, + linkedNoteId = linkedNoteId, ) calendarEvents = calendarEvents + entry return entry } + fun joinRoom(id: String) { + joinedRoomIds = joinedRoomIds + id + } + fun markNotified(ids: Collection) { notifiedEventIds = notifiedEventIds + ids } @@ -93,12 +138,28 @@ class OrgFlowAppStore { notificationSettings = settings } + fun updateConnectionConfig(endpoint: String, servers: List) { + val trimmedEndpoint = endpoint.trim() + if (trimmedEndpoint.isNotBlank()) signalingEndpoint = trimmedEndpoint + val cleanedServers = servers.map { it.trim() }.filter { it.isNotBlank() } + if (cleanedServers.isNotEmpty()) iceServers = cleanedServers + } + companion object { + const val DEFAULT_SIGNALING_ENDPOINT = "ws://192.168.2.114:8091/ws" + val DEFAULT_ICE_SERVERS = listOf("stun:stun.l.google.com:19302") + private fun sampleItems(): List = listOf( AgendaItem("a1", "Team sync", LocalDateTime(2026, 8, 27, 10, 0)), AgendaItem("a2", "Write experiment report", LocalDateTime(2026, 8, 27, 15, 0)), AgendaItem("a3", "Pack rehearsal", LocalDateTime(2026, 8, 28, 9, 30)), ) + + private fun sampleJoinableRooms(): List = listOf( + JoinableRoom("ws-classroom", "Classroom", 4), + JoinableRoom("ws-club", "Club activity", 3), + JoinableRoom("ws-committee", "Committee", 2), + ) } } diff --git a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt index 78990d1..2fb2725 100644 --- a/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt +++ b/modules/orgflow-ui/src/commonMain/kotlin/jp/orgflow/ui/workspace/WorkspaceScreen.kt @@ -19,9 +19,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import jp.orgflow.ui.store.OrgFlowAppStore @Composable -fun WorkspaceScreen(viewModel: WorkspaceViewModel = remember { WorkspaceViewModel() }) { +fun WorkspaceScreen( + store: OrgFlowAppStore, + viewModel: WorkspaceViewModel = remember { WorkspaceViewModel() }, +) { var showAddRoom by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize().padding(12.dp)) { Text("Workspace", fontSize = 17.sp) @@ -45,7 +49,7 @@ fun WorkspaceScreen(viewModel: WorkspaceViewModel = remember { WorkspaceViewMode Text("${member.displayName} — ${member.role}", fontSize = 12.sp) } WorkspaceQrInviteCard( - payload = viewModel.invitePayload("ws://192.168.2.114:8091/ws"), + payload = viewModel.invitePayload(store.signalingEndpoint), ) } if (showAddRoom) { diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt index f8fc370..19ce5d6 100644 --- a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/AppStoreFlowTest.kt @@ -3,10 +3,13 @@ package jp.orgflow.ui import jp.orgflow.ui.agenda.AgendaViewModel import jp.orgflow.ui.capture.CaptureViewModel import jp.orgflow.ui.capture.FiveW1HField +import jp.orgflow.ui.capture.PostTarget import jp.orgflow.ui.home.HomeViewModel import jp.orgflow.ui.notes.NoteEditorViewModel import jp.orgflow.ui.store.OrgFlowAppStore +import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -83,4 +86,103 @@ class AppStoreFlowTest { assertFalse(agenda.overdue(now).any { it.id == "a1" }) assertEquals(2, agenda.forDate(LocalDateTime(2026, 8, 27, 0, 0)).size) } + + @Test + fun postTargetNoteCreatesNoteOnly() { + val store = OrgFlowAppStore() + val capture = CaptureViewModel(store, today = { LocalDate.parse("2026-08-27") }) + capture.updateField(FiveW1HField.WHAT, "observed X") + capture.updateField(FiveW1HField.WHEN, "2026-08-27 14:30") + assertTrue(capture.submit(PostTarget.Note)) + + assertEquals(1, store.activities.size) + assertEquals(1, store.notes.size) + assertEquals("observed X", store.notes.single().body) + assertEquals(emptyList(), store.calendarEvents) + assertEquals(PostTarget.Note, capture.lastResult?.target) + assertEquals(store.notes.single().id, capture.lastResult?.noteId) + } + + @Test + fun postTargetCalendarCreatesEventOnly() { + val store = OrgFlowAppStore() + val capture = CaptureViewModel(store, today = { LocalDate.parse("2026-08-27") }) + capture.updateField(FiveW1HField.WHAT, "observed X") + capture.updateField(FiveW1HField.WHEN, "2026-08-27 14:30") + assertTrue(capture.submit(PostTarget.Calendar)) + + assertEquals(1, store.activities.size) + assertEquals(1, store.calendarEvents.size) + val event = store.calendarEvents.single() + assertEquals("observed X", event.title) + assertEquals(LocalDate.parse("2026-08-27"), event.date) + assertEquals(LocalTime(14, 30), event.time) + assertEquals(emptyList(), store.notes) + assertEquals(event.id, capture.lastResult?.eventId) + } + + @Test + fun postTargetBothCreatesLinkedPair() { + val store = OrgFlowAppStore() + val capture = CaptureViewModel(store, today = { LocalDate.parse("2026-08-27") }) + capture.updateField(FiveW1HField.WHAT, "observed X") + capture.updateField(FiveW1HField.WHEN, "tomorrow 9:00") + assertTrue(capture.submit(PostTarget.Both)) + + assertEquals(1, store.notes.size) + assertEquals(1, store.calendarEvents.size) + val note = store.notes.single() + val event = store.calendarEvents.single() + assertEquals("observed X", note.body) + assertEquals("observed X", event.title) + assertEquals(LocalDate.parse("2026-08-28"), event.date) + assertEquals(note.id, event.linkedNoteId) + assertEquals(event.id, store.linkedEventId(note.id)) + assertEquals(mapOf(note.id to event.id), store.noteEventLinks) + } + + @Test + fun calendarTargetRequiresParseableWhen() { + val store = OrgFlowAppStore() + val capture = CaptureViewModel(store, today = { LocalDate.parse("2026-08-27") }) + capture.updateField(FiveW1HField.WHAT, "someday task") + capture.updateField(FiveW1HField.WHEN, "someday") + assertFalse(capture.submit(PostTarget.Calendar)) + assertFalse(capture.submit(PostTarget.Both)) + assertTrue(store.activities.isEmpty()) + assertTrue(store.notes.isEmpty()) + assertTrue(store.calendarEvents.isEmpty()) + assertTrue(capture.submit(PostTarget.Note)) + assertEquals(1, store.activities.size) + } + + @Test + fun homeDueSoonListsAgendaAndEventsNearestFirst() { + val store = OrgFlowAppStore() + store.addCalendarEvent("lab visit", LocalDate.parse("2026-08-28"), LocalTime(10, 0)) + store.addAgendaItem("room cleanup", LocalDateTime(2026, 8, 27, 9, 0)) + val home = HomeViewModel(store, now = { LocalDateTime(2026, 8, 27, 8, 0) }) + + val items = home.dueSoonItems() + assertEquals( + listOf("room cleanup", "Team sync", "Write experiment report", "Pack rehearsal", "lab visit"), + items.map { it.title }, + ) + assertEquals(listOf("agenda-a4", "agenda-a1", "agenda-a2", "agenda-a3", "cal-1"), items.map { it.id }) + assertEquals(0, items.first().daysUntil) + assertEquals(1, items.last().daysUntil) + assertEquals(listOf(LocalDate.parse("2026-08-27"), LocalDate.parse("2026-08-28")), home.weekDays().take(2)) + assertEquals(listOf("room cleanup", "Team sync", "Write experiment report"), home.entriesOn(LocalDate.parse("2026-08-27")).map { it.title }) + } + + @Test + fun joinRoomRecordsMembership() { + val store = OrgFlowAppStore() + val home = HomeViewModel(store) + assertTrue(store.joinableRooms.isNotEmpty()) + val room = store.joinableRooms.first() + assertTrue(room.id !in home.joinedRoomIds) + home.joinRoom(room.id) + assertEquals(setOf(room.id), store.joinedRoomIds) + } } diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt index 2550a7e..bc03df9 100644 --- a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/CalendarTest.kt @@ -94,6 +94,36 @@ class CalendarTest { assertEquals(setOf("cal-1"), store.notifiedEventIds) } + @Test + fun agendaDueSoonNotifiesOncePerId() { + val store = OrgFlowAppStore() + val notifier = RecordingNotifier() + val vm = CalendarViewModel(store, notifier = notifier, now = { LocalDateTime(2026, 8, 27, 8, 45) }) + store.addAgendaItem("room cleanup", LocalDateTime(2026, 8, 27, 9, 0)) + + val due = vm.checkDueNotifications() + assertEquals(listOf("room cleanup"), due.map { it.title }) + assertEquals(listOf("room cleanup"), notifier.titles) + assertTrue(vm.checkDueNotifications().isEmpty()) + assertEquals(setOf("agenda-a4"), store.notifiedEventIds) + } + + @Test + fun calendarScreenMergesTasksAndTogglePersists() { + val store = OrgFlowAppStore() + val vm = CalendarViewModel(store, now = { LocalDateTime(2026, 8, 27, 8, 45) }) + assertEquals(listOf("a1", "a2", "a3"), vm.tasks.map { it.id }) + assertEquals(3, vm.openTaskCount()) + assertEquals(listOf("a1", "a2"), vm.tasksOn(LocalDate.parse("2026-08-27")).map { it.id }) + + store.addAgendaItem("past task", LocalDateTime(2026, 8, 27, 7, 0)) + assertEquals(listOf("a4"), vm.overdueTasks().map { it.id }) + vm.toggleTaskDone("a4") + assertTrue(store.agendaItems.single { it.id == "a4" }.done) + assertTrue(vm.overdueTasks().isEmpty()) + assertEquals(3, vm.openTaskCount()) + } + @Test fun captureWhenFieldBecomesPersonalEvent() { val store = OrgFlowAppStore() diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ConnectionConfigTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ConnectionConfigTest.kt new file mode 100644 index 0000000..eef1b6c --- /dev/null +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/ConnectionConfigTest.kt @@ -0,0 +1,89 @@ +package jp.orgflow.ui + +import jp.orgflow.ui.store.OrgFlowAppStore +import jp.orgflow.transport.mesh.WebRtcMeshTransport +import jp.orgflow.transport.qr.QrBootstrapEncoder +import jp.orgflow.transport.signaling.SignalingEnvelope +import jp.orgflow.ui.runtime.SignalingPeerRoster +import jp.orgflow.ui.workspace.WorkspaceViewModel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ConnectionConfigTest { + + @Test + fun storePersistsSignalingEndpointAndIceServers() { + val store = OrgFlowAppStore() + assertEquals(OrgFlowAppStore.DEFAULT_SIGNALING_ENDPOINT, store.signalingEndpoint) + assertEquals(OrgFlowAppStore.DEFAULT_ICE_SERVERS, store.iceServers) + + store.updateConnectionConfig( + "ws://10.0.0.5:8091/ws", + listOf("stun:stun.example.com:3478", " ", "turn:turn.example.com:3478"), + ) + assertEquals("ws://10.0.0.5:8091/ws", store.signalingEndpoint) + assertEquals(listOf("stun:stun.example.com:3478", "turn:turn.example.com:3478"), store.iceServers) + } + + @Test + fun storeKeepsPreviousConfigWhenUpdateIsBlank() { + val store = OrgFlowAppStore() + store.updateConnectionConfig("ws://10.0.0.5:8091/ws", listOf("stun:stun.example.com:3478")) + store.updateConnectionConfig(" ", emptyList()) + assertEquals("ws://10.0.0.5:8091/ws", store.signalingEndpoint) + assertEquals(listOf("stun:stun.example.com:3478"), store.iceServers) + } + + @Test + fun workspaceInvitePayloadCarriesConfiguredEndpoint() { + val store = OrgFlowAppStore() + store.updateConnectionConfig("ws://10.0.0.5:8091/ws", emptyList()) + val viewModel = WorkspaceViewModel() + viewModel.createRoom("study group") + + val payload = viewModel.invitePayload(store.signalingEndpoint) + val decoded = QrBootstrapEncoder.decode(payload) + assertNotNull(decoded) + assertEquals("ws://10.0.0.5:8091/ws", decoded.signalingEndpoint) + assertEquals(viewModel.currentRoomId, decoded.workspaceId) + } + + @Test + fun rosterTracksPeerJoinAndLeaveSignals() { + val roster = SignalingPeerRoster(selfPeerId = "self") + roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "alpha")) + roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "beta")) + roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "self")) + assertEquals(listOf("alpha", "beta"), roster.peers) + + roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_LEFT, "alpha")) + assertEquals(listOf("beta"), roster.peers) + + roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_LEFT, "unknown")) + assertEquals(listOf("beta"), roster.peers) + } + + @Test + fun rosterIgnoresOtherSignalTypesAndDuplicates() { + val roster = SignalingPeerRoster(selfPeerId = "self") + roster.onEnvelope(envelope("offer", "alpha")) + assertTrue(roster.peers.isEmpty()) + + roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "alpha")) + roster.onEnvelope(envelope(WebRtcMeshTransport.TYPE_PEER_JOINED, "alpha")) + assertEquals(listOf("alpha"), roster.peers) + + roster.clear() + assertTrue(roster.peers.isEmpty()) + } + + private fun envelope(type: String, sender: String): SignalingEnvelope = SignalingEnvelope( + type = type, + senderPeerId = sender, + nonce = 0L, + timestampMs = 0L, + payloadJson = "{}", + ) +} diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/NoteCardBoardTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/NoteCardBoardTest.kt new file mode 100644 index 0000000..ff428b9 --- /dev/null +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/NoteCardBoardTest.kt @@ -0,0 +1,221 @@ +package jp.orgflow.ui + +import jp.orgflow.ui.notes.CardModel +import jp.orgflow.ui.notes.CardType +import jp.orgflow.ui.notes.NoteDocument +import jp.orgflow.ui.notes.NoteDocumentJson +import jp.orgflow.ui.notes.NoteEditorViewModel +import jp.orgflow.ui.notes.NoteScope +import jp.orgflow.ui.notes.bundleSelected +import jp.orgflow.ui.store.OrgFlowAppStore +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NoteCardBoardTest { + + private fun card( + id: String, + x: Int = 0, + y: Int = 0, + text: String = "", + color: String = "#FFF59D", + type: CardType = CardType.TEXT, + ) = CardModel(id = id, type = type, x = x, y = y, w = 120, h = 80, text = text, color = color) + + @Test + fun cardJsonRoundTrip() { + val document = NoteDocument( + scope = NoteScope.CLASS, + collaborationActive = true, + cards = listOf( + card("card-1", x = 12, y = 34, text = "観察メモ\n\"引用\" \\ 改行\tタブ"), + card("card-2", x = 200, y = 80, text = "second", color = "#81D4FA"), + ), + ) + val encoded = NoteDocumentJson.encode(document) + assertEquals( + "{\"v\":1,\"scope\":\"クラス\",\"collab\":true,\"cards\":[" + + "{\"id\":\"card-1\",\"type\":\"text\",\"x\":12,\"y\":34,\"w\":120,\"h\":80," + + "\"text\":\"観察メモ\\n\\\"引用\\\" \\\\ 改行\\tタブ\",\"color\":\"#FFF59D\"}," + + "{\"id\":\"card-2\",\"type\":\"text\",\"x\":200,\"y\":80,\"w\":120,\"h\":80," + + "\"text\":\"second\",\"color\":\"#81D4FA\"}]}", + encoded, + ) + assertEquals(document, NoteDocumentJson.decode(encoded)) + assertEquals(document, NoteDocumentJson.decode(NoteDocumentJson.encode(NoteDocumentJson.decode(encoded)!!))) + } + + @Test + fun decodeIgnoresUnknownKeysAndTypes() { + val decoded = NoteDocumentJson.decode( + "{\"v\":1,\"scope\":\"部活動\",\"collab\":false,\"future\":123," + + "\"cards\":[{\"id\":\"c9\",\"type\":\"ink\",\"x\":1,\"y\":2,\"w\":3,\"h\":4,\"text\":\"t\",\"color\":\"#FFFFFF\",\"extra\":true}]}", + ) + assertNotNull(decoded) + assertEquals(NoteScope.CLUB, decoded.scope) + assertEquals(CardType.INK, decoded.cards.single().type) + assertEquals(1, decoded.cards.single().x) + } + + @Test + fun malformedAndPlainBodiesDecodeToNull() { + assertNull(NoteDocumentJson.decode("plain memo")) + assertNull(NoteDocumentJson.decode("")) + assertNull(NoteDocumentJson.decode("{\"cards\":[")) + assertNull(NoteDocumentJson.decode("{\"scope\":\"自分\"}")) + } + + @Test + fun plainBodyMigratesToSingleTextCard() { + val body = "* observation A\n- detail" + val document = NoteDocument.fromPlainBody(body) + assertEquals(NoteScope.SELF, document.scope) + assertFalse(document.collaborationActive) + val migrated = document.cards.single() + assertEquals("card-1", migrated.id) + assertEquals(CardType.TEXT, migrated.type) + assertEquals(body, migrated.text) + assertEquals(document, NoteDocumentJson.decode(NoteDocumentJson.encode(document))) + } + + @Test + fun bundleSelectionMovesCardsAndKeepsRemaining() { + val document = NoteDocument( + scope = NoteScope.CLUB, + collaborationActive = true, + cards = listOf( + card("card-1", text = "-first"), + card("card-2", text = "second card"), + card("card-3", text = "third"), + ), + ) + val bundle = assertNotNull(document.bundleSelected(setOf("card-2", "card-3"))) + assertEquals("second card", bundle.title) + assertEquals(listOf("card-2", "card-3"), bundle.document.cards.map { it.id }) + assertEquals(NoteScope.CLUB, bundle.document.scope) + assertFalse(bundle.document.collaborationActive) + assertEquals(listOf("card-1"), bundle.remaining.cards.map { it.id }) + assertNull(document.bundleSelected(setOf("card-1"))) + } + + @Test + fun bundlingThroughViewModelUsesStore() { + val store = OrgFlowAppStore() + val viewModel = NoteEditorViewModel(store) + val sourceId = viewModel.addNote("探究ボード") + viewModel.toggleEditable() + val first = assertNotNull(viewModel.addCard()) + val second = assertNotNull(viewModel.addCard()) + val third = assertNotNull(viewModel.addCard()) + viewModel.beginCardEdit(first) + viewModel.updateEditingCardText("観察A") + viewModel.endCardEdit() + viewModel.beginCardEdit(second) + viewModel.updateEditingCardText("観察B") + viewModel.endCardEdit() + viewModel.beginCardEdit(third) + viewModel.updateEditingCardText("課題C") + viewModel.endCardEdit() + + viewModel.toggleCardSelection(first) + viewModel.toggleCardSelection(second) + viewModel.toggleCardSelection(third) + assertTrue(viewModel.bundleSelectedCards()) + + assertEquals(2, store.notes.size) + val bundled = store.notes.last() + assertEquals("観察A", bundled.title) + assertEquals(viewModel.selectedId, bundled.id) + val bundledDoc = assertNotNull(NoteDocumentJson.decode(bundled.body)) + assertEquals(listOf("観察A", "観察B", "課題C"), bundledDoc.cards.map { it.text }) + assertFalse(bundledDoc.collaborationActive) + + val sourceDoc = assertNotNull(NoteDocumentJson.decode(store.notes.single { it.id == sourceId }.body)) + assertEquals(listOf("card-1"), sourceDoc.cards.map { it.id }) + } + + @Test + fun bundlingRequiresTwoOrMoreSelected() { + val store = OrgFlowAppStore() + val viewModel = NoteEditorViewModel(store) + viewModel.addNote("board") + viewModel.toggleEditable() + viewModel.addCard() + viewModel.addCard() + viewModel.toggleCardSelection("card-2") + assertFalse(viewModel.bundleSelectedCards()) + assertEquals(1, store.notes.size) + + viewModel.toggleCardSelection("card-1") + assertTrue(viewModel.bundleSelectedCards()) + assertEquals(2, store.notes.size) + assertEquals("クリップノート", store.notes.last().title) + } + + @Test + fun scopeAndCollaborationPersistInBody() { + val store = OrgFlowAppStore() + val viewModel = NoteEditorViewModel(store) + viewModel.addNote("note") + viewModel.setScope(NoteScope.COMMITTEE) + viewModel.startCollaboration() + val decoded = assertNotNull(NoteDocumentJson.decode(store.notes.single().body)) + assertEquals(NoteScope.COMMITTEE, decoded.scope) + assertTrue(decoded.collaborationActive) + + val reopened = NoteEditorViewModel(store) + reopened.select(store.notes.single().id) + assertEquals(NoteScope.COMMITTEE, reopened.document.scope) + assertTrue(reopened.document.collaborationActive) + } + + @Test + fun plainNoteSaveStaysPlainTextAndReopenMigrates() { + val store = OrgFlowAppStore() + val viewModel = NoteEditorViewModel(store) + val id = viewModel.addNote("legacy") + viewModel.editor.edit("* observation A") + assertTrue(viewModel.saveCurrent()) + assertEquals("* observation A", store.notes.single().body) + + val reopened = NoteEditorViewModel(store) + reopened.select(id) + assertEquals("* observation A", reopened.editor.text) + assertEquals("* observation A", reopened.document.cards.single().text) + } + + @Test + fun dragCardClampsWithinBoard() { + val store = OrgFlowAppStore() + val viewModel = NoteEditorViewModel(store) + viewModel.addNote("board") + viewModel.toggleEditable() + val cardId = assertNotNull(viewModel.addCard()) + viewModel.endCardEdit() + + val before = assertNotNull(viewModel.document.card(cardId)) + viewModel.dragCard(cardId, dx = 50, dy = 40, boardWidth = 800, boardHeight = 600) + val moved = assertNotNull(viewModel.document.card(cardId)) + assertEquals(before.x + 50, moved.x) + assertEquals(before.y + 40, moved.y) + + viewModel.dragCard(cardId, dx = 5000, dy = 5000, boardWidth = 800, boardHeight = 600) + val clamped = assertNotNull(viewModel.document.card(cardId)) + assertEquals(800 - clamped.w, clamped.x) + assertEquals(600 - clamped.h, clamped.y) + + viewModel.dragCard(cardId, dx = -5000, dy = -5000, boardWidth = 800, boardHeight = 600) + val floor = assertNotNull(viewModel.document.card(cardId)) + assertEquals(0, floor.x) + assertEquals(0, floor.y) + + assertTrue(viewModel.endDrag()) + val persisted = assertNotNull(NoteDocumentJson.decode(store.notes.single().body)) + assertEquals(0, assertNotNull(persisted.card(cardId)).x) + assertEquals(0, assertNotNull(persisted.card(cardId)).y) + } +} diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/GroupModelTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/GroupModelTest.kt new file mode 100644 index 0000000..3240460 --- /dev/null +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/GroupModelTest.kt @@ -0,0 +1,84 @@ +package jp.orgflow.ui.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GroupModelTest { + + @Test + fun membershipStatusMapsToSquareIcons() { + assertEquals(MembershipIcon.CIRCLE, MembershipStatus.JOIN.toIcon()) + assertEquals(MembershipIcon.FULL_MOON, MembershipStatus.AUTO_JOIN.toIcon()) + assertEquals(MembershipIcon.HALF_MOON, MembershipStatus.VIEW_ONLY.toIcon()) + assertEquals(MembershipIcon.KEY, MembershipStatus.HIDDEN.toIcon()) + assertEquals(MembershipIcon.CIRCLE, iconFor(null)) + } + + @Test + fun closedGroupShowsKeyIcon() { + val closed = GroupDirectory.groupOf("g-committee-culture")!! + assertEquals(MembershipIcon.KEY, closed.iconForVisibility()) + val open = GroupDirectory.groupOf("g-club-art")!! + assertNull(open.iconForVisibility()) + } + + @Test + fun directoryMembershipLookups() { + val self = GroupDirectory.currentUser + assertEquals(MembershipStatus.AUTO_JOIN, GroupDirectory.statusOf(self.uid, "g-class-2a")) + assertEquals(MembershipStatus.JOIN, GroupDirectory.statusOf(self.uid, "g-club-art")) + assertEquals(MembershipStatus.VIEW_ONLY, GroupDirectory.statusOf(self.uid, "g-committee-culture")) + assertNull(GroupDirectory.statusOf(self.uid, "g-class-2b")) + assertEquals(listOf("美術部", "軽音部"), GroupDirectory.groupsOf(GroupType.CLUB).map { it.name }) + } + + @Test + fun dynamicRuleResolvesGradeAndClassFromSchoolId() { + val user = User("u9", "テスト", "2-A-15") + assertEquals("2", DynamicMembership.gradeOf(user)) + assertEquals("A", DynamicMembership.classOf(user)) + val grade = DynamicGroup("g-dyn-grade2", MembershipRule.Grade("2")) + assertEquals(MembershipStatus.AUTO_JOIN, DynamicMembership.resolve(grade, user)?.status) + assertEquals(user.uid, DynamicMembership.resolve(grade, user)?.uid) + val classroom = DynamicGroup("g-dyn-2a", MembershipRule.Classroom("2", "A")) + assertEquals(MembershipStatus.AUTO_JOIN, DynamicMembership.resolve(classroom, user)?.status) + assertNull(DynamicMembership.resolve(DynamicGroup("x", MembershipRule.Classroom("2", "B")), user)) + assertNull(DynamicMembership.resolve(DynamicGroup("x", MembershipRule.Grade("3")), user)) + val all = DynamicGroup("g-dyn-all", MembershipRule.All) + assertEquals(MembershipStatus.AUTO_JOIN, DynamicMembership.resolve(all, user)?.status) + } + + @Test + fun dynamicRuleIgnoresMalformedSchoolId() { + val user = User("u9", "テスト", "") + assertNull(DynamicMembership.gradeOf(user)) + assertNull(DynamicMembership.classOf(user)) + assertNull(DynamicMembership.resolve(DynamicGroup("x", MembershipRule.Grade("2")), user)) + val all = DynamicGroup("x", MembershipRule.All) + assertEquals(MembershipStatus.AUTO_JOIN, DynamicMembership.resolve(all, user)?.status) + } + + @Test + fun dynamicMembershipsDerivedForDirectoryUser() { + val self = GroupDirectory.currentUser + val gids = GroupDirectory.dynamicMemberships(self).map { it.gid } + assertTrue("g-dyn-grade2" in gids) + assertTrue("g-dyn-class-2a" in gids) + val other = User("u-x", "外部", "3-C-01") + assertTrue(GroupDirectory.dynamicMemberships(other).isEmpty()) + } + + @Test + fun colorOfIsStablePerGid() { + val first = GroupColor.indexFor("g-class-2a") + assertEquals(first, GroupColor.indexFor("g-class-2a")) + assertEquals(first, GroupColor.indexFor("g-class-2a")) + assertTrue(first in 0 until GroupColor.paletteSize) + assertEquals(GroupColor.colorOf("g-club-art"), GroupColor.colorOf("g-club-art")) + GroupDirectory.groups.forEach { group -> + assertTrue(GroupColor.indexFor(group.gid) in 0 until GroupColor.paletteSize) + } + } +} diff --git a/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/SetFilterStateTest.kt b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/SetFilterStateTest.kt new file mode 100644 index 0000000..affc951 --- /dev/null +++ b/modules/orgflow-ui/src/commonTest/kotlin/jp/orgflow/ui/model/SetFilterStateTest.kt @@ -0,0 +1,64 @@ +package jp.orgflow.ui.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SetFilterStateTest { + + @Test + fun initialFilterIsWhole() { + val state = SetFilterState() + assertEquals(SetFilter.Whole, state.current) + assertEquals("全体", state.label()) + assertEquals(listOf("全体"), state.segments().map { it.label }) + assertNull(state.groupId()) + } + + @Test + fun groupFilterBuildsBreadcrumbPath() { + val state = SetFilterState() + state.select(SetFilter.Group("g-class-2a")) + assertEquals(listOf("全体", "クラス", "クラス2-A"), state.segments().map { it.label }) + assertEquals("g-class-2a", state.groupId()) + assertEquals("クラス2-A", state.label()) + } + + @Test + fun breadcrumbSegmentClickPopsFilter() { + val state = SetFilterState() + state.select(SetFilter.Group("g-class-2a")) + val segments = state.segments() + state.select(segments[1].filter) + assertEquals(SetFilter.Type(GroupType.CLASS), state.current) + state.select(state.segments()[0].filter) + assertEquals(SetFilter.Whole, state.current) + } + + @Test + fun selfAndTypeFilters() { + val state = SetFilterState() + state.select(SetFilter.Self) + assertEquals(listOf("全体", "自分"), state.segments().map { it.label }) + assertEquals("自分", state.label()) + state.select(SetFilter.Type(GroupType.CLUB)) + assertEquals(listOf("全体", "部活動"), state.segments().map { it.label }) + } + + @Test + fun unknownGroupFallsBackToGid() { + val state = SetFilterState() + state.select(SetFilter.Group("g-unknown")) + assertEquals("g-unknown", state.label()) + assertEquals(listOf("全体", "", "g-unknown"), state.segments().map { it.label }) + } + + @Test + fun resetReturnsToWhole() { + val state = SetFilterState() + state.select(SetFilter.Group("g-club-art")) + state.reset() + assertEquals(SetFilter.Whole, state.current) + assertNull(state.groupId()) + } +} diff --git a/modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/runtime/ProvideRuntime.jvm.kt b/modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/runtime/ProvideRuntime.jvm.kt new file mode 100644 index 0000000..75af22f --- /dev/null +++ b/modules/orgflow-ui/src/jvmMain/kotlin/jp/orgflow/ui/runtime/ProvideRuntime.jvm.kt @@ -0,0 +1,3 @@ +package jp.orgflow.ui.runtime + +actual fun provideRuntime(): OrgFlowRuntime = OrgFlowRuntime.UNAVAILABLE diff --git a/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/runtime/WasmDistributionRuntime.kt b/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/runtime/WasmDistributionRuntime.kt new file mode 100644 index 0000000..1f8812f --- /dev/null +++ b/modules/orgflow-ui/src/wasmJsMain/kotlin/jp/orgflow/ui/runtime/WasmDistributionRuntime.kt @@ -0,0 +1,183 @@ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package jp.orgflow.ui.runtime + +import jp.orgflow.ui.store.OrgFlowAppStore +import jp.orgflow.transport.mesh.WebRtcMeshTransport +import jp.orgflow.transport.signaling.SignalingEnvelope +import jp.orgflow.transport.signaling.WsSignalingClient +import jp.orgflow.ui.component.UiWaterline +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Clock + +class WasmDistributionRuntime( + private val selfPeerId: String = "wasm-${Clock.System.now().toEpochMilliseconds() % 100000}", + private var signalingEndpoint: String = OrgFlowAppStore.DEFAULT_SIGNALING_ENDPOINT, + private val roomId: String = "default", + private var iceServers: List = OrgFlowAppStore.DEFAULT_ICE_SERVERS, +) : DistributionRuntime, ConfigRuntime { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val roster = SignalingPeerRoster(selfPeerId) + + private var signaling: WsSignalingClient? = null + private var polledClient: WsSignalingClient? = null + private var collector: Job? = null + private var knownConnected = false + private var started = false + private var lastEvent = "idle" + + private val _snapshot = MutableStateFlow(DistributionSnapshot(selfPeerId = selfPeerId)) + override val snapshot: StateFlow = _snapshot + + private val _config = MutableStateFlow( + ConfigSnapshot(signalingEndpoint = signalingEndpoint, iceServers = iceServers), + ) + override val config: StateFlow = _config + + override suspend fun start() { + if (started) return + started = true + connectSignaling(signalingEndpoint) + scope.launch { + while (scope.isActive) { + val client = signaling + if (client != null) { + if (client !== polledClient) { + polledClient = client + runCatching { client.connect() } + } + runCatching { client.poll() } + if (client.connected != knownConnected) { + knownConnected = client.connected + lastEvent = if (client.connected) { + "connected to signaling $signalingEndpoint room=$roomId" + } else { + "signaling disconnected" + } + refreshSnapshot() + } + } + delay(POLL_INTERVAL_MS) + } + } + lastEvent = "connecting to signaling $signalingEndpoint room=$roomId" + refreshSnapshot() + } + + override fun stop() { + started = false + val client = signaling + signaling = null + polledClient = null + collector?.cancel() + collector = null + if (client != null) scope.launch { runCatching { client.close() } } + lastEvent = "stopped" + refreshSnapshot() + } + + override fun startDistribution() { + lastEvent = "no datachannel transfer on wasm yet (signaling roster only)" + refreshSnapshot() + } + + override fun update(fieldKey: String, value: Double) { + lastEvent = "fsmp numeric config not applied on wasm: $fieldKey=$value" + refreshConfigSnapshot(nowMs()) + } + + override fun updateEndpoint(endpoint: String) { + val next = endpoint.trim() + if (next.isBlank()) return + signalingEndpoint = next + lastEvent = "signaling endpoint updated: $next" + if (started) connectSignaling(next) + refreshConfigSnapshot(nowMs()) + refreshSnapshot() + } + + override fun updateIceServers(servers: List) { + val cleaned = servers.map { it.trim() }.filter { it.isNotBlank() } + if (cleaned.isEmpty()) return + iceServers = cleaned + lastEvent = "ice servers updated: ${cleaned.size} entries (applied with datachannel transfer)" + refreshConfigSnapshot(nowMs()) + } + + private fun connectSignaling(endpoint: String) { + val previous = signaling + val client = WsSignalingClient(endpoint = endpoint, selfPeerId = selfPeerId, roomId = roomId) + signaling = client + polledClient = null + knownConnected = false + roster.clear() + collector?.cancel() + collector = scope.launch { + client.incoming.collect { envelope -> onEnvelope(envelope) } + } + if (previous != null) scope.launch { runCatching { previous.close() } } + } + + private fun onEnvelope(envelope: SignalingEnvelope) { + val before = roster.peers.size + roster.onEnvelope(envelope) + when (envelope.type) { + WebRtcMeshTransport.TYPE_PEER_JOINED -> if (roster.peers.size != before) { + lastEvent = "peer ${envelope.senderPeerId} joined room $roomId" + } + WebRtcMeshTransport.TYPE_PEER_LEFT -> if (roster.peers.size != before) { + lastEvent = "peer ${envelope.senderPeerId} left room $roomId" + } + } + refreshSnapshot() + } + + private fun refreshSnapshot() { + val client = signaling + val connected = client?.connected == true + _snapshot.value = DistributionSnapshot( + selfPeerId = selfPeerId, + signalingEndpoint = signalingEndpoint, + connected = connected, + configDir = "browser (in-session)", + engineReady = connected, + peers = roster.peers.map { peerId -> + UiPeerSync( + peerId = peerId, + connected = connected, + ratio = 0.0, + level = UiWaterline.PREVIEW, + ) + }, + lastEvent = lastEvent, + ) + } + + private fun refreshConfigSnapshot(savedAtMs: Long) { + _config.value = ConfigSnapshot( + configDir = "browser (in-session)", + lastSavedAtMs = savedAtMs, + signalingEndpoint = signalingEndpoint, + iceServers = iceServers, + ) + } + + private fun nowMs(): Long = Clock.System.now().toEpochMilliseconds() + + private companion object { + const val POLL_INTERVAL_MS = 100L + } +} + +private val wasmRuntime = WasmDistributionRuntime() + +actual fun provideRuntime(): OrgFlowRuntime = OrgFlowRuntime(wasmRuntime, wasmRuntime) -- cgit v1.2.1