Merge pull request #3502 from vector-im/feature/bca/spaces_dnd

Feature/bca/spaces dnd
This commit is contained in:
Benoit Marty 2021-06-18 10:28:59 +02:00 committed by GitHub
commit 5325c761f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 829 additions and 38 deletions

View file

@ -0,0 +1,260 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk
import org.amshove.kluent.internal.assertEquals
import org.junit.Assert
import org.junit.Test
import org.matrix.android.sdk.api.session.space.SpaceOrderUtils
class SpaceOrderTest {
@Test
fun testOrderBetweenNodesWithOrder() {
val orderedSpaces = listOf(
"roomId1" to "a",
"roomId2" to "m",
"roomId3" to "z"
).assertSpaceOrdered()
val orderCommand = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId1", 1)
Assert.assertTrue("Only one order should be changed", orderCommand.size == 1)
Assert.assertTrue("Moved space order should change", orderCommand.first().spaceId == "roomId1")
Assert.assertTrue("m" < orderCommand[0].order)
Assert.assertTrue(orderCommand[0].order < "z")
}
@Test
fun testMoveLastBetweenNodesWithOrder() {
val orderedSpaces = listOf(
"roomId1" to "a",
"roomId2" to "m",
"roomId3" to "z"
).assertSpaceOrdered()
val orderCommand = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId1", 2)
Assert.assertTrue("Only one order should be changed", orderCommand.size == 1)
Assert.assertTrue("Moved space order should change", orderCommand.first().spaceId == "roomId1")
Assert.assertTrue("z" < orderCommand[0].order)
}
@Test
fun testMoveUpNoOrder() {
val orderedSpaces = listOf(
"roomId1" to null,
"roomId2" to null,
"roomId3" to null
).assertSpaceOrdered()
val orderCommand = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId1", 1)
Assert.assertTrue("2 orders change should be needed", orderCommand.size == 2)
val reOrdered = reOrderWithCommands(orderedSpaces, orderCommand)
Assert.assertEquals("roomId2", reOrdered[0].first)
Assert.assertEquals("roomId1", reOrdered[1].first)
Assert.assertEquals("roomId3", reOrdered[2].first)
}
@Test
fun testMoveUpNotEnoughSpace() {
val orderedSpaces = listOf(
"roomId1" to "a",
"roomId2" to "j",
"roomId3" to "k"
).assertSpaceOrdered()
val orderCommand = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId1", 1)
Assert.assertTrue("more orders change should be needed", orderCommand.size > 1)
val reOrdered = reOrderWithCommands(orderedSpaces, orderCommand)
Assert.assertEquals("roomId2", reOrdered[0].first)
Assert.assertEquals("roomId1", reOrdered[1].first)
Assert.assertEquals("roomId3", reOrdered[2].first)
}
@Test
fun testMoveEndNoOrder() {
val orderedSpaces = listOf(
"roomId1" to null,
"roomId2" to null,
"roomId3" to null
).assertSpaceOrdered()
val orderCommand = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId1", 2)
// Actually 2 could be enough... as it's last it can stays with null
Assert.assertEquals("3 orders change should be needed", 3, orderCommand.size)
val reOrdered = reOrderWithCommands(orderedSpaces, orderCommand)
Assert.assertEquals("roomId2", reOrdered[0].first)
Assert.assertEquals("roomId3", reOrdered[1].first)
Assert.assertEquals("roomId1", reOrdered[2].first)
}
@Test
fun testMoveUpBiggerOrder() {
val orderedSpaces = listOf(
"roomId1" to "aaaa",
"roomId2" to "ffff",
"roomId3" to "pppp",
"roomId4" to null,
"roomId5" to null,
"roomId6" to null
).assertSpaceOrdered()
val orderCommand = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId2", 3)
// Actually 2 could be enough... as it's last it can stays with null
Assert.assertEquals("3 orders change should be needed", 3, orderCommand.size)
val reOrdered = reOrderWithCommands(orderedSpaces, orderCommand)
Assert.assertEquals("roomId1", reOrdered[0].first)
Assert.assertEquals("roomId3", reOrdered[1].first)
Assert.assertEquals("roomId4", reOrdered[2].first)
Assert.assertEquals("roomId5", reOrdered[3].first)
Assert.assertEquals("roomId2", reOrdered[4].first)
Assert.assertEquals("roomId6", reOrdered[5].first)
}
@Test
fun testMoveDownBetweenNodesWithOrder() {
val orderedSpaces = listOf(
"roomId1" to "a",
"roomId2" to "m",
"roomId3" to "z"
).assertSpaceOrdered()
val orderCommand = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId3", -1)
Assert.assertTrue("Only one order should be changed", orderCommand.size == 1)
Assert.assertTrue("Moved space order should change", orderCommand.first().spaceId == "roomId3")
val reOrdered = reOrderWithCommands(orderedSpaces, orderCommand)
Assert.assertEquals("roomId1", reOrdered[0].first)
Assert.assertEquals("roomId3", reOrdered[1].first)
Assert.assertEquals("roomId2", reOrdered[2].first)
}
@Test
fun testMoveDownNoOrder() {
val orderedSpaces = listOf(
"roomId1" to null,
"roomId2" to null,
"roomId3" to null
).assertSpaceOrdered()
val orderCommand = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId3", -1)
Assert.assertTrue("2 orders change should be needed", orderCommand.size == 2)
val reOrdered = reOrderWithCommands(orderedSpaces, orderCommand)
Assert.assertEquals("roomId1", reOrdered[0].first)
Assert.assertEquals("roomId3", reOrdered[1].first)
Assert.assertEquals("roomId2", reOrdered[2].first)
}
@Test
fun testMoveDownBiggerOrder() {
val orderedSpaces = listOf(
"roomId1" to "aaaa",
"roomId2" to "ffff",
"roomId3" to "pppp",
"roomId4" to null,
"roomId5" to null,
"roomId6" to null
).assertSpaceOrdered()
val orderCommand = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId5", -4)
Assert.assertEquals("1 order change should be needed", 1, orderCommand.size)
val reOrdered = reOrderWithCommands(orderedSpaces, orderCommand)
Assert.assertEquals("roomId5", reOrdered[0].first)
Assert.assertEquals("roomId1", reOrdered[1].first)
Assert.assertEquals("roomId2", reOrdered[2].first)
Assert.assertEquals("roomId3", reOrdered[3].first)
Assert.assertEquals("roomId4", reOrdered[4].first)
Assert.assertEquals("roomId6", reOrdered[5].first)
}
@Test
fun testMultipleMoveOrder() {
val orderedSpaces = listOf(
"roomId1" to null,
"roomId2" to null,
"roomId3" to null,
"roomId4" to null,
"roomId5" to null,
"roomId6" to null
).assertSpaceOrdered()
// move 5 to Top
val fiveToTop = SpaceOrderUtils.orderCommandsForMove(orderedSpaces, "roomId5", -4)
val fiveTopReOrder = reOrderWithCommands(orderedSpaces, fiveToTop)
// now move 4 to second
val orderCommand = SpaceOrderUtils.orderCommandsForMove(fiveTopReOrder, "roomId4", -3)
val reOrdered = reOrderWithCommands(fiveTopReOrder, orderCommand)
// second order should cost 1 re-order
Assert.assertEquals(1, orderCommand.size)
Assert.assertEquals("roomId5", reOrdered[0].first)
Assert.assertEquals("roomId4", reOrdered[1].first)
Assert.assertEquals("roomId1", reOrdered[2].first)
Assert.assertEquals("roomId2", reOrdered[3].first)
Assert.assertEquals("roomId3", reOrdered[4].first)
Assert.assertEquals("roomId6", reOrdered[5].first)
}
@Test
fun testComparator() {
listOf(
"roomId2" to "a",
"roomId1" to "b",
"roomId3" to null,
"roomId4" to null
).assertSpaceOrdered()
}
private fun reOrderWithCommands(orderedSpaces: List<Pair<String, String?>>, orderCommand: List<SpaceOrderUtils.SpaceReOrderCommand>) =
orderedSpaces.map { orderInfo ->
orderInfo.first to (orderCommand.find { it.spaceId == orderInfo.first }?.order ?: orderInfo.second)
}
.sortedWith(testSpaceComparator)
private fun List<Pair<String, String?>>.assertSpaceOrdered(): List<Pair<String, String?>> {
assertEquals(this, this.sortedWith(testSpaceComparator))
return this
}
private val testSpaceComparator = compareBy<Pair<String, String?>, String?>(nullsLast()) { it.second }.thenBy { it.first }
}

