Merge pull request #102 from vector-im/feature/completion

Add Slash command parser and handle room member invitation
This commit is contained in:
Benoit Marty 2019-04-09 16:33:20 +02:00 committed by GitHub
commit eaff5ac9f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 1499 additions and 37 deletions

View file

@ -26,6 +26,10 @@ class RxRoom(private val room: Room) {
return room.roomSummary.asObservable()
}
fun liveRoomMemberIds(): Observable<List<String>> {
return room.getRoomMemberIdsLive().asObservable()
}
}
fun Room.rx(): RxRoom {

View file

@ -17,16 +17,16 @@
package im.vector.matrix.android.api.session.room
import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.session.room.members.RoomMembersService
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.timeline.TimelineService
import im.vector.matrix.android.api.util.Cancelable
/**
* This interface defines methods to interact within a room.
*/
interface Room : TimelineService, SendService, ReadService {
interface Room : TimelineService, SendService, ReadService, RoomMembersService {
/**
* The roomId of this room
@ -39,10 +39,4 @@ interface Room : TimelineService, SendService, ReadService {
*/
val roomSummary: LiveData<RoomSummary>
/**
* This methods load all room members if it was done yet.
* @return a [Cancelable]
*/
fun loadRoomMembersIfNeeded(): Cancelable
}

View file

@ -0,0 +1,57 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * 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 im.vector.matrix.android.api.session.room.members
import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.util.Cancelable
/**
* This interface defines methods to retrieve room members of a room. It's implemented at the room level.
*/
interface RoomMembersService {
/**
* This methods load all room members if it was done yet.
* @return a [Cancelable]
*/
fun loadRoomMembersIfNeeded(): Cancelable
/**
* Return the roomMember with userId or null.
* @param userId the userId param to look for
*
* @return the roomMember with userId or null
*/
fun getRoomMember(userId: String): RoomMember?
/**
* Return all the roomMembers ids of the room
*
* @return a [LiveData] of roomMember list.
*/
fun getRoomMemberIdsLive(): LiveData<List<String>>
/**
* Invite a user in the room
*/
fun invite(userId: String, callback: MatrixCallback<Unit>)
}

View file

@ -20,35 +20,31 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.members.RoomMembersService
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.timeline.TimelineService
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.RealmLiveData
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
internal class DefaultRoom(
override val roomId: String,
private val loadRoomMembersTask: LoadRoomMembersTask,
private val monarchy: Monarchy,
private val timelineService: TimelineService,
private val sendService: SendService,
private val readService: ReadService,
private val taskExecutor: TaskExecutor
private val roomMembersService: RoomMembersService
) : Room,
TimelineService by timelineService,
SendService by sendService,
ReadService by readService {
ReadService by readService,
RoomMembersService by roomMembersService {
override val roomSummary: LiveData<RoomSummary> by lazy {
val liveRealmData = RealmLiveData<RoomSummaryEntity>(monarchy.realmConfiguration) { realm ->
@ -59,8 +55,4 @@ internal class DefaultRoom(
}
}
override fun loadRoomMembersIfNeeded(): Cancelable {
val params = LoadRoomMembersTask.Params(roomId, Membership.LEAVE)
return loadRoomMembersTask.configureWith(params).executeBy(taskExecutor)
}
}

View file

@ -21,6 +21,7 @@ import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse
import im.vector.matrix.android.internal.network.NetworkConstants
import im.vector.matrix.android.internal.session.room.invite.InviteBody
import im.vector.matrix.android.internal.session.room.members.RoomMembersResponse
import im.vector.matrix.android.internal.session.room.send.SendResponse
import im.vector.matrix.android.internal.session.room.timeline.EventContextResponse
@ -120,5 +121,14 @@ internal interface RoomAPI {
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/read_markers")
fun sendReadMarker(@Path("roomId") roomId: String, @Body markers: Map<String, String>): Call<Unit>
/**
* Invite a user to the given room.
* Ref: https://matrix.org/docs/spec/client_server/r0.4.0.html#post-matrix-client-r0-rooms-roomid-invite
*
* @param roomId the room id
* @param body a object that just contains a user id
*/
@POST(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/invite")
fun invite(@Path("roomId") roomId: String, @Body body: InviteBody): Call<Unit>
}

View file

@ -17,8 +17,9 @@
package im.vector.matrix.android.internal.session.room
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.session.room.invite.InviteTask
import im.vector.matrix.android.internal.session.room.members.DefaultRoomMembersService
import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor
import im.vector.matrix.android.internal.session.room.read.DefaultReadService
@ -32,8 +33,8 @@ import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFact
import im.vector.matrix.android.internal.task.TaskExecutor
internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask,
private val inviteTask: InviteTask,
private val monarchy: Monarchy,
private val credentials: Credentials,
private val paginationTask: PaginationTask,
private val contextOfEventTask: GetContextOfEventTask,
private val setReadMarkersTask: SetReadMarkersTask,
@ -45,15 +46,16 @@ internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask,
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor)
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask)
val sendService = DefaultSendService(roomId, eventFactory, monarchy)
val roomMembersService = DefaultRoomMembersService(roomId, monarchy, loadRoomMembersTask, inviteTask, taskExecutor)
val readService = DefaultReadService(roomId, monarchy, setReadMarkersTask, taskExecutor)
return DefaultRoom(
roomId,
loadRoomMembersTask,
monarchy,
timelineService,
sendService,
readService,
taskExecutor
roomMembersService
)
}

View file

@ -19,6 +19,8 @@ package im.vector.matrix.android.internal.session.room
import im.vector.matrix.android.internal.session.DefaultSession
import im.vector.matrix.android.internal.session.room.create.CreateRoomTask
import im.vector.matrix.android.internal.session.room.create.DefaultCreateRoomTask
import im.vector.matrix.android.internal.session.room.invite.DefaultInviteTask
import im.vector.matrix.android.internal.session.room.invite.InviteTask
import im.vector.matrix.android.internal.session.room.members.DefaultLoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask
@ -70,5 +72,9 @@ class RoomModule {
DefaultCreateRoomTask(get(), get()) as CreateRoomTask
}
scope(DefaultSession.SCOPE) {
DefaultInviteTask(get()) as InviteTask
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.matrix.android.internal.session.room.invite
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class InviteBody(
@Json(name = "user_id") val userId: String
)

View file

@ -0,0 +1,40 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.matrix.android.internal.session.room.invite
import arrow.core.Try
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.task.Task
internal interface InviteTask : Task<InviteTask.Params, Unit> {
data class Params(
val roomId: String,
val userId: String
)
}
internal class DefaultInviteTask(private val roomAPI: RoomAPI) : InviteTask {
override fun execute(params: InviteTask.Params): Try<Unit> {
return executeRequest {
val body = InviteBody(params.userId)
apiCall = roomAPI.invite(params.roomId, body)
}
}
}

View file

@ -0,0 +1,71 @@
/*
*
* * Copyright 2019 New Vector Ltd
* *
* * 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 im.vector.matrix.android.internal.session.room.members
import androidx.lifecycle.LiveData
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.members.RoomMembersService
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.session.room.invite.InviteTask
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.fetchCopied
internal class DefaultRoomMembersService(private val roomId: String,
private val monarchy: Monarchy,
private val loadRoomMembersTask: LoadRoomMembersTask,
private val inviteTask: InviteTask,
private val taskExecutor: TaskExecutor
) : RoomMembersService {
override fun loadRoomMembersIfNeeded(): Cancelable {
val params = LoadRoomMembersTask.Params(roomId, Membership.LEAVE)
return loadRoomMembersTask.configureWith(params).executeBy(taskExecutor)
}
override fun getRoomMember(userId: String): RoomMember? {
val eventEntity = monarchy.fetchCopied {
RoomMembers(it, roomId).queryRoomMemberEvent(userId).findFirst()
}
return eventEntity?.asDomain()?.content.toModel()
}
override fun getRoomMemberIdsLive(): LiveData<List<String>> {
return monarchy.findAllMappedWithChanges(
{
RoomMembers(it, roomId).queryRoomMembersEvent()
},
{
it.stateKey!!
}
)
}
override fun invite(userId: String, callback: MatrixCallback<Unit>) {
val params = InviteTask.Params(roomId, userId)
inviteTask.configureWith(params)
.dispatchTo(callback)
.executeBy(taskExecutor)
}
}

View file

@ -26,6 +26,7 @@ import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.Sort
internal class RoomMembers(private val realm: Realm,
@ -47,10 +48,21 @@ internal class RoomMembers(private val realm: Realm,
}
}
fun getLoaded(): Map<String, RoomMember> {
fun queryRoomMembersEvent(): RealmQuery<EventEntity> {
return EventEntity
.where(realm, roomId, EventType.STATE_ROOM_MEMBER)
.sort(EventEntityFields.STATE_INDEX)
.sort(EventEntityFields.STATE_INDEX, Sort.DESCENDING)
.distinct(EventEntityFields.STATE_KEY)
.isNotNull(EventEntityFields.CONTENT)
}
fun queryRoomMemberEvent(userId: String): RealmQuery<EventEntity> {
return queryRoomMembersEvent()
.equalTo(EventEntityFields.STATE_KEY, userId)
}
fun getLoaded(): Map<String, RoomMember> {
return queryRoomMembersEvent()
.findAll()
.map { it.asDomain() }
.associateBy { it.stateKey!! }

View file

@ -171,6 +171,8 @@ dependencies {
implementation "ru.noties.markwon:core:$markwon_version"
implementation "ru.noties.markwon:html:$markwon_version"
implementation 'com.otaliastudios:autocomplete:1.1.0'
// Butterknife
implementation 'com.jakewharton:butterknife:10.1.0'
kapt 'com.jakewharton:butterknife-compiler:10.1.0'

View file

@ -27,7 +27,7 @@ import kotlin.reflect.KProperty
* See [SampleKotlinModelWithHolder] for a usage example.
*/
abstract class VectorEpoxyHolder : EpoxyHolder() {
private lateinit var view: View
lateinit var view: View
override fun bindView(itemView: View) {
view = itemView

View file

@ -19,6 +19,9 @@ package im.vector.riotredesign.core.epoxy
import com.airbnb.epoxy.EpoxyModelWithHolder
import com.airbnb.epoxy.VisibilityState
/**
* EpoxyModelWithHolder which can listen to visibility state change
*/
abstract class VectorEpoxyModel<H : VectorEpoxyHolder> : EpoxyModelWithHolder<H>() {
private var onModelVisibilityStateChangedListener: OnVisibilityStateChangedListener? = null

View file

@ -0,0 +1,25 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.autocomplete
/**
* Simple generic listener interface
*/
interface AutocompleteClickListener<T> {
fun onItemClick(t: T)
}

View file

@ -0,0 +1,92 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.autocomplete
import android.content.Context
import android.database.DataSetObserver
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyRecyclerView
import com.otaliastudios.autocomplete.AutocompletePresenter
abstract class EpoxyAutocompletePresenter<T>(context: Context) : AutocompletePresenter<T>(context), AutocompleteClickListener<T> {
private var recyclerView: EpoxyRecyclerView? = null
private var clicks: AutocompletePresenter.ClickProvider<T>? = null
private var observer: Observer? = null
override fun registerClickProvider(provider: AutocompletePresenter.ClickProvider<T>) {
this.clicks = provider
}
override fun registerDataSetObserver(observer: DataSetObserver) {
this.observer = Observer(observer)
}
override fun getView(): ViewGroup? {
recyclerView = EpoxyRecyclerView(context).apply {
setController(providesController())
observer?.let {
adapter?.registerAdapterDataObserver(it)
}
itemAnimator = null
}
return recyclerView
}
override fun onViewShown() {}
override fun onViewHidden() {
recyclerView = null
observer = null
}
abstract fun providesController(): EpoxyController
protected fun dispatchLayoutChange() {
observer?.onChanged()
}
override fun onItemClick(t: T) {
clicks?.click(t)
}
private class Observer internal constructor(private val root: DataSetObserver) : RecyclerView.AdapterDataObserver() {
override fun onChanged() {
root.onChanged()
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
root.onChanged()
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
root.onChanged()
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
root.onChanged()
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
root.onChanged()
}
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.autocomplete.command
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.features.autocomplete.AutocompleteClickListener
import im.vector.riotredesign.features.command.Command
class AutocompleteCommandController(private val stringProvider: StringProvider) : TypedEpoxyController<List<Command>>() {
var listener: AutocompleteClickListener<Command>? = null
override fun buildModels(data: List<Command>?) {
if (data.isNullOrEmpty()) {
return
}
data.forEach { command ->
autocompleteCommandItem {
id(command.command)
name(command.command)
parameters(command.parameters)
description(stringProvider.getString(command.description))
clickListener { _ ->
listener?.onItemClick(command)
}
}
}
}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.autocomplete.command
import android.view.View
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
@EpoxyModelClass(layout = R.layout.item_autocomplete_command)
abstract class AutocompleteCommandItem : VectorEpoxyModel<AutocompleteCommandItem.Holder>() {
@EpoxyAttribute
var name: CharSequence? = null
@EpoxyAttribute
var parameters: CharSequence? = null
@EpoxyAttribute
var description: CharSequence? = null
@EpoxyAttribute
var clickListener: View.OnClickListener? = null
override fun bind(holder: Holder) {
holder.view.setOnClickListener(clickListener)
holder.nameView.text = name
holder.parametersView.text = parameters
holder.descriptionView.text = description
}
class Holder : VectorEpoxyHolder() {
val nameView by bind<TextView>(R.id.commandName)
val parametersView by bind<TextView>(R.id.commandParameter)
val descriptionView by bind<TextView>(R.id.commandDescription)
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.autocomplete.command
import android.content.Context
import com.airbnb.epoxy.EpoxyController
import im.vector.riotredesign.features.autocomplete.EpoxyAutocompletePresenter
import im.vector.riotredesign.features.command.Command
class AutocompleteCommandPresenter(context: Context,
private val controller: AutocompleteCommandController) :
EpoxyAutocompletePresenter<Command>(context) {
init {
controller.listener = this
}
override fun providesController(): EpoxyController {
return controller
}
override fun onQuery(query: CharSequence?) {
val data = Command.values().filter {
if (query.isNullOrEmpty()) {
true
} else {
it.command.startsWith(query, 1, true)
}
}
controller.setData(data)
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.autocomplete.command
import android.text.Spannable
import com.otaliastudios.autocomplete.AutocompletePolicy
class CommandAutocompletePolicy : AutocompletePolicy {
override fun getQuery(text: Spannable): CharSequence {
if (text.length > 0) {
return text.substring(1, text.length)
}
// Should not happen
return ""
}
override fun onDismiss(text: Spannable?) {
}
// Only if text which starts with '/' and without space
override fun shouldShowPopup(text: Spannable?, cursorPos: Int): Boolean {
return text?.startsWith("/") == true
&& !text.contains(" ")
}
override fun shouldDismissPopup(text: Spannable?, cursorPos: Int): Boolean {
return !shouldShowPopup(text, cursorPos)
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.autocomplete.user
import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.features.autocomplete.AutocompleteClickListener
class AutocompleteUserController : TypedEpoxyController<List<User>>() {
var listener: AutocompleteClickListener<User>? = null
override fun buildModels(data: List<User>?) {
if (data.isNullOrEmpty()) {
return
}
data.forEach { user ->
autocompleteUserItem {
id(user.userId)
name(user.displayName)
avatarUrl(user.avatarUrl)
clickListener { _ ->
listener?.onItemClick(user)
}
}
}
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.autocomplete.user
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.features.home.AvatarRenderer
@EpoxyModelClass(layout = R.layout.item_autocomplete_user)
abstract class AutocompleteUserItem : VectorEpoxyModel<AutocompleteUserItem.Holder>() {
@EpoxyAttribute
var name: String? = null
@EpoxyAttribute
var avatarUrl: String? = null
@EpoxyAttribute
var clickListener: View.OnClickListener? = null
override fun bind(holder: Holder) {
holder.view.setOnClickListener(clickListener)
holder.nameView.text = name
AvatarRenderer.render(avatarUrl, name, holder.avatarImageView)
}
class Holder : VectorEpoxyHolder() {
val nameView by bind<TextView>(R.id.userAutocompleteName)
val avatarImageView by bind<ImageView>(R.id.userAutocompleteAvatar)
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.autocomplete.user
import android.content.Context
import com.airbnb.epoxy.EpoxyController
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Success
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.features.autocomplete.EpoxyAutocompletePresenter
class AutocompleteUserPresenter(context: Context,
private val controller: AutocompleteUserController
) : EpoxyAutocompletePresenter<User>(context) {
var callback: Callback? = null
init {
controller.listener = this
}
override fun providesController(): EpoxyController {
return controller
}
override fun onQuery(query: CharSequence?) {
callback?.onQueryUsers(query)
}
fun render(users: Async<List<User>>) {
if (users is Success) {
controller.setData(users())
}
}
interface Callback {
fun onQueryUsers(query: CharSequence?)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.command
import androidx.annotation.StringRes
import im.vector.riotredesign.R
/**
* Defines the command line operations
* the user can write theses messages to perform some actions
* the list will be displayed in this order
*/
enum class Command(val command: String, val parameters: String, @StringRes val description: Int) {
EMOTE("/me", "<message>", R.string.command_description_emote),
BAN_USER("/ban", "<user-id> [reason]", R.string.command_description_ban_user),
UNBAN_USER("/unban", "<user-id>", R.string.command_description_unban_user),
SET_USER_POWER_LEVEL("/op", "<user-id> [<power-level>]", R.string.command_description_op_user),
RESET_USER_POWER_LEVEL("/deop", "<user-id>", R.string.command_description_deop_user),
INVITE("/invite", "<user-id>", R.string.command_description_invite_user),
JOIN_ROOM("/join", "<room-alias>", R.string.command_description_join_room),
PART("/part", "<room-alias>", R.string.command_description_part_room),
TOPIC("/topic", "<topic>", R.string.command_description_topic),
KICK_USER("/kick", "<user-id> [reason]", R.string.command_description_kick_user),
CHANGE_DISPLAY_NAME("/nick", "<display-name>", R.string.command_description_nick),
MARKDOWN("/markdown", "<on|off>", R.string.command_description_markdown),
CLEAR_SCALAR_TOKEN("/clear_scalar_token", "", R.string.command_description_clear_scalar_token);
}

View file

@ -0,0 +1,220 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.command
import im.vector.matrix.android.api.MatrixPatterns
import timber.log.Timber
object CommandParser {
/**
* Convert the text message into a Slash command.
*
* @param textMessage the text message
* @return a parsed slash command (ok or error)
*/
fun parseSplashCommand(textMessage: String): ParsedCommand {
// check if it has the Slash marker
if (!textMessage.startsWith("/")) {
return ParsedCommand.ErrorNotACommand
} else {
Timber.d("parseSplashCommand")
// "/" only
if (textMessage.length == 1) {
return ParsedCommand.ErrorEmptySlashCommand
}
// Exclude "//"
if ("/" == textMessage.substring(1, 2)) {
return ParsedCommand.ErrorNotACommand
}
var messageParts: List<String>? = null
try {
messageParts = textMessage.split("\\s+".toRegex()).dropLastWhile { it.isEmpty() }
} catch (e: Exception) {
Timber.e(e, "## manageSplashCommand() : split failed " + e.message)
}
// test if the string cut fails
if (messageParts.isNullOrEmpty()) {
return ParsedCommand.ErrorEmptySlashCommand
}
val slashCommand = messageParts[0]
when (slashCommand) {
Command.CHANGE_DISPLAY_NAME.command -> {
val newDisplayName = textMessage.substring(Command.CHANGE_DISPLAY_NAME.command.length).trim()
return if (newDisplayName.isNotEmpty()) {
ParsedCommand.ChangeDisplayName(newDisplayName)
} else {
ParsedCommand.ErrorSyntax(Command.CHANGE_DISPLAY_NAME)
}
}
Command.TOPIC.command -> {
val newTopic = textMessage.substring(Command.TOPIC.command.length).trim()
return if (newTopic.isNotEmpty()) {
ParsedCommand.ChangeTopic(newTopic)
} else {
ParsedCommand.ErrorSyntax(Command.TOPIC)
}
}
Command.EMOTE.command -> {
val message = textMessage.substring(Command.EMOTE.command.length).trim()
return ParsedCommand.SendEmote(message)
}
Command.JOIN_ROOM.command -> {
val roomAlias = textMessage.substring(Command.JOIN_ROOM.command.length).trim()
return if (roomAlias.isNotEmpty()) {
ParsedCommand.JoinRoom(roomAlias)
} else {
ParsedCommand.ErrorSyntax(Command.JOIN_ROOM)
}
}
Command.PART.command -> {
val roomAlias = textMessage.substring(Command.PART.command.length).trim()
return if (roomAlias.isNotEmpty()) {
ParsedCommand.PartRoom(roomAlias)
} else {
ParsedCommand.ErrorSyntax(Command.PART)
}
}
Command.INVITE.command -> {
return if (messageParts.size == 2) {
val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) {
ParsedCommand.Invite(userId)
} else {
ParsedCommand.ErrorSyntax(Command.INVITE)
}
} else {
ParsedCommand.ErrorSyntax(Command.INVITE)
}
}
Command.KICK_USER.command -> {
return if (messageParts.size >= 2) {
val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) {
val reason = textMessage.substring(Command.KICK_USER.command.length
+ 1
+ userId.length).trim()
ParsedCommand.KickUser(userId, reason)
} else {
ParsedCommand.ErrorSyntax(Command.KICK_USER)
}
} else {
ParsedCommand.ErrorSyntax(Command.KICK_USER)
}
}
Command.BAN_USER.command -> {
return if (messageParts.size >= 2) {
val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) {
val reason = textMessage.substring(Command.BAN_USER.command.length
+ 1
+ userId.length).trim()
ParsedCommand.BanUser(userId, reason)
} else {
ParsedCommand.ErrorSyntax(Command.BAN_USER)
}
} else {
ParsedCommand.ErrorSyntax(Command.BAN_USER)
}
}
Command.UNBAN_USER.command -> {
return if (messageParts.size == 2) {
val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) {
ParsedCommand.UnbanUser(userId)
} else {
ParsedCommand.ErrorSyntax(Command.UNBAN_USER)
}
} else {
ParsedCommand.ErrorSyntax(Command.UNBAN_USER)
}
}
Command.SET_USER_POWER_LEVEL.command -> {
return if (messageParts.size == 3) {
val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) {
val powerLevelsAsString = messageParts[2]
try {
val powerLevelsAsInt = Integer.parseInt(powerLevelsAsString)
ParsedCommand.SetUserPowerLevel(userId, powerLevelsAsInt)
} catch (e: Exception) {
ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL)
}
} else {
ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL)
}
} else {
ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL)
}
}
Command.RESET_USER_POWER_LEVEL.command -> {
return if (messageParts.size == 2) {
val userId = messageParts[1]
if (MatrixPatterns.isUserId(userId)) {
ParsedCommand.SetUserPowerLevel(userId, 0)
} else {
ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL)
}
} else {
ParsedCommand.ErrorSyntax(Command.SET_USER_POWER_LEVEL)
}
}
Command.MARKDOWN.command -> {
return if (messageParts.size == 2) {
when {
"on".equals(messageParts[1], true) -> ParsedCommand.SetMarkdown(true)
"off".equals(messageParts[1], true) -> ParsedCommand.SetMarkdown(false)
else -> ParsedCommand.ErrorSyntax(Command.MARKDOWN)
}
} else {
ParsedCommand.ErrorSyntax(Command.MARKDOWN)
}
}
Command.CLEAR_SCALAR_TOKEN.command -> {
return if (messageParts.size == 1) {
ParsedCommand.ClearScalarToken
} else {
ParsedCommand.ErrorSyntax(Command.CLEAR_SCALAR_TOKEN)
}
}
else -> {
// Unknown command
return ParsedCommand.ErrorUnknownSlashCommand(slashCommand)
}
}
}
}
}

View file

@ -0,0 +1,48 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.command
/**
* Represent a parsed command
*/
sealed class ParsedCommand {
// This is not a Slash command
object ErrorNotACommand : ParsedCommand()
object ErrorEmptySlashCommand : ParsedCommand()
// Unknown/Unsupported slash command
class ErrorUnknownSlashCommand(val slashCommand: String) : ParsedCommand()
// A slash command is detected, but there is an error
class ErrorSyntax(val command: Command) : ParsedCommand()
// Valid commands:
class SendEmote(val message: String) : ParsedCommand()
class BanUser(val userId: String, val reason: String) : ParsedCommand()
class UnbanUser(val userId: String) : ParsedCommand()
class SetUserPowerLevel(val userId: String, val powerLevel: Int) : ParsedCommand()
class Invite(val userId: String) : ParsedCommand()
class JoinRoom(val roomAlias: String) : ParsedCommand()
class PartRoom(val roomAlias: String) : ParsedCommand()
class ChangeTopic(val topic: String) : ParsedCommand()
class KickUser(val userId: String, val reason: String) : ParsedCommand()
class ChangeDisplayName(val displayName: String) : ParsedCommand()
class SetMarkdown(val enable: Boolean) : ParsedCommand()
object ClearScalarToken : ParsedCommand()
}

View file

@ -18,6 +18,10 @@ package im.vector.riotredesign.features.home
import androidx.fragment.app.Fragment
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandController
import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter
import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserController
import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserPresenter
import im.vector.riotredesign.features.home.group.GroupSummaryController
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.factory.*
@ -75,6 +79,15 @@ class HomeModule {
GroupSummaryController()
}
scope(ROOM_DETAIL_SCOPE) { (fragment: Fragment) ->
val commandController = AutocompleteCommandController(get())
AutocompleteCommandPresenter(fragment.requireContext(), commandController)
}
scope(ROOM_DETAIL_SCOPE) { (fragment: Fragment) ->
val userController = AutocompleteUserController()
AutocompleteUserPresenter(fragment.requireContext(), userController)
}
}
}

View file

@ -16,23 +16,43 @@
package im.vector.riotredesign.features.home.room.detail
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.os.Parcelable
import android.text.Editable
import android.text.Spannable
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.fragmentViewModel
import com.otaliastudios.autocomplete.Autocomplete
import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.core.platform.VectorBaseFragment
import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter
import im.vector.riotredesign.features.autocomplete.command.CommandAutocompletePolicy
import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserPresenter
import im.vector.riotredesign.features.command.Command
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.home.HomePermalinkHandler
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerActions
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewModel
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewState
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotredesign.features.html.PillImageSpan
import im.vector.riotredesign.features.media.MediaContentRenderer
import im.vector.riotredesign.features.media.MediaViewerActivity
import kotlinx.android.parcel.Parcelize
@ -50,7 +70,7 @@ data class RoomDetailArgs(
) : Parcelable
class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callback {
class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callback, AutocompleteUserPresenter.Callback {
companion object {
@ -61,8 +81,16 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
}
}
private val session by inject<Session>()
private val glideRequests by lazy {
GlideApp.with(this)
}
private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel()
private val textComposerViewModel: TextComposerViewModel by fragmentViewModel()
private val timelineEventController: TimelineEventController by inject { parametersOf(this) }
private val autocompleteCommandPresenter: AutocompleteCommandPresenter by inject { parametersOf(this) }
private val autocompleteUserPresenter: AutocompleteUserPresenter by inject { parametersOf(this) }
private val homePermalinkHandler: HomePermalinkHandler by inject()
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback
@ -74,8 +102,10 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
bindScope(getOrCreateScope(HomeModule.ROOM_DETAIL_SCOPE))
setupRecyclerView()
setupToolbar()
setupSendButton()
setupComposer()
roomDetailViewModel.subscribe { renderState(it) }
textComposerViewModel.subscribe { renderTextComposerState(it) }
roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) }
}
override fun onResume() {
@ -114,12 +144,73 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
timelineEventController.callback = this
}
private fun setupSendButton() {
private fun setupComposer() {
val elevation = 6f
val backgroundDrawable = ColorDrawable(Color.WHITE)
Autocomplete.on<Command>(composerEditText)
.with(CommandAutocompletePolicy())
.with(autocompleteCommandPresenter)
.with(elevation)
.with(backgroundDrawable)
.with(object : AutocompleteCallback<Command> {
override fun onPopupItemClicked(editable: Editable, item: Command): Boolean {
editable.clear()
editable
.append(item.command)
.append(" ")
return true
}
override fun onPopupVisibilityChanged(shown: Boolean) {
}
})
.build()
autocompleteUserPresenter.callback = this
Autocomplete.on<User>(composerEditText)
.with(CharPolicy('@', true))
.with(autocompleteUserPresenter)
.with(elevation)
.with(backgroundDrawable)
.with(object : AutocompleteCallback<User> {
override fun onPopupItemClicked(editable: Editable, item: User): Boolean {
// Detect last '@' and remove it
var startIndex = editable.lastIndexOf("@")
if (startIndex == -1) {
startIndex = 0
}
// Detect next word separator
var endIndex = editable.indexOf(" ", startIndex)
if (endIndex == -1) {
endIndex = editable.length
}
// Replace the word by its completion
val displayName = item.displayName ?: item.userId
// with a trailing space
editable.replace(startIndex, endIndex, "$displayName ")
// Add the span
val user = session.getUser(item.userId)
val span = PillImageSpan(glideRequests, context!!, item.userId, user)
span.bind(composerEditText)
editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
return true
}
override fun onPopupVisibilityChanged(shown: Boolean) {
}
})
.build()
sendButton.setOnClickListener {
val textMessage = composerEditText.text.toString()
if (textMessage.isNotBlank()) {
roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage))
composerEditText.text = null
}
}
}
@ -142,7 +233,44 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
}
}
// TimelineEventController.Callback ************************************************************
private fun renderTextComposerState(state: TextComposerViewState) {
autocompleteUserPresenter.render(state.asyncUsers)
}
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) {
is SendMessageResult.MessageSent,
is SendMessageResult.SlashCommandHandled -> {
// Clear composer
composerEditText.text = null
}
is SendMessageResult.SlashCommandError -> {
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
}
is SendMessageResult.SlashCommandUnknown -> {
displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
}
is SendMessageResult.SlashCommandResultOk -> {
// Ignore
}
is SendMessageResult.SlashCommandResultError -> {
displayCommandError(sendMessageResult.throwable.localizedMessage)
}
is SendMessageResult.SlashCommandNotImplemented -> {
displayCommandError(getString(R.string.not_implemented))
}
}
}
private fun displayCommandError(message: String) {
AlertDialog.Builder(activity!!)
.setTitle(R.string.command_error)
.setMessage(message)
.setPositiveButton(R.string.ok, null)
.show()
}
// TimelineEventController.Callback ************************************************************
override fun onUrlClicked(url: String) {
homePermalinkHandler.launch(url)
@ -157,4 +285,9 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
startActivity(intent)
}
// AutocompleteUserPresenter.Callback
override fun onQueryUsers(query: CharSequence?) {
textComposerViewModel.process(TextComposerActions.QueryUsers(query))
}
}

View file

@ -16,6 +16,8 @@
package im.vector.riotredesign.features.home.room.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
@ -24,6 +26,9 @@ import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.VectorViewModel
import im.vector.riotredesign.core.utils.LiveEvent
import im.vector.riotredesign.features.command.CommandParser
import im.vector.riotredesign.features.command.ParsedCommand
import im.vector.riotredesign.features.home.room.VisibleRoomStore
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import io.reactivex.rxkotlin.subscribeBy
@ -63,17 +68,100 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
fun process(action: RoomDetailActions) {
when (action) {
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action)
}
}
private val _sendMessageResultLiveData = MutableLiveData<LiveEvent<SendMessageResult>>()
val sendMessageResultLiveData: LiveData<LiveEvent<SendMessageResult>>
get() = _sendMessageResultLiveData
// PRIVATE METHODS *****************************************************************************
private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
room.sendTextMessage(action.text, callback = object : MatrixCallback<Event> {})
// Handle slash command
val slashCommandResult = CommandParser.parseSplashCommand(action.text)
when (slashCommandResult) {
is ParsedCommand.ErrorNotACommand -> {
// Send the text message to the room
room.sendTextMessage(action.text, callback = object : MatrixCallback<Event> {})
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
is ParsedCommand.ErrorSyntax -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command)))
}
is ParsedCommand.ErrorEmptySlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/")))
}
is ParsedCommand.ErrorUnknownSlashCommand -> {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand)))
}
is ParsedCommand.Invite -> {
handleInviteSlashCommand(slashCommandResult)
}
is ParsedCommand.SetUserPowerLevel -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.ClearScalarToken -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SetMarkdown -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.UnbanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.BanUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.KickUser -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.JoinRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.PartRoom -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.SendEmote -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.ChangeTopic -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
is ParsedCommand.ChangeDisplayName -> {
// TODO
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
}
}
}
private fun handleInviteSlashCommand(invite: ParsedCommand.Invite) {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
room.invite(invite.userId, object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandResultOk))
}
override fun onFailure(failure: Throwable) {
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandResultError(failure)))
}
})
}
private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) {

View file

@ -0,0 +1,30 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.home.room.detail
import im.vector.riotredesign.features.command.Command
sealed class SendMessageResult {
object MessageSent : SendMessageResult()
class SlashCommandError(val command: Command) : SendMessageResult()
class SlashCommandUnknown(val command: String) : SendMessageResult()
object SlashCommandHandled : SendMessageResult()
object SlashCommandResultOk : SendMessageResult()
class SlashCommandResultError(val throwable: Throwable) : SendMessageResult()
// TODO Remove
object SlashCommandNotImplemented : SendMessageResult()
}

View file

@ -0,0 +1,21 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.home.room.detail.composer
sealed class TextComposerActions {
data class QueryUsers(val query: CharSequence?) : TextComposerActions()
}

View file

@ -0,0 +1,94 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.home.room.detail.composer
import arrow.core.Option
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.user.model.User
import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.VectorViewModel
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import org.koin.android.ext.android.get
import java.util.concurrent.TimeUnit
typealias AutocompleteUserQuery = CharSequence
class TextComposerViewModel(initialState: TextComposerViewState,
private val session: Session
) : VectorViewModel<TextComposerViewState>(initialState) {
private val room = session.getRoom(initialState.roomId)!!
private val roomId = initialState.roomId
private val usersQueryObservable = BehaviorRelay.create<Option<AutocompleteUserQuery>>()
companion object : MvRxViewModelFactory<TextComposerViewModel, TextComposerViewState> {
@JvmStatic
override fun create(viewModelContext: ViewModelContext, state: TextComposerViewState): TextComposerViewModel? {
val currentSession = viewModelContext.activity.get<Session>()
return TextComposerViewModel(state, currentSession)
}
}
init {
observeUsersQuery()
}
fun process(action: TextComposerActions) {
when (action) {
is TextComposerActions.QueryUsers -> handleQueryUsers(action)
}
}
private fun handleQueryUsers(action: TextComposerActions.QueryUsers) {
val query = Option.fromNullable(action.query)
usersQueryObservable.accept(query)
}
private fun observeUsersQuery() {
Observable.combineLatest<List<String>, Option<AutocompleteUserQuery>, List<User>>(
room.rx().liveRoomMemberIds(),
usersQueryObservable.throttleLast(300, TimeUnit.MILLISECONDS),
BiFunction { roomMembers, query ->
val users = roomMembers
.mapNotNull {
session.getUser(it)
}
val filter = query.orNull()
if (filter.isNullOrBlank()) {
users
} else {
users.filter {
it.displayName?.startsWith(prefix = filter, ignoreCase = true)
?: false
}
}
}
).execute { async ->
copy(
asyncUsers = async
)
}
}
}

View file

@ -0,0 +1,32 @@
/*
* Copyright 2019 New Vector Ltd
*
* 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 im.vector.riotredesign.features.home.room.detail.composer
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.Uninitialized
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.features.home.room.detail.RoomDetailArgs
data class TextComposerViewState(val roomId: String,
val asyncUsers: Async<List<User>> = Uninitialized
) : MvRxState {
constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
}

View file

@ -36,7 +36,6 @@ import java.lang.ref.WeakReference
* This span is able to replace a text by a [ChipDrawable]
* It's needed to call [bind] method to start requesting avatar, otherwise only the placeholder icon will be displayed if not already cached.
*/
class PillImageSpan(private val glideRequests: GlideRequests,
private val context: Context,
private val userId: String,

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="6dp">
<TextView
android:id="@+id/commandName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:maxLines="1"
android:textSize="12sp"
android:textStyle="bold"
tools:text="/invite" />
<TextView
android:id="@+id/commandParameter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="5dp"
android:layout_marginLeft="5dp"
android:layout_toEndOf="@+id/commandName"
android:layout_toRightOf="@+id/commandName"
android:maxLines="1"
android:textSize="12sp"
android:textStyle="italic"
tools:text="&lt;user-id&gt;" />
<TextView
android:id="@+id/commandDescription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/commandName"
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:layout_gravity="center_vertical"
android:maxLines="1"
android:textColor="?android:attr/textColorSecondary"
android:textSize="12sp"
tools:text="@string/command_description_invite_user" />
</RelativeLayout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp">
<ImageView
android:id="@+id/userAutocompleteAvatar"
android:layout_width="28dp"
android:layout_height="28dp"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/userAutocompleteName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="12dp"
android:layout_marginLeft="12dp"
android:maxLines="1"
android:textSize="12sp"
android:textStyle="bold"
tools:text="name" />
</LinearLayout>