Add/Remove jitsi widget via option menu

This commit is contained in:
Valere 2020-08-13 16:34:28 +02:00
parent 42a24300a1
commit 3ce1e3e5d9
18 changed files with 286 additions and 55 deletions

View File

@ -42,6 +42,9 @@ import org.matrix.android.sdk.api.util.JsonDict
* }
* ]
* }
* "im.vector.riot.jitsi": {
* "preferredDomain": "https://jitsi.riot.im/"
* }
* }
* </pre>
*/
@ -57,7 +60,10 @@ data class WellKnown(
val integrations: JsonDict? = null,
@Json(name = "im.vector.riot.e2ee")
val e2eAdminSetting: E2EWellKnownConfig? = null
val e2eAdminSetting: E2EWellKnownConfig? = null,
@Json(name = "im.vector.riot.jitsi")
val jitsiServer: WellKnownPreferredConfig? = null
)
@ -66,3 +72,11 @@ data class E2EWellKnownConfig(
@Json(name = "default")
val e2eDefault: Boolean = true
)
@JsonClass(generateAdapter = true)
class WellKnownPreferredConfig {
@JvmField
@Json(name = "preferredDomain")
var preferredDomain: String? = null
}

View File

@ -38,7 +38,9 @@ data class HomeServerCapabilities(
* Option to allow homeserver admins to set the default E2EE behaviour back to disabled for DMs / private rooms
* (as it was before) for various environments where this is desired.
*/
val adminE2EByDefault: Boolean = true
val adminE2EByDefault: Boolean = true,
val preferredJitsiDomain: String? = null
) {
companion object {
const val MAX_UPLOAD_FILE_SIZE_UNKNOWN = -1L

View File

@ -31,6 +31,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
if (oldVersion <= 0) migrateTo1(realm)
if (oldVersion <= 1) migrateTo2(realm)
if (oldVersion <= 2) migrateTo3(realm)
}
private fun migrateTo1(realm: DynamicRealm) {
@ -52,4 +53,11 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
obj.setBoolean(HomeServerCapabilitiesEntityFields.ADMIN_E2_E_BY_DEFAULT, true)
}
}
private fun migrateTo3(realm: DynamicRealm) {
Timber.d("Step 1 -> 2")
realm.schema.get("HomeServerCapabilitiesEntity")
?.addField(HomeServerCapabilitiesEntityFields.PREFERRED_JITSI_DOMAIN, String::class.java)
}
}

View File

@ -47,7 +47,7 @@ internal class SessionRealmConfigurationFactory @Inject constructor(
context: Context) {
companion object {
const val SESSION_STORE_SCHEMA_VERSION = 2L
const val SESSION_STORE_SCHEMA_VERSION = 3L
}
// Keep legacy preferences name for compatibility reason

View File

@ -31,7 +31,8 @@ internal object HomeServerCapabilitiesMapper {
maxUploadFileSize = entity.maxUploadFileSize,
lastVersionIdentityServerSupported = entity.lastVersionIdentityServerSupported,
defaultIdentityServerUrl = entity.defaultIdentityServerUrl,
adminE2EByDefault = entity.adminE2EByDefault
adminE2EByDefault = entity.adminE2EByDefault,
preferredJitsiDomain = entity.preferredJitsiDomain
)
}
}

View File