View file

@ -0,0 +1,110 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk
import org.amshove.kluent.internal.assertEquals
import org.junit.Assert
import org.junit.Test
import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.util.StringOrderUtils
class StringOrderTest {
@Test
fun testbasing() {
assertEquals("a", StringOrderUtils.baseToString(StringOrderUtils.stringToBase("a", StringOrderUtils.DEFAULT_ALPHABET), StringOrderUtils.DEFAULT_ALPHABET))
assertEquals("element", StringOrderUtils.baseToString(StringOrderUtils.stringToBase("element", StringOrderUtils.DEFAULT_ALPHABET), StringOrderUtils.DEFAULT_ALPHABET))
assertEquals("matrix", StringOrderUtils.baseToString(StringOrderUtils.stringToBase("matrix", StringOrderUtils.DEFAULT_ALPHABET), StringOrderUtils.DEFAULT_ALPHABET))
}
@Test
fun testValid() {
println(StringOrderUtils.DEFAULT_ALPHABET.joinToString(","))
assert(MatrixPatterns.isValidOrderString("a"))
assert(MatrixPatterns.isValidOrderString(" "))
assert(MatrixPatterns.isValidOrderString("abc"))
assert(!MatrixPatterns.isValidOrderString("abcê"))
assert(!MatrixPatterns.isValidOrderString(""))
assert(MatrixPatterns.isValidOrderString("!"))
assert(MatrixPatterns.isValidOrderString("!\"#\$%&'()*+,012"))
assert(!MatrixPatterns.isValidOrderString(Char(' '.code - 1).toString()))
assert(!MatrixPatterns.isValidOrderString(
buildString {
for (i in 0..49) {
append(StringOrderUtils.DEFAULT_ALPHABET.random())
}
}
))
assert(MatrixPatterns.isValidOrderString(
buildString {
for (i in 0..48) {
append(StringOrderUtils.DEFAULT_ALPHABET.random())
}
}
))
}
@Test
fun testAverage() {
assertAverage("${StringOrderUtils.DEFAULT_ALPHABET.first()}", "m")
assertAverage("aa", "aab")
assertAverage("matrix", "element")
assertAverage("mmm", "mmmmm")
assertAverage("aab", "aa")
assertAverage("", "aa")
assertAverage("a", "z")
assertAverage("ground", "sky")
}
@Test
fun testMidPoints() {
val orders = StringOrderUtils.midPoints("element", "matrix", 4)
assertEquals(4, orders!!.size)
assert("element" < orders[0])
assert(orders[0] < orders[1])
assert(orders[1] < orders[2])
assert(orders[2] < orders[3])
assert(orders[3] < "matrix")
println("element < ${orders.joinToString(" < ") { "[$it]" }} < matrix")
val orders2 = StringOrderUtils.midPoints("a", "d", 4)
assertEquals(null, orders2)
}
@Test
fun testRenumberNeeded() {
assertEquals(null, StringOrderUtils.average("a", "a"))
assertEquals(null, StringOrderUtils.average("", ""))
assertEquals(null, StringOrderUtils.average("a", "b"))
assertEquals(null, StringOrderUtils.average("b", "a"))
assertEquals(null, StringOrderUtils.average("mmmm", "mmmm"))
assertEquals(null, StringOrderUtils.average("a${Char(0)}", "a"))
}
private fun assertAverage(first: String, second: String) {
val left = first.takeIf { first < second } ?: second
val right = first.takeIf { first > second } ?: second
val av1 = StringOrderUtils.average(left, right)!!
println("[$left] < [$av1] < [$right]")
Assert.assertTrue(left < av1)
Assert.assertTrue(av1 < right)
}
}

