mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2024-12-22 23:58:47 +01:00
Merge pull request #2190 from vector-im/feature/utd_pagination
Feature/utd pagination
This commit is contained in:
commit
2a2187196f
@ -2,7 +2,7 @@ Changes in Element 1.0.9 (2020-XX-XX)
|
||||
===================================================
|
||||
|
||||
Features ✨:
|
||||
-
|
||||
- Hide encrypted history (before user is invited). Can be shown if wanted in developer settings
|
||||
|
||||
Improvements 🙌:
|
||||
- Wording differentiation for direct rooms (#2176)
|
||||
|
@ -50,6 +50,12 @@ import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
|
||||
import im.vector.app.features.home.room.detail.timeline.item.TimelineReadMarkerItem_
|
||||
import im.vector.app.features.media.ImageContentRenderer
|
||||
import im.vector.app.features.media.VideoContentRenderer
|
||||
import im.vector.app.features.settings.VectorPreferences
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import org.matrix.android.sdk.api.session.events.model.EventType
|
||||
import org.matrix.android.sdk.api.session.events.model.toModel
|
||||
import org.matrix.android.sdk.api.session.room.model.Membership
|
||||
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
|
||||
import org.matrix.android.sdk.api.session.room.timeline.Timeline
|
||||
@ -59,11 +65,13 @@ import javax.inject.Inject
|
||||
private const val DEFAULT_PREFETCH_THRESHOLD = 30
|
||||
|
||||
class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter,
|
||||
private val vectorPreferences: VectorPreferences,
|
||||
private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder,
|
||||
private val contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder,
|
||||
private val timelineItemFactory: TimelineItemFactory,
|
||||
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
|
||||
private val mergedHeaderItemFactory: MergedHeaderItemFactory,
|
||||
private val session: Session,
|
||||
@TimelineEventControllerHandler
|
||||
private val backgroundHandler: Handler
|
||||
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor {
|
||||
@ -115,6 +123,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
||||
private val modelCache = arrayListOf<CacheItemData?>()
|
||||
private var currentSnapshot: List<TimelineEvent> = emptyList()
|
||||
private var inSubmitList: Boolean = false
|
||||
private var hasReachedInvite: Boolean = false
|
||||
private var hasUTD: Boolean = false
|
||||
private var unreadState: UnreadState = UnreadState.Unknown
|
||||
private var positionOfReadMarker: Int? = null
|
||||
private var eventIdToHighlight: String? = null
|
||||
@ -267,7 +277,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
||||
|
||||
val timelineModels = getModels()
|
||||
add(timelineModels)
|
||||
|
||||
if (hasReachedInvite && hasUTD) {
|
||||
return
|
||||
}
|
||||
// Avoid displaying two loaders if there is no elements between them
|
||||
val showBackwardsLoader = !showingForwardLoader || timelineModels.isNotEmpty()
|
||||
// We can hide the loader but still add the item to controller so it can trigger backwards pagination
|
||||
@ -327,6 +339,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
||||
}
|
||||
|
||||
private fun buildCacheItemsIfNeeded() = synchronized(modelCache) {
|
||||
hasUTD = false
|
||||
hasReachedInvite = false
|
||||
|
||||
if (modelCache.isEmpty()) {
|
||||
return
|
||||
}
|
||||
@ -342,13 +357,21 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
||||
private fun buildCacheItem(currentPosition: Int, items: List<TimelineEvent>): CacheItemData {
|
||||
val event = items[currentPosition]
|
||||
val nextEvent = items.nextOrNull(currentPosition)
|
||||
val date = event.root.localDateTime()
|
||||
val nextDate = nextEvent?.root?.localDateTime()
|
||||
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
|
||||
if (hasReachedInvite && hasUTD) {
|
||||
return CacheItemData(event.localId, event.root.eventId, null, null, null)
|
||||
}
|
||||
updateUTDStates(event, nextEvent)
|
||||
val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, callback).also {
|
||||
it.id(event.localId)
|
||||
it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event))
|
||||
}
|
||||
val addDaySeparator = if (hasReachedInvite && hasUTD) {
|
||||
true
|
||||
} else {
|
||||
val date = event.root.localDateTime()
|
||||
val nextDate = nextEvent?.root?.localDateTime()
|
||||
date.toLocalDate() != nextDate?.toLocalDate()
|
||||
}
|
||||
val mergedHeaderModel = mergedHeaderItemFactory.create(event,
|
||||
nextEvent = nextEvent,
|
||||
items = items,
|
||||
@ -372,6 +395,27 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUTDStates(event: TimelineEvent, nextEvent: TimelineEvent?) {
|
||||
if (vectorPreferences.labShowCompleteHistoryInEncryptedRoom()) {
|
||||
return
|
||||
}
|
||||
if (event.root.type == EventType.STATE_ROOM_MEMBER
|
||||
&& event.root.stateKey == session.myUserId) {
|
||||
val content = event.root.content.toModel<RoomMemberContent>()
|
||||
if (content?.membership == Membership.INVITE) {
|
||||
hasReachedInvite = true
|
||||
} else if (content?.membership == Membership.JOIN) {
|
||||
val prevContent = event.root.resolvedPrevContent().toModel<RoomMemberContent>()
|
||||
if (prevContent?.membership?.isActive() == false) {
|
||||
hasReachedInvite = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nextEvent?.root?.getClearType() == EventType.ENCRYPTED) {
|
||||
hasUTD = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if added
|
||||
*/
|
||||
|
@ -31,9 +31,6 @@ import im.vector.app.features.home.room.detail.timeline.item.MergedMembershipEve
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MergedMembershipEventsItem_
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem_
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MergedUTDItem
|
||||
import im.vector.app.features.home.room.detail.timeline.item.MergedUTDItem_
|
||||
import im.vector.app.features.settings.VectorPreferences
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import org.matrix.android.sdk.api.session.events.model.EventType
|
||||
import org.matrix.android.sdk.api.session.events.model.toModel
|
||||
@ -41,14 +38,12 @@ import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent
|
||||
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
|
||||
import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
|
||||
import org.matrix.android.sdk.internal.crypto.model.event.EncryptionEventContent
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
|
||||
private val avatarRenderer: AvatarRenderer,
|
||||
private val avatarSizeProvider: AvatarSizeProvider,
|
||||
private val roomSummaryHolder: RoomSummaryHolder,
|
||||
private val vectorPreferences: VectorPreferences) {
|
||||
private val roomSummaryHolder: RoomSummaryHolder) {
|
||||
|
||||
private val collapsedEventIds = linkedSetOf<Long>()
|
||||
private val mergeItemCollapseStates = HashMap<Long, Boolean>()
|
||||
@ -66,10 +61,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
|
||||
callback: TimelineEventController.Callback?,
|
||||
requestModelBuild: () -> Unit)
|
||||
: BasedMergedItem<*>? {
|
||||
return if (shouldMergedAsCannotDecryptGroup(event, nextEvent)) {
|
||||
Timber.v("## MERGE: Candidate for merge, top event ${event.eventId}")
|
||||
buildUTDMergedSummary(currentPosition, items, event, eventIdToHighlight, /*requestModelBuild,*/ callback)
|
||||
} else if (nextEvent?.root?.getClearType() == EventType.STATE_ROOM_CREATE
|
||||
return if (nextEvent?.root?.getClearType() == EventType.STATE_ROOM_CREATE
|
||||
&& event.isRoomConfiguration(nextEvent.root.getClearContent()?.toModel<RoomCreateContent>()?.creator)) {
|
||||
// It's the first item before room.create
|
||||
// Collapse all room configuration events
|
||||
@ -144,83 +136,6 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
|
||||
}
|
||||
}
|
||||
|
||||
// Event should be UTD
|
||||
// Next event should not
|
||||
private fun shouldMergedAsCannotDecryptGroup(event: TimelineEvent, nextEvent: TimelineEvent?): Boolean {
|
||||
if (!vectorPreferences.mergeUTDinTimeline()) return false
|
||||
// if event is not UTD return false
|
||||
if (!isEventUTD(event)) return false
|
||||
// At this point event cannot be decrypted
|
||||
// Let's check if older event is not UTD
|
||||
return nextEvent == null || !isEventUTD(event)
|
||||
}
|
||||
|
||||
private fun isEventUTD(event: TimelineEvent): Boolean {
|
||||
return event.root.getClearType() == EventType.ENCRYPTED && !event.root.isRedacted()
|
||||
}
|
||||
|
||||
private fun buildUTDMergedSummary(currentPosition: Int,
|
||||
items: List<TimelineEvent>,
|
||||
event: TimelineEvent,
|
||||
eventIdToHighlight: String?,
|
||||
// requestModelBuild: () -> Unit,
|
||||
callback: TimelineEventController.Callback?): MergedUTDItem_? {
|
||||
Timber.v("## MERGE: buildUTDMergedSummary from position $currentPosition")
|
||||
var prevEvent = items.prevOrNull(currentPosition)
|
||||
var tmpPos = currentPosition - 1
|
||||
val mergedEvents = ArrayList<TimelineEvent>().also { it.add(event) }
|
||||
|
||||
while (prevEvent != null && isEventUTD(prevEvent)) {
|
||||
mergedEvents.add(prevEvent)
|
||||
tmpPos--
|
||||
prevEvent = if (tmpPos >= 0) items[tmpPos] else null
|
||||
}
|
||||
|
||||
Timber.v("## MERGE: buildUTDMergedSummary merge group size ${mergedEvents.size}")
|
||||
if (mergedEvents.size < 3) return null
|
||||
|
||||
var highlighted = false
|
||||
val mergedData = ArrayList<BasedMergedItem.Data>(mergedEvents.size)
|
||||
mergedEvents.reversed()
|
||||
.forEach { mergedEvent ->
|
||||
if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) {
|
||||
highlighted = true
|
||||
}
|
||||
val senderAvatar = mergedEvent.senderInfo.avatarUrl
|
||||
val senderName = mergedEvent.senderInfo.disambiguatedDisplayName
|
||||
val data = BasedMergedItem.Data(
|
||||
userId = mergedEvent.root.senderId ?: "",
|
||||
avatarUrl = senderAvatar,
|
||||
memberName = senderName,
|
||||
localId = mergedEvent.localId,
|
||||
eventId = mergedEvent.root.eventId ?: "",
|
||||
isDirectRoom = isDirectRoom()
|
||||
)
|
||||
mergedData.add(data)
|
||||
}
|
||||
val mergedEventIds = mergedEvents.map { it.localId }
|
||||
|
||||
collapsedEventIds.addAll(mergedEventIds)
|
||||
|
||||
val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() }
|
||||
|
||||
val attributes = MergedUTDItem.Attributes(
|
||||
isCollapsed = true,
|
||||
mergeData = mergedData,
|
||||
avatarRenderer = avatarRenderer,
|
||||
onCollapsedStateChanged = {}
|
||||
)
|
||||
return MergedUTDItem_()
|
||||
.id(mergeId)
|
||||
.big(mergedEventIds.size > 5)
|
||||
.leftGuideline(avatarSizeProvider.leftGuideline)
|
||||
.highlighted(highlighted)
|
||||
.attributes(attributes)
|
||||
.also {
|
||||
it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents))
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRoomCreationMergedSummary(currentPosition: Int,
|
||||
items: List<TimelineEvent>,
|
||||
event: TimelineEvent,
|
||||
|
@ -1,127 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2020 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.app.features.home.room.detail.timeline.item
|
||||
|
||||
import android.util.TypedValue
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.RelativeLayout
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import im.vector.app.R
|
||||
import im.vector.app.features.home.AvatarRenderer
|
||||
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
|
||||
|
||||
@EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo)
|
||||
abstract class MergedUTDItem : BasedMergedItem<MergedUTDItem.Holder>() {
|
||||
|
||||
@EpoxyAttribute
|
||||
override lateinit var attributes: Attributes
|
||||
|
||||
@EpoxyAttribute
|
||||
var big: Boolean? = false
|
||||
|
||||
override fun getViewType() = STUB_ID
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
|
||||
holder.mergedTile.updateLayoutParams<RelativeLayout.LayoutParams> {
|
||||
this.marginEnd = leftGuideline
|
||||
if (big == true) {
|
||||
this.height = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_DIP,
|
||||
800f,
|
||||
holder.view.context.resources.displayMetrics
|
||||
).toInt()
|
||||
} else {
|
||||
this.height = LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
}
|
||||
}
|
||||
|
||||
// if (attributes.isCollapsed) {
|
||||
// // Take the oldest data
|
||||
// val data = distinctMergeData.lastOrNull()
|
||||
//
|
||||
// val summary = holder.expandView.resources.getString(R.string.room_created_summary_item,
|
||||
// data?.memberName ?: data?.userId ?: "")
|
||||
// holder.summaryView.text = summary
|
||||
// holder.summaryView.visibility = View.VISIBLE
|
||||
// holder.avatarView.visibility = View.VISIBLE
|
||||
// if (data != null) {
|
||||
// holder.avatarView.visibility = View.VISIBLE
|
||||
// attributes.avatarRenderer.render(data.toMatrixItem(), holder.avatarView)
|
||||
// } else {
|
||||
// holder.avatarView.visibility = View.GONE
|
||||
// }
|
||||
//
|
||||
// if (attributes.hasEncryptionEvent) {
|
||||
// holder.encryptionTile.isVisible = true
|
||||
// holder.encryptionTile.updateLayoutParams<RelativeLayout.LayoutParams> {
|
||||
// this.marginEnd = leftGuideline
|
||||
// }
|
||||
// if (attributes.isEncryptionAlgorithmSecure) {
|
||||
// holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_enabled)
|
||||
// holder.e2eTitleDescriptionView.text = holder.expandView.resources.getString(R.string.encryption_enabled_tile_description)
|
||||
// holder.e2eTitleDescriptionView.textAlignment = View.TEXT_ALIGNMENT_CENTER
|
||||
// holder.e2eTitleTextView.setCompoundDrawablesWithIntrinsicBounds(
|
||||
// ContextCompat.getDrawable(holder.view.context, R.drawable.ic_shield_black),
|
||||
// null, null, null
|
||||
// )
|
||||
// } else {
|
||||
// holder.e2eTitleTextView.text = holder.expandView.resources.getString(R.string.encryption_not_enabled)
|
||||
// holder.e2eTitleDescriptionView.text = holder.expandView.resources.getString(R.string.encryption_unknown_algorithm_tile_description)
|
||||
// holder.e2eTitleTextView.setCompoundDrawablesWithIntrinsicBounds(
|
||||
// ContextCompat.getDrawable(holder.view.context, R.drawable.ic_shield_warning),
|
||||
// null, null, null
|
||||
// )
|
||||
// }
|
||||
// } else {
|
||||
// holder.encryptionTile.isVisible = false
|
||||
// }
|
||||
// } else {
|
||||
// holder.avatarView.visibility = View.INVISIBLE
|
||||
// holder.summaryView.visibility = View.GONE
|
||||
// holder.encryptionTile.isGone = true
|
||||
// }
|
||||
// No read receipt for this item
|
||||
holder.readReceiptsView.isVisible = false
|
||||
}
|
||||
|
||||
class Holder : BasedMergedItem.Holder(STUB_ID) {
|
||||
// val summaryView by bind<TextView>(R.id.itemNoticeTextView)
|
||||
// val avatarView by bind<ImageView>(R.id.itemNoticeAvatarView)
|
||||
val mergedTile by bind<ViewGroup>(R.id.mergedUTDTile)
|
||||
//
|
||||
// val e2eTitleTextView by bind<TextView>(R.id.itemVerificationDoneTitleTextView)
|
||||
// val e2eTitleDescriptionView by bind<TextView>(R.id.itemVerificationDoneDetailTextView)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STUB_ID = R.id.messageContentMergedUTDStub
|
||||
}
|
||||
|
||||
data class Attributes(
|
||||
override val isCollapsed: Boolean,
|
||||
override val mergeData: List<Data>,
|
||||
override val avatarRenderer: AvatarRenderer,
|
||||
override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null,
|
||||
override val onCollapsedStateChanged: (Boolean) -> Unit
|
||||
) : BasedMergedItem.Attributes
|
||||
}
|
@ -153,7 +153,7 @@ class VectorPreferences @Inject constructor(private val context: Context) {
|
||||
private const val SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY = "SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY"
|
||||
|
||||
// SETTINGS_LABS_HIDE_TECHNICAL_E2E_ERRORS
|
||||
private const val SETTINGS_LABS_MERGE_E2E_ERRORS = "SETTINGS_LABS_MERGE_E2E_ERRORS"
|
||||
private const val SETTINGS_LABS_SHOW_COMPLETE_HISTORY_IN_ENCRYPTED_ROOM = "SETTINGS_LABS_SHOW_COMPLETE_HISTORY_IN_ENCRYPTED_ROOM"
|
||||
const val SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB = "SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB"
|
||||
|
||||
// analytics
|
||||
@ -285,8 +285,8 @@ class VectorPreferences @Inject constructor(private val context: Context) {
|
||||
return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY, true)
|
||||
}
|
||||
|
||||
fun mergeUTDinTimeline(): Boolean {
|
||||
return defaultPrefs.getBoolean(SETTINGS_LABS_MERGE_E2E_ERRORS, false)
|
||||
fun labShowCompleteHistoryInEncryptedRoom(): Boolean {
|
||||
return developerMode() && defaultPrefs.getBoolean(SETTINGS_LABS_SHOW_COMPLETE_HISTORY_IN_ENCRYPTED_ROOM, false)
|
||||
}
|
||||
|
||||
fun labAllowedExtendedLogging(): Boolean {
|
||||
|
@ -1710,6 +1710,7 @@
|
||||
<string name="send_suggestion_failed">The suggestion failed to be sent (%s)</string>
|
||||
|
||||
<string name="settings_labs_show_hidden_events_in_timeline">Show hidden events in timeline</string>
|
||||
<string name="settings_labs_show_complete_history_in_encrypted_room">"Show complete history in encrypted rooms"</string>
|
||||
|
||||
<string name="bottom_action_people_x">Direct Messages</string>
|
||||
|
||||
|
@ -16,6 +16,12 @@
|
||||
android:key="SETTINGS_LABS_SHOW_HIDDEN_EVENTS_PREFERENCE_KEY"
|
||||
android:title="@string/settings_labs_show_hidden_events_in_timeline" />
|
||||
|
||||
<im.vector.app.core.preference.VectorSwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:dependency="SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY"
|
||||
android:key="SETTINGS_LABS_SHOW_COMPLETE_HISTORY_IN_ENCRYPTED_ROOM"
|
||||
android:title="@string/settings_labs_show_complete_history_in_encrypted_room" />
|
||||
|
||||
<im.vector.app.core.preference.VectorSwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:dependency="SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY"
|
||||
|
@ -39,13 +39,6 @@
|
||||
android:key="SETTINGS_LABS_ENABLE_SWIPE_TO_REPLY"
|
||||
android:title="@string/labs_swipe_to_reply_in_timeline" />
|
||||
|
||||
|
||||
<im.vector.app.core.preference.VectorSwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:key="SETTINGS_LABS_MERGE_E2E_ERRORS"
|
||||
android:title="@string/labs_merge_e2e_in_timeline" />
|
||||
|
||||
|
||||
<im.vector.app.core.preference.VectorSwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:key="SETTINGS_LABS_UNREAD_NOTIFICATIONS_AS_TAB"
|
||||
|
Loading…
Reference in New Issue
Block a user