Introduces CallEventGrouper so we can manage properly call history

This commit is contained in:
ganfra 2021-07-29 12:06:38 +02:00
parent d2785b69df
commit f405532e4c
5 changed files with 156 additions and 76 deletions

View File

@ -41,6 +41,7 @@ import im.vector.app.features.home.room.detail.timeline.factory.MergedHeaderItem
import im.vector.app.features.home.room.detail.timeline.factory.ReadReceiptsItemFactory import im.vector.app.features.home.room.detail.timeline.factory.ReadReceiptsItemFactory
import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactory import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams
import im.vector.app.features.home.room.detail.timeline.helper.CallEventGrouper
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.TimelineControllerInterceptorHelper import im.vector.app.features.home.room.detail.timeline.helper.TimelineControllerInterceptorHelper
@ -48,7 +49,6 @@ import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventDiff
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem
import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem
import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem_ import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem_
@ -56,7 +56,6 @@ import im.vector.app.features.home.room.detail.timeline.item.ItemWithEvents
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem
import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.media.VideoContentRenderer import im.vector.app.features.media.VideoContentRenderer
@ -164,6 +163,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
// Map eventId to adapter position // Map eventId to adapter position
private val adapterPositionMapping = HashMap<String, Int>() private val adapterPositionMapping = HashMap<String, Int>()
private val callEventGroupers = HashMap<String, CallEventGrouper>()
private val receiptsByEvent = HashMap<String, MutableList<ReadReceipt>>()
private val modelCache = arrayListOf<CacheItemData?>() private val modelCache = arrayListOf<CacheItemData?>()
private var currentSnapshot: List<TimelineEvent> = emptyList() private var currentSnapshot: List<TimelineEvent> = emptyList()
private var inSubmitList: Boolean = false private var inSubmitList: Boolean = false
@ -353,8 +354,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
if (modelCache.isEmpty()) { if (modelCache.isEmpty()) {
return return
} }
val receiptsByEvents = getReadReceiptsByShownEvent() preprocessReverseEvents()
val lastSentEventWithoutReadReceipts = searchLastSentEventWithoutReadReceipts(receiptsByEvents) val lastSentEventWithoutReadReceipts = searchLastSentEventWithoutReadReceipts(receiptsByEvent)
(0 until modelCache.size).forEach { position -> (0 until modelCache.size).forEach { position ->
val event = currentSnapshot[position] val event = currentSnapshot[position]
val nextEvent = currentSnapshot.nextOrNull(position) val nextEvent = currentSnapshot.nextOrNull(position)
@ -362,22 +363,28 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val nextDisplayableEvent = currentSnapshot.subList(position + 1, currentSnapshot.size).firstOrNull { val nextDisplayableEvent = currentSnapshot.subList(position + 1, currentSnapshot.size).firstOrNull {
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId) timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId)
} }
val params = TimelineItemFactoryParams(
event = event,
prevEvent = prevEvent,
nextEvent = nextEvent,
nextDisplayableEvent = nextDisplayableEvent,
partialState = partialState,
lastSentEventIdWithoutReadReceipts = lastSentEventWithoutReadReceipts,
callback = callback
)
// Should be build if not cached or if model should be refreshed // Should be build if not cached or if model should be refreshed
if (modelCache[position] == null || modelCache[position]?.isCacheable == false) { if (modelCache[position] == null || modelCache[position]?.isCacheable == false) {
val callEventGrouper = if (EventType.isCallEvent(event.root.getClearType())) {
(event.root.getClearContent()?.get("call_id") as? String)?.let { callId -> callEventGroupers[callId] }
} else {
null
}
val params = TimelineItemFactoryParams(
event = event,
prevEvent = prevEvent,
nextEvent = nextEvent,
nextDisplayableEvent = nextDisplayableEvent,
partialState = partialState,
lastSentEventIdWithoutReadReceipts = lastSentEventWithoutReadReceipts,
callback = callback,
callEventGrouper = callEventGrouper
)
modelCache[position] = buildCacheItem(params) modelCache[position] = buildCacheItem(params)
} }
val itemCachedData = modelCache[position] ?: return@forEach val itemCachedData = modelCache[position] ?: return@forEach
// Then update with additional models if needed // Then update with additional models if needed
modelCache[position] = itemCachedData.enrichWithModels(event, nextEvent, position, receiptsByEvents) modelCache[position] = itemCachedData.enrichWithModels(event, nextEvent, position, receiptsByEvent)
} }
} }
@ -450,15 +457,18 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
return null return null
} }
private fun getReadReceiptsByShownEvent(): Map<String, List<ReadReceipt>> { private fun preprocessReverseEvents() {
val receiptsByEvent = HashMap<String, MutableList<ReadReceipt>>() receiptsByEvent.clear()
if (!userPreferencesProvider.shouldShowReadReceipts()) { callEventGroupers.clear()
return receiptsByEvent
}
var lastShownEventId: String? = null
val itr = currentSnapshot.listIterator(currentSnapshot.size) val itr = currentSnapshot.listIterator(currentSnapshot.size)
var lastShownEventId: String? = null
while (itr.hasPrevious()) { while (itr.hasPrevious()) {
val event = itr.previous() val event = itr.previous()
if (EventType.isCallEvent(event.root.getClearType())) {
(event.root.getClearContent()?.get("call_id") as? String)?.also { callId ->
callEventGroupers.getOrPut(callId) { CallEventGrouper(session.myUserId, callId) }.add(event)
}
}
val currentReadReceipts = ArrayList(event.readReceipts).filter { val currentReadReceipts = ArrayList(event.readReceipts).filter {
it.user.userId != session.myUserId it.user.userId != session.myUserId
} }
@ -471,7 +481,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val existingReceipts = receiptsByEvent.getOrPut(lastShownEventId) { ArrayList() } val existingReceipts = receiptsByEvent.getOrPut(lastShownEventId) { ArrayList() }
existingReceipts.addAll(currentReadReceipts) existingReceipts.addAll(currentReadReceipts)
} }
return receiptsByEvent
} }
private fun buildDaySeparatorItem(originServerTs: Long?): DaySeparatorItem { private fun buildDaySeparatorItem(originServerTs: Long?): DaySeparatorItem {

View File

@ -16,81 +16,79 @@
package im.vector.app.features.home.room.detail.timeline.factory package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.features.call.vectorCallService import im.vector.app.features.call.vectorCallService
import im.vector.app.features.call.webrtc.WebRtcCallManager
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder import im.vector.app.features.home.room.detail.timeline.helper.RoomSummariesHolder
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper
import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem
import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem_ import im.vector.app.features.home.room.detail.timeline.item.CallTileTimelineItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
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.EventType 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.call.CallAnswerContent
import org.matrix.android.sdk.api.session.room.model.call.CallHangupContent
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.session.room.model.call.CallRejectContent
import org.matrix.android.sdk.api.session.room.model.call.CallSignalingContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject import javax.inject.Inject
class CallItemFactory @Inject constructor( class CallItemFactory @Inject constructor(
private val session: Session, private val session: Session,
private val userPreferencesProvider: UserPreferencesProvider,
private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper,
private val messageColorProvider: MessageColorProvider, private val messageColorProvider: MessageColorProvider,
private val messageInformationDataFactory: MessageInformationDataFactory, private val messageInformationDataFactory: MessageInformationDataFactory,
private val messageItemAttributesFactory: MessageItemAttributesFactory, private val messageItemAttributesFactory: MessageItemAttributesFactory,
private val avatarSizeProvider: AvatarSizeProvider, private val avatarSizeProvider: AvatarSizeProvider,
private val roomSummariesHolder: RoomSummariesHolder, private val roomSummariesHolder: RoomSummariesHolder) {
private val callManager: WebRtcCallManager
) {
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? { fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*>? {
val event = params.event val event = params.event
if (event.root.eventId == null) return null if (event.root.eventId == null) return null
val showHiddenEvents = userPreferencesProvider.shouldShowHiddenEvents()
val callEventGrouper = params.callEventGrouper ?: return null
val roomId = event.roomId val roomId = event.roomId
val informationData = messageInformationDataFactory.create(params) val informationData = messageInformationDataFactory.create(params)
val callSignalingContent = event.getCallSignalingContent() ?: return null val callKind = if (callEventGrouper.isVideo()) CallTileTimelineItem.CallKind.VIDEO else CallTileTimelineItem.CallKind.AUDIO
val callId = callSignalingContent.callId ?: return null val isRinging = callEventGrouper.isRinging()
val call = callManager.getCallById(callId)
val callKind = when {
call == null -> CallTileTimelineItem.CallKind.UNKNOWN
call.mxCall.isVideoCall -> CallTileTimelineItem.CallKind.VIDEO
else -> CallTileTimelineItem.CallKind.AUDIO
}
return when (event.root.getClearType()) { return when (event.root.getClearType()) {
EventType.CALL_ANSWER -> { EventType.CALL_ANSWER -> {
createCallTileTimelineItem( if (isRinging || showHiddenEvents) {
roomId = roomId, createCallTileTimelineItem(
callId = callId, roomId = roomId,
callStatus = CallTileTimelineItem.CallStatus.IN_CALL, callId = callEventGrouper.callId,
callKind = callKind, callStatus = CallTileTimelineItem.CallStatus.IN_CALL,
callback = params.callback, callKind = callKind,
highlight = params.isHighlighted, callback = params.callback,
informationData = informationData, highlight = params.isHighlighted,
isStillActive = call != null informationData = informationData,
) isStillActive = isRinging
)
} else {
null
}
} }
EventType.CALL_INVITE -> { EventType.CALL_INVITE -> {
createCallTileTimelineItem( if (isRinging || showHiddenEvents) {
roomId = roomId, createCallTileTimelineItem(
callId = callId, roomId = roomId,
callStatus = CallTileTimelineItem.CallStatus.INVITED, callId = callEventGrouper.callId,
callKind = callKind, callStatus = CallTileTimelineItem.CallStatus.INVITED,
callback = params.callback, callKind = callKind,
highlight = params.isHighlighted, callback = params.callback,
informationData = informationData, highlight = params.isHighlighted,
isStillActive = call != null informationData = informationData,
) isStillActive = isRinging
)
} else {
null
}
} }
EventType.CALL_REJECT -> { EventType.CALL_REJECT -> {
createCallTileTimelineItem( createCallTileTimelineItem(
roomId = roomId, roomId = roomId,
callId = callId, callId = callEventGrouper.callId,
callStatus = CallTileTimelineItem.CallStatus.REJECTED, callStatus = CallTileTimelineItem.CallStatus.REJECTED,
callKind = callKind, callKind = callKind,
callback = params.callback, callback = params.callback,
@ -102,8 +100,8 @@ class CallItemFactory @Inject constructor(
EventType.CALL_HANGUP -> { EventType.CALL_HANGUP -> {
createCallTileTimelineItem( createCallTileTimelineItem(
roomId = roomId, roomId = roomId,
callId = callId, callId = callEventGrouper.callId,
callStatus = CallTileTimelineItem.CallStatus.ENDED, callStatus = if (callEventGrouper.callWasMissed()) CallTileTimelineItem.CallStatus.MISSED else CallTileTimelineItem.CallStatus.ENDED,
callKind = callKind, callKind = callKind,
callback = params.callback, callback = params.callback,
highlight = params.isHighlighted, highlight = params.isHighlighted,
@ -115,16 +113,6 @@ class CallItemFactory @Inject constructor(
} }
} }
private fun TimelineEvent.getCallSignalingContent(): CallSignalingContent? {
return when (root.getClearType()) {
EventType.CALL_INVITE -> root.getClearContent().toModel<CallInviteContent>()
EventType.CALL_HANGUP -> root.getClearContent().toModel<CallHangupContent>()
EventType.CALL_REJECT -> root.getClearContent().toModel<CallRejectContent>()
EventType.CALL_ANSWER -> root.getClearContent().toModel<CallAnswerContent>()
else -> null
}
}
private fun createCallTileTimelineItem( private fun createCallTileTimelineItem(
roomId: String, roomId: String,
callId: String, callId: String,

View File

@ -17,6 +17,7 @@
package im.vector.app.features.home.room.detail.timeline.factory package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.CallEventGrouper
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
data class TimelineItemFactoryParams( data class TimelineItemFactoryParams(
@ -26,7 +27,8 @@ data class TimelineItemFactoryParams(
val nextDisplayableEvent: TimelineEvent? = null, val nextDisplayableEvent: TimelineEvent? = null,
val partialState: TimelineEventController.PartialState = TimelineEventController.PartialState(), val partialState: TimelineEventController.PartialState = TimelineEventController.PartialState(),
val lastSentEventIdWithoutReadReceipts: String? = null, val lastSentEventIdWithoutReadReceipts: String? = null,
val callback: TimelineEventController.Callback? = null val callback: TimelineEventController.Callback? = null,
val callEventGrouper: CallEventGrouper?= null
) { ) {
val highlightedEventId: String? val highlightedEventId: String?

View File

@ -0,0 +1,64 @@
/*
* Copyright (c) 2021 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.helper
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
import org.matrix.android.sdk.api.session.room.model.call.CallInviteContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
class CallEventGrouper(private val myUserId: String, val callId: String) {
private val events = HashSet<TimelineEvent>()
fun add(timelineEvent: TimelineEvent) {
events.add(timelineEvent)
}
fun isVideo(): Boolean {
val invite = getInvite() ?: return false
return invite.root.getClearContent().toModel<CallInviteContent>()?.isVideo().orFalse()
}
fun isRinging(): Boolean {
return getAnswer() == null && getHangup() == null && getReject() == null
}
/**
* Returns true if there are only events from the other side - we missed the call
*/
fun callWasMissed(): Boolean {
return events.none { it.senderInfo.userId == myUserId }
}
private fun getAnswer(): TimelineEvent? {
return events.firstOrNull { it.root.getClearType() == EventType.CALL_ANSWER }
}
private fun getInvite(): TimelineEvent? {
return events.firstOrNull { it.root.getClearType() == EventType.CALL_INVITE }
}
private fun getHangup(): TimelineEvent? {
return events.firstOrNull { it.root.getClearType() == EventType.CALL_HANGUP }
}
private fun getReject(): TimelineEvent? {
return events.firstOrNull { it.root.getClearType() == EventType.CALL_REJECT }
}
}

View File

@ -15,6 +15,7 @@
*/ */
package im.vector.app.features.home.room.detail.timeline.item package im.vector.app.features.home.room.detail.timeline.item
import android.content.res.Resources
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Button import android.widget.Button
@ -72,10 +73,22 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
CallStatus.IN_CALL -> renderInCallStatus(holder) CallStatus.IN_CALL -> renderInCallStatus(holder)
CallStatus.REJECTED -> renderRejectedStatus(holder) CallStatus.REJECTED -> renderRejectedStatus(holder)
CallStatus.ENDED -> renderEndedStatus(holder) CallStatus.ENDED -> renderEndedStatus(holder)
CallStatus.MISSED -> renderMissedStatus(holder)
} }
renderSendState(holder.view, null, holder.failedToSendIndicator) renderSendState(holder.view, null, holder.failedToSendIndicator)
} }
private fun renderMissedStatus(holder: Holder) {
holder.acceptRejectViewGroup.isVisible = false
holder.statusView.isVisible = true
val status = if (attributes.callKind == CallKind.VIDEO) {
holder.resources.getQuantityString(R.plurals.missed_video_call, 1)
} else {
holder.resources.getQuantityString(R.plurals.missed_audio_call, 1)
}
holder.statusView.text = status
}
private fun renderEndedStatus(holder: Holder) { private fun renderEndedStatus(holder: Holder) {
holder.acceptRejectViewGroup.isVisible = false holder.acceptRejectViewGroup.isVisible = false
holder.statusView.isVisible = true holder.statusView.isVisible = true
@ -91,7 +104,7 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
attributes.callback?.onTimelineItemAction(callbackAction) attributes.callback?.onTimelineItemAction(callbackAction)
} }
} else { } else {
holder.statusView.text = holder.view.context.getString(R.string.call_tile_other_declined, attributes.userOfInterest.getBestName()) holder.statusView.text = holder.resources.getString(R.string.call_tile_other_declined, attributes.userOfInterest.getBestName())
} }
} }
@ -166,7 +179,7 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
if (attributes.informationData.sentByMe) { if (attributes.informationData.sentByMe) {
holder.statusView.setText(R.string.call_tile_you_started_call) holder.statusView.setText(R.string.call_tile_you_started_call)
} else { } else {
holder.statusView.text = holder.view.context.getString(R.string.call_tile_other_started_call, attributes.userOfInterest.getBestName()) holder.statusView.text = holder.resources.getString(R.string.call_tile_other_started_call, attributes.userOfInterest.getBestName())
} }
} }
} }
@ -182,6 +195,9 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
val statusView by bind<TextView>(R.id.itemCallStatusTextView) val statusView by bind<TextView>(R.id.itemCallStatusTextView)
val endGuideline by bind<View>(R.id.messageEndGuideline) val endGuideline by bind<View>(R.id.messageEndGuideline)
val failedToSendIndicator by bind<ImageView>(R.id.messageFailToSendIndicator) val failedToSendIndicator by bind<ImageView>(R.id.messageFailToSendIndicator)
val resources: Resources
get() = view.context.resources
} }
companion object { companion object {
@ -215,6 +231,7 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem<CallTileTimelineItem.Ho
INVITED, INVITED,
IN_CALL, IN_CALL,
REJECTED, REJECTED,
MISSED,
ENDED; ENDED;
fun isActive() = this == INVITED || this == IN_CALL fun isActive() = this == INVITED || this == IN_CALL