View file

@ -71,6 +71,9 @@ object MatrixPatterns {
private const val LINK_TO_APP_ROOM_ALIAS_REGEXP = APP_BASE_REGEX + MATRIX_ROOM_ALIAS_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX private const val LINK_TO_APP_ROOM_ALIAS_REGEXP = APP_BASE_REGEX + MATRIX_ROOM_ALIAS_REGEX + SEP_REGEX + MATRIX_EVENT_IDENTIFIER_REGEX
private val PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ALIAS = LINK_TO_APP_ROOM_ALIAS_REGEXP.toRegex(RegexOption.IGNORE_CASE) private val PATTERN_CONTAIN_APP_LINK_PERMALINK_ROOM_ALIAS = LINK_TO_APP_ROOM_ALIAS_REGEXP.toRegex(RegexOption.IGNORE_CASE)
// ascii characters in the range \x20 (space) to \x7E (~)
val ORDER_STRING_REGEX = "[ -~]+".toRegex()
// list of patterns to find some matrix item. // list of patterns to find some matrix item.
val MATRIX_PATTERNS = listOf( val MATRIX_PATTERNS = listOf(
PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ID, PATTERN_CONTAIN_MATRIX_TO_PERMALINK_ROOM_ID,
@ -146,4 +149,12 @@ object MatrixPatterns {
fun extractServerNameFromId(matrixId: String?): String? { fun extractServerNameFromId(matrixId: String?): String? {
return matrixId?.substringAfter(":", missingDelimiterValue = "")?.takeIf { it.isNotEmpty() } return matrixId?.substringAfter(":", missingDelimiterValue = "")?.takeIf { it.isNotEmpty() }
} }
/**
* Orders which are not strings, or do not consist solely of ascii characters in the range \x20 (space) to \x7E (~),
* or consist of more than 50 characters, are forbidden and the field should be ignored if received.
*/
fun isValidOrderString(order: String?) : Boolean {
return order != null && order.length < 50 && order matches ORDER_STRING_REGEX
}
} }

View file

@ -20,4 +20,5 @@ object RoomAccountDataTypes {
const val EVENT_TYPE_VIRTUAL_ROOM = "im.vector.is_virtual_room" const val EVENT_TYPE_VIRTUAL_ROOM = "im.vector.is_virtual_room"
const val EVENT_TYPE_TAG = "m.tag" const val EVENT_TYPE_TAG = "m.tag"
const val EVENT_TYPE_FULLY_READ = "m.fully_read" const val EVENT_TYPE_FULLY_READ = "m.fully_read"
const val EVENT_TYPE_SPACE_ORDER = "org.matrix.msc3230.space_order" // m.space_order
} }

View file

@ -0,0 +1,105 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.space
import org.matrix.android.sdk.api.util.StringOrderUtils
/**
* Adds some utilities to compute correct string orders when ordering spaces.
* After moving a space (e.g via DnD), client should limit the number of room account data update.
* For example if the space is moved between two other spaces with orders, just update the moved space order by computing
* a mid point between the surrounding orders.
* If the space is moved after a space with no order, all the previous spaces should be then ordered,
* and the computed orders should be chosen so that there is enough gaps in between them to facilitate future re-order.
* Re numbering (i.e change all spaces m.space.order account data) should be avoided as much as possible,
* as the updates might not be atomic for other clients and would makes spaces jump around.
*/
object SpaceOrderUtils {
data class SpaceReOrderCommand(
val spaceId: String,
val order: String
)
/**
* Returns a minimal list of order change in order to re order the space list as per given move.
*/
fun orderCommandsForMove(orderedSpacesToOrderMap: List<Pair<String, String?>>, movedSpaceId: String, delta: Int): List<SpaceReOrderCommand> {
val movedIndex = orderedSpacesToOrderMap.indexOfFirst { it.first == movedSpaceId }
if (movedIndex == -1) return emptyList()
if (delta == 0) return emptyList()
val targetIndex = if (delta > 0) movedIndex + delta else (movedIndex + delta - 1)
val nodesToReNumber = mutableListOf<String>()
var lowerBondOrder: String? = null
var index = targetIndex
while (index >= 0 && lowerBondOrder == null) {
val node = orderedSpacesToOrderMap.getOrNull(index)
if (node != null /*null when adding at the end*/) {
val nodeOrder = node.second
if (node.first == movedSpaceId) break
if (nodeOrder == null) {
nodesToReNumber.add(0, node.first)
} else {
lowerBondOrder = nodeOrder
}
}
index--
}
nodesToReNumber.add(movedSpaceId)
val afterSpace: Pair<String, String?>? = if (orderedSpacesToOrderMap.indices.contains(targetIndex + 1)) {
orderedSpacesToOrderMap[targetIndex + 1]
} else null
val defaultMaxOrder = CharArray(4) { StringOrderUtils.DEFAULT_ALPHABET.last() }
.joinToString("")
val defaultMinOrder = CharArray(4) { StringOrderUtils.DEFAULT_ALPHABET.first() }
.joinToString("")
val afterOrder = afterSpace?.second ?: defaultMaxOrder
val beforeOrder = lowerBondOrder ?: defaultMinOrder
val newOrder = StringOrderUtils.midPoints(beforeOrder, afterOrder, nodesToReNumber.size)
if (newOrder.isNullOrEmpty()) {
// re order all?
val expectedList = orderedSpacesToOrderMap.toMutableList()
expectedList.removeAt(movedIndex).let {
expectedList.add(movedIndex + delta, it)
}
return StringOrderUtils.midPoints(defaultMinOrder, defaultMaxOrder, orderedSpacesToOrderMap.size)?.let { orders ->
expectedList.mapIndexed { index, pair ->
SpaceReOrderCommand(
pair.first,
orders[index]
)
}
} ?: emptyList()
} else {
return nodesToReNumber.mapIndexed { i, s ->
SpaceReOrderCommand(
s,
newOrder[i]
)
}
}
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.space.model
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.MatrixPatterns
/**
* {
* "type": "m.space_order",
* "content": {
* "order": "..."
* }
* }
*/
@JsonClass(generateAdapter = true)
data class SpaceOrderContent(
val order: String? = null
) {
fun safeOrder(): String? {
return order?.takeIf { MatrixPatterns.isValidOrderString(it) }
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.space.model
import org.matrix.android.sdk.api.session.room.model.RoomSummary
// Can't use regular compare by because Null is considered less than any value, and for space order it's the opposite
class TopLevelSpaceComparator(val orders: Map<String, String?>) : Comparator<RoomSummary> {
override fun compare(left: RoomSummary?, right: RoomSummary?): Int {
val leftOrder = left?.roomId?.let { orders[it] }
val rightOrder = right?.roomId?.let { orders[it] }
return if (leftOrder != null && rightOrder != null) {
leftOrder.compareTo(rightOrder)
} else {
if (leftOrder == null) {
if (rightOrder == null) {
compareValues(left?.roomId, right?.roomId)
} else {
1
}
} else {
-1
}
}
// .also {
// Timber.w("VAL: compare(${left?.displayName} | $leftOrder ,${right?.displayName} | $rightOrder) = $it")
// }
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.util
import java.math.BigInteger
object StringOrderUtils {
val DEFAULT_ALPHABET = buildString {
for (i in 0x20..0x7E) {
append(Char(i))
}
}.toCharArray()
// /=Range(0x20, 0x7E)
fun average(left: String, right: String, alphabet: CharArray = DEFAULT_ALPHABET): String? {
return midPoints(left, right, 1, alphabet)?.firstOrNull()
}
fun midPoints(left: String, right: String, count: Int, alphabet: CharArray = DEFAULT_ALPHABET): List<String>? {
if (left == right) return null // no space in between..
if (left > right) return midPoints(right, left, count, alphabet)
val size = maxOf(left.length, right.length)
val leftPadded = pad(left, size, alphabet.first())
val rightPadded = pad(right, size, alphabet.first())
val b1 = stringToBase(leftPadded, alphabet)
val b2 = stringToBase(rightPadded, alphabet)
val step = (b2.minus(b1)).div(BigInteger("${count + 1}"))
val orders = mutableListOf<String>()
var previous = left
for (i in 0 until count) {
val newOrder = baseToString(b1.add(step.multiply(BigInteger("${i + 1}"))), alphabet)
orders.add(newOrder)
// ensure there was enought precision
if (previous >= newOrder) return null
previous = newOrder
}
return orders.takeIf { orders.last() < right }
}
private fun pad(string: String, size: Int, padding: Char): String {
val raw = string.toCharArray()
return CharArray(size).also {
for (index in it.indices) {
if (index < raw.size) {
it[index] = raw[index]
} else {
it[index] = padding
}
}
}.joinToString("")
}
fun baseToString(x: BigInteger, alphabet: CharArray): String {
val len = alphabet.size.toBigInteger()
if (x < len) {
return alphabet[x.toInt()].toString()
} else {
return baseToString(x.div(len), alphabet) + alphabet[x.rem(len).toInt()].toString()
}
}
fun stringToBase(x: String, alphabet: CharArray): BigInteger {
if (x.isEmpty()) throw IllegalArgumentException()
val len = alphabet.size.toBigInteger()
var result = BigInteger("0")
x.reversed().forEachIndexed { index, c ->
result += (alphabet.indexOf(c).toBigInteger() * len.pow(index))
}
return result
}
}

View file

@ -0,0 +1 @@
User defined top level spaces ordering (#3501)

View file

@ -26,6 +26,9 @@ sealed class SpaceListAction : VectorViewModelAction {
data class LeaveSpace(val spaceSummary: RoomSummary) : SpaceListAction() data class LeaveSpace(val spaceSummary: RoomSummary) : SpaceListAction()
data class ToggleExpand(val spaceSummary: RoomSummary) : SpaceListAction() data class ToggleExpand(val spaceSummary: RoomSummary) : SpaceListAction()
object AddSpace : SpaceListAction() object AddSpace : SpaceListAction()
data class MoveSpace(val spaceId: String, val delta : Int) : SpaceListAction()
data class OnStartDragging(val spaceId: String, val expanded: Boolean) : SpaceListAction()
data class OnEndDragging(val spaceId: String, val expanded: Boolean) : SpaceListAction()
data class SelectLegacyGroup(val groupSummary: GroupSummary?) : SpaceListAction() data class SelectLegacyGroup(val groupSummary: GroupSummary?) : SpaceListAction()
} }

View file

@ -17,9 +17,11 @@
package im.vector.app.features.spaces package im.vector.app.features.spaces
import android.os.Bundle import android.os.Bundle
import android.view.HapticFeedbackConstants
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.airbnb.epoxy.EpoxyTouchHelper
import com.airbnb.mvrx.Incomplete import com.airbnb.mvrx.Incomplete
import com.airbnb.mvrx.Success import com.airbnb.mvrx.Success
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
@ -54,6 +56,53 @@ class SpaceListFragment @Inject constructor(
spaceController.callback = this spaceController.callback = this
views.stateView.contentView = views.groupListView views.stateView.contentView = views.groupListView
views.groupListView.configureWith(spaceController) views.groupListView.configureWith(spaceController)
EpoxyTouchHelper.initDragging(spaceController)
.withRecyclerView(views.groupListView)
.forVerticalList()
.withTarget(SpaceSummaryItem::class.java)
.andCallbacks(object : EpoxyTouchHelper.DragCallbacks<SpaceSummaryItem>() {
var toPositionM: Int? = null
var fromPositionM: Int? = null
var initialElevation: Float? = null
override fun onDragStarted(model: SpaceSummaryItem?, itemView: View?, adapterPosition: Int) {
toPositionM = null
fromPositionM = null
model?.matrixItem?.id?.let {
viewModel.handle(SpaceListAction.OnStartDragging(it, model.expanded))
}
itemView?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
initialElevation = itemView?.elevation
itemView?.elevation = 6f
}
override fun onDragReleased(model: SpaceSummaryItem?, itemView: View?) {
// Timber.v("VAL: onModelMoved from $fromPositionM to $toPositionM ${model?.matrixItem?.getBestName()}")
if (toPositionM == null || fromPositionM == null) return
val movingSpace = model?.matrixItem?.id ?: return
viewModel.handle(SpaceListAction.MoveSpace(movingSpace, toPositionM!! - fromPositionM!!))
}
override fun clearView(model: SpaceSummaryItem?, itemView: View?) {
// Timber.v("VAL: clearView ${model?.matrixItem?.getBestName()}")
itemView?.elevation = initialElevation ?: 0f
}
override fun onModelMoved(fromPosition: Int, toPosition: Int, modelBeingMoved: SpaceSummaryItem?, itemView: View?) {
// Timber.v("VAL: onModelMoved incremental from $fromPosition to $toPosition ${modelBeingMoved?.matrixItem?.getBestName()}")
if (fromPositionM == null) {
fromPositionM = fromPosition
}
toPositionM = toPosition
itemView?.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
override fun isDragEnabledForModel(model: SpaceSummaryItem?): Boolean {
// Timber.v("VAL: isDragEnabledForModel ${model?.matrixItem?.getBestName()}")
return model?.canDrag == true
}
})
viewModel.observeViewEvents { viewModel.observeViewEvents {
when (it) { when (it) {
is SpaceListViewEvents.OpenSpaceSummary -> sharedActionViewModel.post(HomeActivitySharedAction.OpenSpacePreview(it.id)) is SpaceListViewEvents.OpenSpaceSummary -> sharedActionViewModel.post(HomeActivitySharedAction.OpenSpacePreview(it.id))
@ -86,6 +135,7 @@ class SpaceListFragment @Inject constructor(
override fun onSpaceInviteSelected(spaceSummary: RoomSummary) { override fun onSpaceInviteSelected(spaceSummary: RoomSummary) {
viewModel.handle(SpaceListAction.OpenSpaceInvite(spaceSummary)) viewModel.handle(SpaceListAction.OpenSpaceInvite(spaceSummary))
} }
override fun onSpaceSettings(spaceSummary: RoomSummary) { override fun onSpaceSettings(spaceSummary: RoomSummary) {
sharedActionViewModel.post(HomeActivitySharedAction.ShowSpaceSettings(spaceSummary.roomId)) sharedActionViewModel.post(HomeActivitySharedAction.ShowSpaceSettings(spaceSummary.roomId))
} }

View file

@ -29,7 +29,9 @@ data class SpaceListViewState(
val myMxItem : Async<MatrixItem.UserItem> = Uninitialized, val myMxItem : Async<MatrixItem.UserItem> = Uninitialized,
val asyncSpaces: Async<List<RoomSummary>> = Uninitialized, val asyncSpaces: Async<List<RoomSummary>> = Uninitialized,
val selectedGroupingMethod: RoomGroupingMethod = RoomGroupingMethod.BySpace(null), val selectedGroupingMethod: RoomGroupingMethod = RoomGroupingMethod.BySpace(null),
val rootSpaces: List<RoomSummary>? = null, val rootSpacesOrdered: List<RoomSummary>? = null,
val spaceOrderInfo: Map<String, String?>? = null,
val spaceOrderLocalEchos: Map<String, String?>? = null,
val legacyGroups: List<GroupSummary>? = null, val legacyGroups: List<GroupSummary>? = null,
val expandedStates: Map<String, Boolean> = emptyMap(), val expandedStates: Map<String, Boolean> = emptyMap(),
val homeAggregateCount : RoomAggregateNotificationCount = RoomAggregateNotificationCount(0, 0) val homeAggregateCount : RoomAggregateNotificationCount = RoomAggregateNotificationCount(0, 0)

View file

@ -62,7 +62,7 @@ class SpaceSummaryController @Inject constructor(
buildGroupModels( buildGroupModels(
nonNullViewState.asyncSpaces(), nonNullViewState.asyncSpaces(),
nonNullViewState.selectedGroupingMethod, nonNullViewState.selectedGroupingMethod,
nonNullViewState.rootSpaces, nonNullViewState.rootSpacesOrdered,
nonNullViewState.expandedStates, nonNullViewState.expandedStates,
nonNullViewState.homeAggregateCount) nonNullViewState.homeAggregateCount)
@ -127,6 +127,7 @@ class SpaceSummaryController @Inject constructor(
countState(UnreadCounterBadgeView.State(1, true)) countState(UnreadCounterBadgeView.State(1, true))
selected(false) selected(false)
description(host.stringProvider.getString(R.string.you_are_invited)) description(host.stringProvider.getString(R.string.you_are_invited))
canDrag(false)
listener { host.callback?.onSpaceInviteSelected(roomSummary) } listener { host.callback?.onSpaceInviteSelected(roomSummary) }
} }
} }
@ -139,7 +140,6 @@ class SpaceSummaryController @Inject constructor(
} }
rootSpaces rootSpaces
?.sortedBy { it.roomId }
?.forEach { groupSummary -> ?.forEach { groupSummary ->
val isSelected = selected is RoomGroupingMethod.BySpace && groupSummary.roomId == selected.space()?.roomId val isSelected = selected is RoomGroupingMethod.BySpace && groupSummary.roomId == selected.space()?.roomId
// does it have children? // does it have children?
@ -154,8 +154,12 @@ class SpaceSummaryController @Inject constructor(
id(groupSummary.roomId) id(groupSummary.roomId)
hasChildren(hasChildren) hasChildren(hasChildren)
expanded(expanded) expanded(expanded)
// to debug order
// matrixItem(groupSummary.copy(displayName = "${groupSummary.displayName} / ${spaceOrderInfo?.get(groupSummary.roomId)}")
// .toMatrixItem())
matrixItem(groupSummary.toMatrixItem()) matrixItem(groupSummary.toMatrixItem())
selected(isSelected) selected(isSelected)
canDrag(true)
onMore { host.callback?.onSpaceSettings(groupSummary) } onMore { host.callback?.onSpaceSettings(groupSummary) }
listener { host.callback?.onSpaceSelected(groupSummary) } listener { host.callback?.onSpaceSelected(groupSummary) }
toggleExpand { host.callback?.onToggleExpand(groupSummary) } toggleExpand { host.callback?.onToggleExpand(groupSummary) }

View file

@ -51,6 +51,7 @@ abstract class SpaceSummaryItem : VectorEpoxyModel<SpaceSummaryItem.Holder>() {
@EpoxyAttribute var countState: UnreadCounterBadgeView.State = UnreadCounterBadgeView.State(0, false) @EpoxyAttribute var countState: UnreadCounterBadgeView.State = UnreadCounterBadgeView.State(0, false)
@EpoxyAttribute var description: String? = null @EpoxyAttribute var description: String? = null
@EpoxyAttribute var showSeparator: Boolean = false @EpoxyAttribute var showSeparator: Boolean = false
@EpoxyAttribute var canDrag: Boolean = true
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)

View file

@ -28,23 +28,30 @@ import dagger.assisted.AssistedInject
import im.vector.app.AppStateHandler import im.vector.app.AppStateHandler
import im.vector.app.RoomGroupingMethod import im.vector.app.RoomGroupingMethod
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.session.coroutineScope
import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.VectorPreferences
import im.vector.app.group import im.vector.app.group
import im.vector.app.space import im.vector.app.space
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.query.ActiveSpaceFilter import org.matrix.android.sdk.api.query.ActiveSpaceFilter
import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.group.groupSummaryQueryParams import org.matrix.android.sdk.api.session.group.groupSummaryQueryParams
import org.matrix.android.sdk.api.session.room.RoomSortOrder import org.matrix.android.sdk.api.session.room.RoomSortOrder
import org.matrix.android.sdk.api.session.room.accountdata.RoomAccountDataEvent
import org.matrix.android.sdk.api.session.room.accountdata.RoomAccountDataTypes
import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams
import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount import org.matrix.android.sdk.api.session.room.summary.RoomAggregateNotificationCount
import org.matrix.android.sdk.api.session.space.SpaceOrderUtils
import org.matrix.android.sdk.api.session.space.model.SpaceOrderContent
import org.matrix.android.sdk.api.session.space.model.TopLevelSpaceComparator
import org.matrix.android.sdk.api.session.user.model.User import org.matrix.android.sdk.api.session.user.model.User
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.rx.asObservable import org.matrix.android.sdk.rx.asObservable
@ -143,24 +150,6 @@ class SpacesListViewModel @AssistedInject constructor(@Assisted initialState: Sp
}.disposeOnClear() }.disposeOnClear()
} }
// private fun observeSelectionState() {
// selectSubscribe(SpaceListViewState::selectedSpace) { spaceSummary ->
// if (spaceSummary != null) {
// // We only want to open group if the updated selectedGroup is a different one.
// if (currentGroupId != spaceSummary.roomId) {
// currentGroupId = spaceSummary.roomId
// _viewEvents.post(SpaceListViewEvents.OpenSpace)
// }
// appStateHandler.setCurrentSpace(spaceSummary.roomId)
// } else {
// // If selected group is null we force to default. It can happens when leaving the selected group.
// setState {
// copy(selectedSpace = this.asyncSpaces()?.find { it.roomId == ALL_COMMUNITIES_GROUP_ID })
// }
// }
// }
// }
override fun handle(action: SpaceListAction) { override fun handle(action: SpaceListAction) {
when (action) { when (action) {
is SpaceListAction.SelectSpace -> handleSelectSpace(action) is SpaceListAction.SelectSpace -> handleSelectSpace(action)
@ -169,11 +158,78 @@ class SpacesListViewModel @AssistedInject constructor(@Assisted initialState: Sp
is SpaceListAction.ToggleExpand -> handleToggleExpand(action) is SpaceListAction.ToggleExpand -> handleToggleExpand(action)
is SpaceListAction.OpenSpaceInvite -> handleSelectSpaceInvite(action) is SpaceListAction.OpenSpaceInvite -> handleSelectSpaceInvite(action)
is SpaceListAction.SelectLegacyGroup -> handleSelectGroup(action) is SpaceListAction.SelectLegacyGroup -> handleSelectGroup(action)
is SpaceListAction.MoveSpace -> handleMoveSpace(action)
is SpaceListAction.OnEndDragging -> handleEndDragging()
is SpaceListAction.OnStartDragging -> handleStartDragging()
} }
} }
// PRIVATE METHODS ***************************************************************************** // PRIVATE METHODS *****************************************************************************
var preDragExpandedState: Map<String, Boolean>? = null
private fun handleStartDragging() = withState { state ->
preDragExpandedState = state.expandedStates.toMap()
setState {
copy(
expandedStates = expandedStates.map {
it.key to false
}.toMap()
)
}
}
private fun handleEndDragging() {
// restore expanded state
setState {
copy(
expandedStates = preDragExpandedState.orEmpty()
)
}
}
private fun handleMoveSpace(action: SpaceListAction.MoveSpace) = withState { state ->
state.rootSpacesOrdered ?: return@withState
val orderCommands = SpaceOrderUtils.orderCommandsForMove(
state.rootSpacesOrdered.map {
it.roomId to (state.spaceOrderLocalEchos?.get(it.roomId) ?: state.spaceOrderInfo?.get(it.roomId))
},
action.spaceId,
action.delta
)
// local echo
val updatedLocalEchos = state.spaceOrderLocalEchos.orEmpty().toMutableMap().apply {
orderCommands.forEach {
this[it.spaceId] = it.order
}
}.toMap()
setState {
copy(
rootSpacesOrdered = state.rootSpacesOrdered.toMutableList().apply {
val index = indexOfFirst { it.roomId == action.spaceId }
val moved = removeAt(index)
add(index + action.delta, moved)
},
spaceOrderLocalEchos = updatedLocalEchos
)
}
session.coroutineScope.launch {
orderCommands.forEach {
session.getRoom(it.spaceId)?.updateAccountData(RoomAccountDataTypes.EVENT_TYPE_SPACE_ORDER,
SpaceOrderContent(order = it.order).toContent()
)
}
}
// restore expanded state
setState {
copy(
expandedStates = preDragExpandedState.orEmpty()
)
}
}
private fun handleSelectSpace(action: SpaceListAction.SelectSpace) = withState { state -> private fun handleSelectSpace(action: SpaceListAction.SelectSpace) = withState { state ->
val groupingMethod = state.selectedGroupingMethod val groupingMethod = state.selectedGroupingMethod
if (groupingMethod is RoomGroupingMethod.ByLegacyGroup || groupingMethod.space()?.roomId != action.spaceSummary?.roomId) { if (groupingMethod is RoomGroupingMethod.ByLegacyGroup || groupingMethod.space()?.roomId != action.spaceSummary?.roomId) {
@ -224,24 +280,43 @@ class SpacesListViewModel @AssistedInject constructor(@Assisted initialState: Sp
excludeType = listOf(/**RoomType.MESSAGING,$*/ excludeType = listOf(/**RoomType.MESSAGING,$*/
null) null)
} }
Observable.combineLatest<User?, List<RoomSummary>, List<RoomSummary>>(
session val rxSession = session.rx()
.rx()
Observable.combineLatest<User?, List<RoomSummary>, List<RoomAccountDataEvent>, List<RoomSummary>>(
rxSession
.liveUser(session.myUserId) .liveUser(session.myUserId)
.map { .map {
it.getOrNull() it.getOrNull()
}, },
session rxSession
.rx()
.liveSpaceSummaries(spaceSummaryQueryParams), .liveSpaceSummaries(spaceSummaryQueryParams),
BiFunction { _, communityGroups -> session.accountDataService().getLiveRoomAccountDataEvents(setOf(RoomAccountDataTypes.EVENT_TYPE_SPACE_ORDER)).asObservable(),
{ _, communityGroups, _ ->
communityGroups communityGroups
} }
) )
.execute { async -> .execute { async ->
val rootSpaces = session.spaceService().getRootSpaceSummaries()
val orders = rootSpaces.map {
it.roomId to session.getRoom(it.roomId)
?.getAccountDataEvent(RoomAccountDataTypes.EVENT_TYPE_SPACE_ORDER)
?.content.toModel<SpaceOrderContent>()
?.safeOrder()
}.toMap()
copy( copy(
asyncSpaces = async, asyncSpaces = async,
rootSpaces = session.spaceService().getRootSpaceSummaries() rootSpacesOrdered = rootSpaces.sortedWith(TopLevelSpaceComparator(orders)),
spaceOrderInfo = orders
)
}
// clear local echos on update
session.accountDataService()
.getLiveRoomAccountDataEvents(setOf(RoomAccountDataTypes.EVENT_TYPE_SPACE_ORDER))
.asObservable().execute {
copy(
spaceOrderLocalEchos = emptyMap()
) )
} }
} }

View file

@ -20,7 +20,7 @@
<item> <item>
<shape> <shape>
<solid android:color="@android:color/transparent" /> <solid android:color="?android:colorBackground" />
</shape> </shape>
</item> </item>