@ -26,7 +26,8 @@ internal open class HomeServerCapabilitiesEntity(
var lastVersionIdentityServerSupported: Boolean = false,
var defaultIdentityServerUrl: String? = null,
var adminE2EByDefault: Boolean = true,
var lastUpdatedTimestamp: Long = 0L
var lastUpdatedTimestamp: Long = 0L,
var preferredJitsiDomain: String? = null
) : RealmObject() {
companion object

View File

@ -110,6 +110,7 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
if (getWellknownResult != null && getWellknownResult is WellknownResult.Prompt) {
homeServerCapabilitiesEntity.defaultIdentityServerUrl = getWellknownResult.identityServerUrl
homeServerCapabilitiesEntity.adminE2EByDefault = getWellknownResult.wellKnown.e2eAdminSetting?.e2eDefault ?: true
homeServerCapabilitiesEntity.preferredJitsiDomain = getWellknownResult.wellKnown.jitsiServer?.preferredDomain
// We are also checking for integration manager configurations
val config = configExtractor.extract(getWellknownResult.wellKnown)
if (config != null) {

View File

@ -24,10 +24,14 @@ import android.util.AttributeSet
import android.view.View
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.utils.tappableMatchingText
import im.vector.app.features.home.room.detail.RoomDetailViewState
import im.vector.app.features.themes.ThemeUtils
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.session.widgets.model.WidgetType
class ActiveConferenceView @JvmOverloads constructor(
context: Context,
@ -38,6 +42,7 @@ class ActiveConferenceView @JvmOverloads constructor(
interface Callback {
fun onTapJoinAudio(jitsiWidget: Widget)
fun onTapJoinVideo(jitsiWidget: Widget)
fun onDelete(jitsiWidget: Widget)
}
var callback: Callback? = null
@ -77,5 +82,33 @@ class ActiveConferenceView @JvmOverloads constructor(
text = styledText
movementMethod = LinkMovementMethod.getInstance()
}
findViewById<TextView>(R.id.deleteWidgetButton).setOnClickListener {
jitsiWidget?.let { callback?.onDelete(it) }
}
}
fun render(state: RoomDetailViewState) {
val summary = state.asyncRoomSummary()
if (summary?.membership == Membership.JOIN) {
// We only display banner for 'live' widgets
val activeConf = // for now only jitsi?
state.activeRoomWidgets()?.firstOrNull {
// for now only jitsi?
it.type == WidgetType.Jitsi
}
if (activeConf == null) {
isVisible = false
} else {
isVisible = true
jitsiWidget = activeConf
}
// if sent by me or if i can moderate?
findViewById<TextView>(R.id.deleteWidgetButton).isVisible = state.isAllowedToManageWidgets
} else {
isVisible = false
}
}
}

View File

@ -20,7 +20,9 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Parcelable
import android.view.View
import android.widget.FrameLayout
import androidx.core.view.isVisible
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.Success
@ -82,9 +84,14 @@ class VectorJitsiActivity : VectorBaseActivity(), JitsiMeetActivityInterface, Ji
when (viewState.widget) {
is Fail -> finish()
is Success -> {
// val widget = viewState.widget.invoke()
findViewById<View>(R.id.jitsi_progress_layout).isVisible = false
jitsiMeetView?.isVisible = true
configureJitsiView(viewState)
}
else -> {
jitsiMeetView?.isVisible = false
findViewById<View>(R.id.jitsi_progress_layout).isVisible = true
}
}
}

View File

@ -81,4 +81,6 @@ sealed class RoomDetailAction : VectorViewModelAction {
object SelectStickerAttachment : RoomDetailAction()
object OpenIntegrationManager: RoomDetailAction()
object ManageIntegrations: RoomDetailAction()
data class AddJitsiWidget(val video: Boolean): RoomDetailAction()
data class RemoveWidget(val widgetId: String): RoomDetailAction()
}

View File

@ -356,6 +356,10 @@ class RoomDetailFragment @Inject constructor(
is RoomDetailViewEvents.OpenIntegrationManager -> openIntegrationManager()
is RoomDetailViewEvents.OpenFile -> startOpenFileIntent(it)
RoomDetailViewEvents.OpenActiveWidgetBottomSheet -> onViewWidgetsClicked()
is RoomDetailViewEvents.ShowInfoOkDialog -> showDialogWithMessage(it.message)
is RoomDetailViewEvents.JoinJitsiConference -> joinJitsiRoom(it.widget, it.withVideo)
RoomDetailViewEvents.ShowWaitingView -> vectorBaseActivity.showWaitingView()
RoomDetailViewEvents.HideWaitingView -> vectorBaseActivity.hideWaitingView()
}.exhaustive
}
}
@ -372,15 +376,23 @@ class RoomDetailFragment @Inject constructor(
private fun setupConfBannerView() {
activeConferenceView.callback = object : ActiveConferenceView.Callback {
override fun onTapJoinAudio(jitsiWidget: Widget) {
navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to false))
joinJitsiRoom(jitsiWidget, false)
}
override fun onTapJoinVideo(jitsiWidget: Widget) {
navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to true))
joinJitsiRoom(jitsiWidget, true)
}
override fun onDelete(jitsiWidget: Widget) {
roomDetailViewModel.handle(RoomDetailAction.RemoveWidget(jitsiWidget.widgetId))
}
}
}
private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) {
navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo))
}
private fun openStickerPicker(event: RoomDetailViewEvents.OpenStickerPicker) {
navigator.openStickerPicker(this, roomDetailArgs.roomId, event.widget)
}
@ -598,22 +610,7 @@ class RoomDetailFragment @Inject constructor(
}
R.id.voice_call,
R.id.video_call -> {
val activeCall = sharedCallActionViewModel.activeCall.value
val isVideoCall = item.itemId == R.id.video_call
if (activeCall != null) {
// resume existing if same room, if not prompt to kill and then restart new call?
if (activeCall.roomId == roomDetailArgs.roomId) {
onTapToReturnToCall()
}
// else {
// TODO might not work well, and should prompt
// webRtcPeerConnectionManager.endCall()
// safeStartCall(it, isVideoCall)
// }
} else {
safeStartCall(isVideoCall)
}
true
handleCallRequest(item)
}
R.id.hangup_call -> {
roomDetailViewModel.handle(RoomDetailAction.EndCall)
@ -623,6 +620,66 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun handleCallRequest(item: MenuItem): Boolean = withState(roomDetailViewModel) { state ->
val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState true
val isVideoCall = item.itemId == R.id.video_call
return@withState when (roomSummary.joinedMembersCount) {
1 -> {
val pendingInvite = roomSummary.invitedMembersCount ?: 0 > 0
if (pendingInvite) {
// wait for other to join
showDialogWithMessage(getString(R.string.cannot_call_yourself_with_invite))
} else {
// You cannot place a call with yourself.
showDialogWithMessage(getString(R.string.cannot_call_yourself))
}
true
}
2 -> {
val activeCall = sharedCallActionViewModel.activeCall.value
if (activeCall != null) {
// resume existing if same room, if not prompt to kill and then restart new call?
if (activeCall.roomId == roomDetailArgs.roomId) {
onTapToReturnToCall()
}
// else {
// TODO might not work well, and should prompt
// webRtcPeerConnectionManager.endCall()
// safeStartCall(it, isVideoCall)
// }
} else {
safeStartCall(isVideoCall)
}
true
}
else -> {
// it's jitsi call
// can you add widgets??
if (!state.isAllowedToManageWidgets) {
// You do not have permission to start a conference call in this room
showDialogWithMessage(getString(R.string.no_permissions_to_start_conf_call))
} else {
if (state.activeRoomWidgets()?.filter { it.type == WidgetType.Jitsi }?.any() == true) {
// A conference is already in progress!
showDialogWithMessage(getString(R.string.conference_call_in_progress))
} else {
AlertDialog.Builder(requireContext())
.setTitle(if (isVideoCall) R.string.video_meeting else R.string.audio_meeting)
.setMessage(R.string.audio_video_meeting_description)
.setPositiveButton(getString(R.string.create)) { _, _ ->
// create the widget, then navigate to it..
roomDetailViewModel.handle(RoomDetailAction.AddJitsiWidget(isVideoCall))
}
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
}
true
}
}
}
private fun displayDisabledIntegrationDialog() {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.disabled_integration_dialog_title)
@ -915,21 +972,9 @@ class RoomDetailFragment @Inject constructor(
invalidateOptionsMenu()
val summary = state.asyncRoomSummary()
renderToolbar(summary, state.typingMessage)
activeConferenceView.render(state)
val inviter = state.asyncInviter()
if (summary?.membership == Membership.JOIN) {
// We only display banner for 'live' widgets
val activeConf = // for now only jitsi?
state.activeRoomWidgets()?.firstOrNull {
// for now only jitsi?
it.type == WidgetType.Jitsi
}
if (activeConf == null) {
activeConferenceView.isVisible = false
} else {
activeConferenceView.isVisible = true
activeConferenceView.jitsiWidget = activeConf
}
jumpToBottomView.count = summary.notificationCount
jumpToBottomView.drawBadge = summary.hasUnreadMessages
scrollOnHighlightedEventCallback.timeline = roomDetailViewModel.timeline
@ -1167,7 +1212,7 @@ class RoomDetailFragment @Inject constructor(
}
}
// TimelineEventController.Callback ************************************************************
// TimelineEventController.Callback ************************************************************
override fun onUrlClicked(url: String, title: String): Boolean {
permalinkHandler
@ -1639,7 +1684,18 @@ class RoomDetailFragment @Inject constructor(
Snackbar.make(requireView(), message, duration).show()
}
// VectorInviteView.Callback
private fun showDialogWithMessage(message: String) {
AlertDialog.Builder(requireContext())
.setMessage(message)
.setPositiveButton(getString(R.string.ok), null)
.show()
}
// private fun joinCurrentJitsiCall(withVideo: Boolean) {
//
// }
// VectorInviteView.Callback
override fun onAcceptInvite() {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)
@ -1651,7 +1707,7 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.RejectInvite)
}
// JumpToReadMarkerView.Callback
// JumpToReadMarkerView.Callback
override fun onJumpToReadMarkerClicked() = withState(roomDetailViewModel) {
jumpToReadMarkerView.isVisible = false
@ -1667,7 +1723,7 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.MarkAllAsRead)
}
// AttachmentTypeSelectorView.Callback
// AttachmentTypeSelectorView.Callback
override fun onTypeSelected(type: AttachmentTypeSelectorView.Type) {
if (checkPermissions(type.permissionsBit, this, PERMISSION_REQUEST_CODE_PICK_ATTACHMENT)) {
@ -1688,7 +1744,7 @@ class RoomDetailFragment @Inject constructor(
}.exhaustive
}
// AttachmentsHelper.Callback
// AttachmentsHelper.Callback
override fun onContentAttachmentsReady(attachments: List<ContentAttachmentData>) {
if (roomDetailViewModel.preventAttachmentPreview) {

View File

@ -35,9 +35,15 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
data class ActionFailure(val action: RoomDetailAction, val throwable: Throwable) : RoomDetailViewEvents()
data class ShowMessage(val message: String) : RoomDetailViewEvents()
data class ShowInfoOkDialog(val message: String) : RoomDetailViewEvents()
data class ShowE2EErrorMessage(val withHeldCode: WithHeldCode?) : RoomDetailViewEvents()
data class NavigateToEvent(val eventId: String) : RoomDetailViewEvents()
data class JoinJitsiConference(val widget: Widget, val withVideo: Boolean) : RoomDetailViewEvents()
object ShowWaitingView: RoomDetailViewEvents()
object HideWaitingView: RoomDetailViewEvents()
data class FileTooBigError(
val filename: String,

View File

@ -43,6 +43,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import im.vector.app.features.home.room.typing.TypingHelper
import im.vector.app.features.powerlevel.PowerLevelsObservableFactory
import im.vector.app.features.settings.VectorLocale
import im.vector.app.features.settings.VectorPreferences
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.MatrixPatterns
@ -91,8 +92,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.matrix.android.sdk.api.session.widgets.model.Widget
import org.matrix.android.sdk.api.session.widgets.model.WidgetType
import org.matrix.android.sdk.internal.util.awaitCallback
import timber.log.Timber
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@ -188,8 +193,12 @@ class RoomDetailViewModel @AssistedInject constructor(
PowerLevelsObservableFactory(room).createObservable()
.subscribe {
val canSendMessage = PowerLevelsHelper(it).isUserAllowedToSend(session.myUserId, false, EventType.MESSAGE)
val isAllowedToManageWidgets = session.widgetService().hasPermissionsToHandleWidgets(room.roomId)
setState {
copy(canSendMessage = canSendMessage)
copy(
canSendMessage = canSendMessage,
isAllowedToManageWidgets = isAllowedToManageWidgets
)
}
}
.disposeOnClear()
@ -269,7 +278,9 @@ class RoomDetailViewModel @AssistedInject constructor(
is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager()
is RoomDetailAction.StartCall -> handleStartCall(action)
is RoomDetailAction.EndCall -> handleEndCall()
is RoomDetailAction.ManageIntegrations -> handleManageIntegrations()
is RoomDetailAction.ManageIntegrations -> handleManageIntegrations()
is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action)
is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId)
}.exhaustive
}
@ -317,6 +328,89 @@ class RoomDetailViewModel @AssistedInject constructor(
}
}
private fun handleAddJitsiConference(action: RoomDetailAction.AddJitsiWidget) {
_viewEvents.post(RoomDetailViewEvents.ShowWaitingView)
viewModelScope.launch(Dispatchers.IO) {
// Build data for a jitsi widget
// Build data for a jitsi widget
val widgetId: String = WidgetType.Jitsi.preferred + "_" + session.myUserId + "_" + System.currentTimeMillis()
// Create a random enough jitsi conference id
// Note: the jitsi server automatically creates conference when the conference
// id does not exist yet
// Create a random enough jitsi conference id
// Note: the jitsi server automatically creates conference when the conference
// id does not exist yet
var widgetSessionId = UUID.randomUUID().toString()
if (widgetSessionId.length > 8) {
widgetSessionId = widgetSessionId.substring(0, 7)
}
val roomId: String = room.roomId
val confId = roomId.substring(1, roomId.indexOf(":") - 1) + widgetSessionId.toLowerCase(VectorLocale.applicationLocale)
//DO NOT COMMIT
val jitsiDomain = session.getHomeServerCapabilities().preferredJitsiDomain ?: stringProvider.getString(R.string.preferred_jitsi_domain)
// We use the default element wrapper for this widget
// https://github.com/vector-im/element-web/blob/develop/docs/jitsi-dev.md
val url = "https://app.element.io/jitsi.html?" +
"confId=$confId#conferenceDomain=\$domain&conferenceId=\$conferenceId&isAudioOnly=${action.video}" +
"&displayName=\$matrix_display_name&avatarUrl=\$matrix_avatar_url&userId=\$matrix_user_id"
val widgetEventContent = mapOf(
"url" to url,
"type" to WidgetType.Jitsi.legacy,
"data" to mapOf(
"conferenceId" to confId,
"domain" to jitsiDomain,
"isAudioOnly" to !action.video
),
"creatorUserId" to session.myUserId,
"id" to widgetId,
"name" to "jitsi"
)
try {
val widget = awaitCallback<Widget> {
session.widgetService().createRoomWidget(roomId, widgetId, widgetEventContent, it)
}
_viewEvents.post(RoomDetailViewEvents.JoinJitsiConference(widget, action.video))
} catch (failure: Throwable) {
_viewEvents.post(RoomDetailViewEvents.ShowMessage(stringProvider.getString(R.string.failed_to_add_widget)))
} finally {
_viewEvents.post(RoomDetailViewEvents.HideWaitingView)
}
}
}
private fun handleDeleteWidget(widgetId: String) {
_viewEvents.post(RoomDetailViewEvents.ShowWaitingView)
viewModelScope.launch(Dispatchers.IO) {
try {
awaitCallback<Unit> { session.widgetService().destroyRoomWidget(room.roomId, widgetId, it) }
// local echo
setState {
copy(
activeRoomWidgets = when (activeRoomWidgets) {
is Success -> {
Success(activeRoomWidgets.invoke().filter { it.widgetId != widgetId })
}
else -> activeRoomWidgets
}
)
}
} catch (failure: Throwable) {
_viewEvents.post(RoomDetailViewEvents.ShowMessage(stringProvider.getString(R.string.failed_to_remove_widget)))
} finally {
_viewEvents.post(RoomDetailViewEvents.HideWaitingView)
}
}
}
private fun startTrackingUnreadMessages() {
trackUnreadMessages.set(true)
setState { copy(canShowJumpToReadMarker = false) }
@ -430,7 +524,7 @@ class RoomDetailViewModel @AssistedInject constructor(
R.id.clear_all -> state.asyncRoomSummary()?.hasFailedSending == true
R.id.open_matrix_apps -> true
R.id.voice_call,
R.id.video_call -> state.asyncRoomSummary()?.canStartCall == true && webRtcPeerConnectionManager.currentCall == null
R.id.video_call -> true // always show for discoverability
R.id.hangup_call -> webRtcPeerConnectionManager.currentCall != null
else -> false
}

View File

@ -66,7 +66,8 @@ data class RoomDetailViewState(
val unreadState: UnreadState = UnreadState.Unknown,
val canShowJumpToReadMarker: Boolean = true,
val changeMembershipState: ChangeMembershipState = ChangeMembershipState.Unknown,
val canSendMessage: Boolean = true
val canSendMessage: Boolean = true,
val isAllowedToManageWidgets: Boolean = false
) : MvRxState {
constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)

View File

@ -15,16 +15,9 @@
android:gravity="center"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/call_connecting"
android:textColor="@android:color/white" />
<ProgressBar
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="@dimen/layout_vertical_margin"
android:indeterminate="true" />
</LinearLayout>

View File

@ -12,7 +12,7 @@
android:id="@+id/activeConferenceInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toStartOf="@id/returnToCallButton"
android:layout_toStartOf="@id/deleteWidgetButton"
android:background="?attr/selectableItemBackground"
android:drawableStart="@drawable/ic_call"
android:drawablePadding="10dp"
@ -27,7 +27,7 @@
tools:text="@string/ongoing_conference_call" />
<TextView
android:id="@+id/returnToCallButton"
android:id="@+id/deleteWidgetButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@+id/activeConferenceInfo"

View File

@ -18,6 +18,9 @@
<!-- Note: pusher_app_id cannot exceed 64 chars -->
<string name="pusher_app_id" translatable="false">im.vector.app.android</string>
<!-- preferred jitsi domain -->
<string name="preferred_jitsi_domain" translatable="false">jitsi.riot.im</string>
<string-array name="room_directory_servers" translatable="false">
<item>matrix.org</item>
</string-array>

View File

@ -89,8 +89,17 @@
<string name="missing_permissions_warning">"Due to missing permissions, some features may be missing…</string>
<string name="missing_permissions_error">"Due to missing permissions, this action is not possible.</string>
<string name="missing_permissions_to_start_conf_call">You need permission to invite to start a conference in this room</string>
<string name="no_permissions_to_start_conf_call">You do not have permission to start a conference call in this room</string>
<string name="conference_call_in_progress">A conference is already in progress!</string>
<string name="video_meeting">Start video meeting</string>
<string name="audio_meeting">Start audio meeting</string>
<string name="audio_video_meeting_description">Meetings use Jisti security and permission policies. All people currently in the room will see an invite to join while your meeting is happening.</string>
<string name="missing_permissions_title_to_start_conf_call">Cannot start call</string>
<string name="cannot_call_yourself">You cannot place a call with yourself</string>
<string name="cannot_call_yourself_with_invite">You cannot place a call with yourself, wait for participants to accept invitation</string>
<string name="device_information">Session information</string>
<string name="failed_to_add_widget">Failed to add widget</string>
<string name="failed_to_remove_widget">Failed to remove widget</string>
<string name="room_no_conference_call_in_encrypted_rooms">Conference calls are not supported in encrypted rooms</string>
<string name="call_anyway">Call Anyway</string>
<string name="send_anyway">Send Anyway</string>