Merge branch 'develop' into feature/fga/room_list_filter_iteration

This commit is contained in:
ganfra 2024-03-12 15:40:38 +01:00
commit 23b276dfcb
368 changed files with 6450 additions and 2317 deletions

34
.github/workflows/clear-cache.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Clear Gradle Cache
on:
workflow_dispatch:
schedule:
# Every nights at 4
- cron: "0 4 * * *"
# Enrich gradle.properties for CI/CD
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx8g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --warn
jobs:
tests:
name: Clear Gradle cache
runs-on: ubuntu-latest
steps:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
- name: ☕️ Use JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/actions/setup-gradle@v3
with:
gradle-home-cache-cleanup: true
# This should build the project and run the tests, and the build files will be used to diff with the cache
- name: ⚙️ Build the GPlay debug variant, run unit tests
run: ./gradlew :app:assembleGplayDebug test $CI_GRADLE_ARG_PROPERTIES

View file

@ -0,0 +1,38 @@
name: Generate GitHub Pages
on:
workflow_dispatch:
schedule:
# At 00:00 on every Tuesday UTC
- cron: '0 0 * * 2'
jobs:
generate-github-pages:
runs-on: ubuntu-latest
# Skip in forks
if: github.repository == 'element-hq/element-x-android'
steps:
- name: ⏬ Checkout with LFS
uses: nschloe/action-cached-lfs-checkout@v1.2.2
- name: Use JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/actions/setup-gradle@v3
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.9
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Run World screenshots generation script
run: |
./tools/test/generateWorldScreenshots.py
mkdir -p screenshots/en
cp tests/uitests/src/test/snapshots/images/* screenshots/en
- name: Deploy GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./screenshots

View file

@ -21,6 +21,18 @@ jobs:
- name: Run code quality check suite
run: ./tools/check/check_code_quality.sh
checkScreesnhot:
name: Search for invalid screenshot files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.9
uses: actions/setup-python@v5
with:
python-version: 3.9
- name: Search for invalid screenshot files
run: ./tools/test/checkInvalidScreenshots.py
check:
name: Project Check Suite
runs-on: ubuntu-latest

View file

@ -13,7 +13,7 @@ jobs:
# No concurrency required, runs every time on a schedule.
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v5
with:
python-version: 3.9

3
.gitignore vendored
View file

@ -20,6 +20,9 @@ out/
.gradle/
build/
# Python cache
__pycache__/
# Local configuration file (sdk path, etc)
local.properties

View file

@ -60,7 +60,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.3.11")
detektPlugins("io.nlopez.compose.rules:detekt:0.3.12")
}
// KtLint

View file

@ -0,0 +1 @@
Add `local_time`, `utc_time` and `sdk_sha` params to bug reports so they're easier to investigate.

1
changelog.d/2198.bugfix Normal file
View file

@ -0,0 +1 @@
Hide blocked users list when there are no blocked users.

1
changelog.d/2257.feature Normal file
View file

@ -0,0 +1 @@
Admins can now change user roles in rooms.

1
changelog.d/2258.feature Normal file
View file

@ -0,0 +1 @@
Room member moderation: kick, ban and unban users from a room.

1
changelog.d/2322.misc Normal file
View file

@ -0,0 +1 @@
Improve room member list loading times, increase chunk size

1
changelog.d/2511.misc Normal file
View file

@ -0,0 +1 @@
Remove the special log level for the Rust SDK read receipts.

1
changelog.d/995.bugfix Normal file
View file

@ -0,0 +1 @@
Prevent sending empty messages.

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="call_foreground_service_channel_title_android">"進行中的通話"</string>
<string name="call_foreground_service_message_android">"點擊以返回到通話頁面"</string>
<string name="call_foreground_service_title_android">"☎️ 通話中"</string>
</resources>

View file

@ -19,7 +19,6 @@ package io.element.android.features.createroom.impl.addpeople
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.userlist.SelectionMode
import io.element.android.features.createroom.impl.userlist.UserListState
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.features.createroom.impl.userlist.aUserListState
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
@ -32,7 +31,7 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
aUserListState(),
aUserListState().copy(
searchResults = SearchBarResultState.Results(aMatrixUserList().toImmutableList()),
selectedUsers = aListOfSelectedUsers(),
selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = false,
selectionMode = SelectionMode.Multiple,
),
@ -44,7 +43,7 @@ open class AddPeopleUserListStateProvider : PreviewParameterProvider<UserListSta
}
.toImmutableList()
),
selectedUsers = aListOfSelectedUsers(),
selectedUsers = aMatrixUserList().toImmutableList(),
isSearchActive = true,
selectionMode = SelectionMode.Multiple,
)

View file

@ -40,7 +40,7 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.collections.immutable.ImmutableList
@ -92,7 +92,7 @@ fun SearchUserBar(
animationSpec = spring(stiffness = Spring.StiffnessMediumLow)
)
SelectedUsersList(
SelectedUsersRowList(
contentPadding = PaddingValues(16.dp),
selectedUsers = selectedUsers,
autoScroll = true,
@ -114,7 +114,7 @@ fun SearchUserBar(
SearchMultipleUsersResultItem(
modifier = Modifier.fillMaxWidth(),
searchResult = searchResult,
isUserSelected = selectedUsers.find { it.userId == searchResult.matrixUser.userId } != null,
isUserSelected = selectedUsers.contains(searchResult.matrixUser),
onCheckedChange = { checked ->
if (checked) {
onUserSelected(searchResult.matrixUser)

View file

@ -29,7 +29,7 @@ import io.element.android.features.createroom.impl.userlist.UserListStateProvide
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
@Composable
fun UserListView(
@ -64,7 +64,7 @@ fun UserListView(
)
if (state.isMultiSelectionEnabled && !state.isSearchActive && state.selectedUsers.isNotEmpty()) {
SelectedUsersList(
SelectedUsersRowList(
contentPadding = PaddingValues(16.dp),
selectedUsers = state.selectedUsers,
autoScroll = true,

View file

@ -18,10 +18,11 @@ package io.element.android.features.createroom.impl.configureroom
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.userlist.aListOfSelectedUsers
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.permissions.api.aPermissionsState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomState> {
override val values: Sequence<ConfigureRoomState>
@ -31,7 +32,7 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider<ConfigureRoomSt
config = CreateRoomConfig(
roomName = "Room 101",
topic = "Room topic for this room when the text goes onto multiple lines and is really long, there shouldnt be more than 3 lines",
invites = aListOfSelectedUsers(),
invites = aMatrixUserList().toImmutableList(),
privacy = RoomPrivacy.Public,
),
),

View file

@ -59,7 +59,7 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.components.UnsavedAvatar
import io.element.android.libraries.permissions.api.PermissionsView
import io.element.android.libraries.ui.strings.CommonStrings
@ -120,7 +120,7 @@ fun ConfigureRoomView(
onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) },
)
if (state.config.invites.isNotEmpty()) {
SelectedUsersList(
SelectedUsersRowList(
modifier = Modifier.padding(bottom = 16.dp),
contentPadding = PaddingValues(horizontal = 24.dp),
selectedUsers = state.config.invites,

View file

@ -25,7 +25,7 @@ class UserListDataStore @Inject constructor() {
private val selectedUsers: MutableStateFlow<List<MatrixUser>> = MutableStateFlow(emptyList())
fun selectUser(user: MatrixUser) {
if (user !in selectedUsers.value) {
if (!selectedUsers.value.contains(user)) {
selectedUsers.tryEmit(selectedUsers.value.plus(user))
}
}

View file

@ -38,15 +38,15 @@ open class UserListStateProvider : PreviewParameterProvider<UserListState> {
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectedUsers = aListOfSelectedUsers(),
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
),
aUserListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
selectionMode = SelectionMode.Multiple,
selectedUsers = aListOfSelectedUsers(),
searchResults = SearchBarResultState.Results(aMatrixUserList().map { UserSearchResult(it) }.toImmutableList()),
selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aListOfUserSearchResults()),
),
aUserListState().copy(
isSearchActive = true,
@ -68,3 +68,4 @@ fun aUserListState() = UserListState(
)
fun aListOfSelectedUsers() = aMatrixUserList().take(6).toImmutableList()
fun aListOfUserSearchResults() = aMatrixUserList().take(6).map { UserSearchResult(it) }.toImmutableList()

View file

@ -4,7 +4,7 @@
<string name="screen_notification_optin_title">"Дозволити сповіщення і ніколи не пропускати повідомлення"</string>
<string name="screen_welcome_bullet_1">"Дзвінки, опитування, пошук тощо будуть додані пізніше цього року."</string>
<string name="screen_welcome_bullet_2">"Історія повідомлень для зашифрованих кімнат ще недоступна."</string>
<string name="screen_welcome_bullet_3">"Ми хотіли б почути вас, розкажіть нам, ваші враження та ідеї щодо застосунку, на сторінці налаштувань."</string>
<string name="screen_welcome_bullet_3">"Ми хотіли б почути вас, розкажіть нам ваші враження та ідеї щодо застосунку на сторінці налаштувань."</string>
<string name="screen_welcome_button">"Пішли!"</string>
<string name="screen_welcome_subtitle">"Ось що вам потрібно знати:"</string>
<string name="screen_welcome_title">"Ласкаво просимо до %1$s!"</string>

View file

@ -142,7 +142,7 @@ fun SendLocationView(
lon = cameraPositionState.position.target!!.longitude,
zoom = cameraPositionState.position.zoom,
),
cameraPositionState.location?.let {
location = cameraPositionState.location?.let {
Location(
lat = it.latitude,
lon = it.longitude,

View file

@ -16,7 +16,9 @@
<string name="screen_app_lock_setup_confirm_pin">"Confirmer le code PIN"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Vous ne pouvez pas choisir ce code PIN pour des raisons de sécurité"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Choisissez un code PIN différent"</string>
<string name="screen_app_lock_setup_pin_context">"Verrouillez %1$s pour ajouter une sécurité supplémentaire à vos discussions. Choisissez un code facile à retenir. Si vous oubliez le code PIN, vous serez déconnecté."</string>
<string name="screen_app_lock_setup_pin_context">"Verrouillez %1$s pour ajouter une sécurité supplémentaire à vos discussions.
Choisissez un code facile à retenir. Si vous oubliez le code PIN, vous serez déconnecté."</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Veuillez saisir le même code PIN deux fois"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Les codes PIN ne correspondent pas"</string>
<string name="screen_app_lock_signout_alert_message">"Pour continuer, vous devrez vous connecter à nouveau et créer un nouveau code PIN."</string>

View file

@ -10,13 +10,18 @@
<string name="screen_app_lock_settings_remove_pin_alert_message">"您確定要移除 PIN 碼嗎?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"移除 PIN 碼"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"允許 %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"我想使用 PIN 碼"</string>
<string name="screen_app_lock_setup_choose_pin">"選擇 PIN 碼"</string>
<string name="screen_app_lock_setup_confirm_pin">"確認 PIN 碼"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"基於安全性的考量,您選的 PIN 碼無法使用"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"選擇一個不一樣的 PIN 碼"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"選擇不一樣的 PIN 碼"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"請輸入相同的 PIN 碼兩次"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN 碼不一樣"</string>
<string name="screen_app_lock_signout_alert_message">"您需要重新登入並建立新的 PIN 碼才能繼續"</string>
<string name="screen_app_lock_signout_alert_title">"您即將登出"</string>
<plurals name="screen_app_lock_subtitle">
<item quantity="other">"您有 %1$d 次解鎖的機會"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="other">"PIN 碼錯誤。您還有 %1$d 次機會"</item>
</plurals>

View file

@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_account_provider_change">"Промяна на доставчика на акаунт"</string>
<string name="screen_account_provider_form_notice">"Въведете термин за търсене или адрес на домейн."</string>
<string name="screen_account_provider_form_subtitle">"Търсене на компания, общност или частен сървър."</string>
<string name="screen_account_provider_form_subtitle">"Потърсете компания, общност или частен сървър."</string>
<string name="screen_account_provider_form_title">"Намерете доставчик на акаунт"</string>
<string name="screen_account_provider_signin_subtitle">"Това е мястото, където ще живеят вашите разговори — точно както бихте използвали имейл доставчик, за да съхранявате вашите имейли."</string>
<string name="screen_account_provider_signin_title">"На път сте да влезете в %s"</string>

View file

@ -18,6 +18,7 @@
<string name="screen_change_server_form_header">"Hemserverns URL"</string>
<string name="screen_change_server_form_notice">"Du kan bara ansluta till en befintlig server som stöder sliding sync. Din hemserveradministratör måste konfigurera det. %1$s"</string>
<string name="screen_change_server_subtitle">"Vad är adressen till din server?"</string>
<string name="screen_change_server_title">"Välj din server"</string>
<string name="screen_login_error_deactivated_account">"Detta konto har avaktiverats."</string>
<string name="screen_login_error_invalid_credentials">"Felaktigt användarnamn och/eller lösenord"</string>
<string name="screen_login_error_invalid_user_id">"Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'"</string>

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Är du säker på att du vill logga ut?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Logga ut"</string>
<string name="screen_signout_confirmation_dialog_title">"Logga ut"</string>
<string name="screen_signout_in_progress_dialog_content">"Loggar ut …"</string>
<string name="screen_signout_preference_item">"Logga ut"</string>
</resources>

View file

@ -43,8 +43,8 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.textcomposer.aRichTextEditorState
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
@ -99,7 +99,7 @@ fun aMessagesState(
userHasPermissionToRedactOther: Boolean = false,
userHasPermissionToSendReaction: Boolean = true,
composerState: MessageComposerState = aMessageComposerState(
richTextEditorState = RichTextEditorState("Hello", initialFocus = true),
richTextEditorState = aRichTextEditorState(initialText = "Hello", initialFocus = true),
isFullScreen = false,
mode = MessageComposerMode.Normal,
),

View file

@ -28,12 +28,29 @@
<string name="screen_room_attachment_source_location">"Poloha"</string>
<string name="screen_room_attachment_source_poll">"Hlasování"</string>
<string name="screen_room_attachment_text_formatting">"Formátování textu"</string>
<string name="screen_room_change_permissions_administrators">"Pouze správci"</string>
<string name="screen_room_change_permissions_ban_people">"Vykázat lidi"</string>
<string name="screen_room_change_permissions_delete_messages">"Odstranit zprávy"</string>
<string name="screen_room_change_permissions_everyone">"Všichni"</string>
<string name="screen_room_change_permissions_invite_people">"Pozvat lidi"</string>
<string name="screen_room_change_permissions_member_moderation">"Moderování členů"</string>
<string name="screen_room_change_permissions_messages_and_content">"Zprávy a obsah"</string>
<string name="screen_room_change_permissions_moderators">"Správci a moderátoři"</string>
<string name="screen_room_change_permissions_remove_people">"Odebrat lidi"</string>
<string name="screen_room_change_permissions_room_avatar">"Změnit avatar místnosti"</string>
<string name="screen_room_change_permissions_room_details">"Podrobnosti místnosti"</string>
<string name="screen_room_change_permissions_room_name">"Změnit název místnosti"</string>
<string name="screen_room_change_permissions_room_topic">"Změnit téma místnosti"</string>
<string name="screen_room_change_permissions_send_messages">"Odeslat zprávy"</string>
<string name="screen_room_change_role_administrators_title">"Upravit správce"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Tuto akci nebudete moci vrátit zpět. Upravujete oprávnění uživatele, tak aby měl stejnou úroveň jako vy."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Přidat správce?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Degradovat"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Tuto změnu nebudete moci vrátit zpět, protože sami degradujete, pokud jste posledním privilegovaným uživatelem v místnosti, nebude možné znovu získat oprávnění."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Degradovat se?"</string>
<string name="screen_room_change_role_moderators_title">"Upravit moderátory"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Máte neuložené změny."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Uložit změny?"</string>
<string name="screen_room_encrypted_history_banner">"Historie zpráv je momentálně v této místnosti nedostupná"</string>
<string name="screen_room_encrypted_history_banner_unverified">"Historie zpráv není v této místnosti k dispozici. Ověřte toto zařízení, abyste viděli historii zpráv."</string>
<string name="screen_room_error_failed_processing_media">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
@ -62,6 +79,20 @@
<string name="screen_room_reactions_show_more">"Zobrazit více"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Odeslat znovu"</string>
<string name="screen_room_retry_send_menu_title">"Vaši zprávu se nepodařilo odeslat"</string>
<string name="screen_room_roles_and_permissions_admins">"Správci"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Změnit moji roli"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Degradovat na člena"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Degradovat na moderátora"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Moderování členů"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Zprávy a obsah"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderátoři"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Oprávnění"</string>
<string name="screen_room_roles_and_permissions_reset">"Obnovit oprávnění"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Po obnovení oprávnění ztratíte aktuální nastavení."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Obnovit oprávnění?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Role"</string>
<string name="screen_room_roles_and_permissions_room_details">"Podrobnosti místnosti"</string>
<string name="screen_room_roles_and_permissions_title">"Role a oprávnění"</string>
<string name="screen_room_timeline_add_reaction">"Přidat emoji"</string>
<string name="screen_room_timeline_less_reactions">"Zobrazit méně"</string>
<plurals name="screen_room_typing_many_members">

View file

@ -28,6 +28,10 @@
<string name="screen_room_attachment_source_poll">"Sondage"</string>
<string name="screen_room_attachment_text_formatting">"Formatage du texte"</string>
<string name="screen_room_change_permissions_everyone">"Tout le monde"</string>
<string name="screen_room_change_permissions_room_avatar">"Changer lavatar du salon"</string>
<string name="screen_room_change_permissions_room_details">"Détails du salon"</string>
<string name="screen_room_change_permissions_room_name">"Changer le nom du salon"</string>
<string name="screen_room_change_permissions_room_topic">"Changer le sujet du salon"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Vous ne pourrez pas annuler cette action. Vous êtes en train de promouvoir lutilisateur pour quil ait le même niveau que vous."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Ajouter un administrateur ?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Rétrograder"</string>
@ -61,6 +65,12 @@
<string name="screen_room_reactions_show_more">"Afficher plus"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Envoyer à nouveau"</string>
<string name="screen_room_retry_send_menu_title">"Votre message na pas pu être envoyé"</string>
<string name="screen_room_roles_and_permissions_admins">"Administrateurs"</string>
<string name="screen_room_roles_and_permissions_moderators">"Modérateurs"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Autorisations"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Rôles"</string>
<string name="screen_room_roles_and_permissions_room_details">"Détails du salon"</string>
<string name="screen_room_roles_and_permissions_title">"Rôles et autorisations"</string>
<string name="screen_room_timeline_add_reaction">"Ajouter un émoji"</string>
<string name="screen_room_timeline_less_reactions">"Afficher moins"</string>
<plurals name="screen_room_typing_many_members">

View file

@ -28,12 +28,29 @@
<string name="screen_room_attachment_source_location">"Местоположение"</string>
<string name="screen_room_attachment_source_poll">"Опрос"</string>
<string name="screen_room_attachment_text_formatting">"Форматирование текста"</string>
<string name="screen_room_change_permissions_administrators">"Только для администраторов"</string>
<string name="screen_room_change_permissions_ban_people">"Заблокировать людей"</string>
<string name="screen_room_change_permissions_delete_messages">"Удалить сообщения"</string>
<string name="screen_room_change_permissions_everyone">"Для всех"</string>
<string name="screen_room_change_permissions_invite_people">"Пригласить людей"</string>
<string name="screen_room_change_permissions_member_moderation">"Модерация участников"</string>
<string name="screen_room_change_permissions_messages_and_content">"Сообщения и содержание"</string>
<string name="screen_room_change_permissions_moderators">"Администраторы и модераторы"</string>
<string name="screen_room_change_permissions_remove_people">"Удалить людей"</string>
<string name="screen_room_change_permissions_room_avatar">"Изменить изображение комнаты"</string>
<string name="screen_room_change_permissions_room_details">"Информация о комнате"</string>
<string name="screen_room_change_permissions_room_name">"Изменить название комнаты"</string>
<string name="screen_room_change_permissions_room_topic">"Сменить тему комнаты"</string>
<string name="screen_room_change_permissions_send_messages">"Отправить сообщение"</string>
<string name="screen_room_change_role_administrators_title">"Редактировать роль администраторов"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Вы не сможете отменить это действие. Вы устанавливаете уровень пользователю соответствующий вашему."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Добавить администратора?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Понизить уровень"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Вы не сможете отменить это изменение, так как понижаете себя статус. Если вы являетесь последним привилегированным пользователем в комнате, восстановить привилегии будет невозможно."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Понизить свой уровень?"</string>
<string name="screen_room_change_role_moderators_title">"Редактировать роль модераторов"</string>
<string name="screen_room_change_role_unsaved_changes_description">"У вас есть несохраненные изменения."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Сохранить изменения?"</string>
<string name="screen_room_encrypted_history_banner">"В настоящее время история сообщений недоступна в этой комнате."</string>
<string name="screen_room_encrypted_history_banner_unverified">"История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений."</string>
<string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
@ -62,6 +79,20 @@
<string name="screen_room_reactions_show_more">"Показать больше"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Отправить снова"</string>
<string name="screen_room_retry_send_menu_title">"Не удалось отправить ваше сообщение"</string>
<string name="screen_room_roles_and_permissions_admins">"Администраторы"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Измените мою роль"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Понижение до участника"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Понизить до модератора"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Модерация участников"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Сообщения и содержание"</string>
<string name="screen_room_roles_and_permissions_moderators">"Модераторы"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Разрешения"</string>
<string name="screen_room_roles_and_permissions_reset">"Сбросить разрешения"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Как только вы сбросите разрешения, вы потеряете текущие настройки."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Сбросить разрешения?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Роли"</string>
<string name="screen_room_roles_and_permissions_room_details">"Информация о комнате"</string>
<string name="screen_room_roles_and_permissions_title">"Роли и разрешения"</string>
<string name="screen_room_timeline_add_reaction">"Добавить эмодзи"</string>
<string name="screen_room_timeline_less_reactions">"Показать меньше"</string>
<plurals name="screen_room_typing_many_members">

View file

@ -42,11 +42,15 @@
<string name="screen_room_change_permissions_room_name">"Zmeniť názov miestnosti"</string>
<string name="screen_room_change_permissions_room_topic">"Zmeniť tému miestnosti"</string>
<string name="screen_room_change_permissions_send_messages">"Odoslať správy"</string>
<string name="screen_room_change_role_administrators_title">"Upraviť správcov"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Túto akciu nebudete môcť vrátiť späť. Zvyšujete úroveň používateľa na rovnakú úroveň výkonu ako máte vy."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Pridať správcu?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Znížiť"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Túto zmenu nebudete môcť vrátiť späť, pretože znižujete svoju úroveň. Ak ste posledným privilegovaným používateľom v miestnosti, nebude možné získať znova oprávnenia."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Znížiť svoju úroveň?"</string>
<string name="screen_room_change_role_moderators_title">"Upraviť moderátorov"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Máte neuložené zmeny."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Uložiť zmeny?"</string>
<string name="screen_room_encrypted_history_banner">"História správ v tejto miestnosti nie je momentálne k dispozícii"</string>
<string name="screen_room_encrypted_history_banner_unverified">"História správ nie je v tejto miestnosti k dispozícii. Ak chcete zobraziť históriu správ, overte toto zariadenie."</string>
<string name="screen_room_error_failed_processing_media">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
@ -76,11 +80,16 @@
<string name="screen_room_retry_send_menu_send_again_action">"Odoslať znova"</string>
<string name="screen_room_retry_send_menu_title">"Vašu správu sa nepodarilo odoslať"</string>
<string name="screen_room_roles_and_permissions_admins">"Správcovia"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Zmeniť moje oprávnenia"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Znížiť úroveň na člena"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Znížiť úroveň na moderátora"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Moderovanie členov"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Správy a obsah"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderátori"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Oprávnenia"</string>
<string name="screen_room_roles_and_permissions_reset">"Obnoviť povolenia"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Po obnovení oprávnení prídete o aktuálne nastavenia."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Obnoviť oprávnenia?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Roly"</string>
<string name="screen_room_roles_and_permissions_room_details">"Podrobnosti o miestnosti"</string>
<string name="screen_room_roles_and_permissions_title">"Roly a povolenia"</string>

View file

@ -27,11 +27,13 @@
<string name="screen_room_attachment_source_location">"Plats"</string>
<string name="screen_room_attachment_source_poll">"Omröstning"</string>
<string name="screen_room_attachment_text_formatting">"Textformatering"</string>
<string name="screen_room_change_permissions_everyone">"Alla"</string>
<string name="screen_room_encrypted_history_banner">"Meddelandehistoriken är för närvarande otillgänglig."</string>
<string name="screen_room_error_failed_processing_media">"Misslyckades att bearbeta media för uppladdning, vänligen pröva igen."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Kunde inte hämta användarinformation"</string>
<string name="screen_room_invite_again_alert_message">"Vill du bjuda tillbaka dem?"</string>
<string name="screen_room_invite_again_alert_title">"Du är ensam i den här chatten"</string>
<string name="screen_room_mentions_at_room_title">"Alla"</string>
<string name="screen_room_message_copied">"Meddelande kopierat"</string>
<string name="screen_room_no_permission_to_post">"Du är inte behörig att göra inlägg i det här rummet"</string>
<string name="screen_room_notification_settings_allow_custom">"Tillåt anpassad inställning"</string>
@ -53,4 +55,5 @@
<string name="screen_room_retry_send_menu_title">"Ditt meddelande kunde inte skickas"</string>
<string name="screen_room_timeline_add_reaction">"Lägg till emoji"</string>
<string name="screen_room_timeline_less_reactions">"Visa mindre"</string>
<string name="screen_room_voice_message_tooltip">"Håll för att spela in"</string>
</resources>

View file

@ -42,11 +42,13 @@
<string name="screen_room_change_permissions_room_name">"Змінити назву кімнати"</string>
<string name="screen_room_change_permissions_room_topic">"Змінити тему кімнати"</string>
<string name="screen_room_change_permissions_send_messages">"Надіслати повідомлення"</string>
<string name="screen_room_change_role_administrators_title">"Керувати адмінами"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Ви не зможете скасувати цю дію. Ви просуваєте користувача, щоб він мав такий же рівень прав, як і ви."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Додати адміністратора?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Понизити"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Ви не зможете скасувати цю зміну, оскільки ви знижуєте себе, якщо ви останній привілейований користувач у кімнаті, відновити привілеї буде неможливо."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Понизити себе?"</string>
<string name="screen_room_change_role_moderators_title">"Керувати модераторами"</string>
<string name="screen_room_encrypted_history_banner">"Історія повідомлень наразі недоступна."</string>
<string name="screen_room_encrypted_history_banner_unverified">"Історія повідомлень недоступна в цій кімнаті. Перевірте цей пристрій, щоб побачити історію повідомлень."</string>
<string name="screen_room_error_failed_processing_media">"Не вдалося обробити медіафайл для завантаження, спробуйте ще раз."</string>

View file

@ -40,4 +40,11 @@
<string name="screen_room_retry_send_menu_title">"無法傳送您的訊息"</string>
<string name="screen_room_timeline_add_reaction">"新增表情符號"</string>
<string name="screen_room_timeline_less_reactions">"較少"</string>
<plurals name="screen_room_typing_many_members">
<item quantity="other">"%1$s、%2$s 和其他 %3$d 個人"</item>
</plurals>
<plurals name="screen_room_typing_notification">
<item quantity="other">"%1$s 正在打字"</item>
</plurals>
<string name="screen_room_typing_two_members">"%1$s 和 %2$s"</string>
</resources>

View file

@ -36,10 +36,10 @@
<string name="screen_room_change_permissions_messages_and_content">"Messages and content"</string>
<string name="screen_room_change_permissions_moderators">"Admins and moderators"</string>
<string name="screen_room_change_permissions_remove_people">"Remove people"</string>
<string name="screen_room_change_permissions_room_avatar">"Change Room Avatar"</string>
<string name="screen_room_change_permissions_room_avatar">"Change room avatar"</string>
<string name="screen_room_change_permissions_room_details">"Room details"</string>
<string name="screen_room_change_permissions_room_name">"Change Room Name"</string>
<string name="screen_room_change_permissions_room_topic">"Change Room Topic"</string>
<string name="screen_room_change_permissions_room_name">"Change room name"</string>
<string name="screen_room_change_permissions_room_topic">"Change room topic"</string>
<string name="screen_room_change_permissions_send_messages">"Send messages"</string>
<string name="screen_room_change_role_administrators_title">"Edit Admins"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"You will not be able to undo this action. You are promoting the user to have the same power level as you."</string>
@ -87,6 +87,8 @@
<string name="screen_room_roles_and_permissions_moderators">"Moderators"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Permissions"</string>
<string name="screen_room_roles_and_permissions_reset">"Reset permissions"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Once you reset permissions, you will lose the current settings."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Reset permissions?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Roles"</string>
<string name="screen_room_roles_and_permissions_room_details">"Room details"</string>
<string name="screen_room_roles_and_permissions_title">"Roles and permissions"</string>

View file

@ -16,6 +16,7 @@
package io.element.android.features.poll.api.pollcontent
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.ImmutableList
@ -83,9 +84,11 @@ fun aPollAnswerItem(
)
fun aPollContentState(
eventId: EventId? = null,
isMine: Boolean = false,
isEnded: Boolean = false,
isDisclosed: Boolean = true,
isPollEditable: Boolean = true,
hasVotes: Boolean = true,
question: String = aPollQuestion(),
pollKind: PollKind = PollKind.Disclosed,
@ -95,11 +98,11 @@ fun aPollContentState(
hasVotes = hasVotes
),
) = PollContentState(
eventId = null,
eventId = eventId,
question = question,
answerItems = answerItems,
pollKind = pollKind,
isPollEditable = isMine && !isEnded,
isPollEditable = isMine && !isEnded && isPollEditable,
isPollEnded = isEnded,
isMine = isMine,
)

View file

@ -23,6 +23,11 @@ plugins {
android {
namespace = "io.element.android.features.poll.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
anvil {
@ -48,12 +53,15 @@ dependencies {
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.poll.test)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor)
}

View file

@ -22,44 +22,48 @@ import io.element.android.features.poll.api.pollcontent.aPollContentState
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItem
import io.element.android.features.poll.impl.history.model.PollHistoryItems
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
class PollHistoryStateProvider : PreviewParameterProvider<PollHistoryState> {
override val values: Sequence<PollHistoryState>
get() = sequenceOf(
aPollHistoryState(
isLoading = false,
hasMoreToLoad = false,
activeFilter = PollHistoryFilter.ONGOING,
),
aPollHistoryState(),
aPollHistoryState(
isLoading = true,
hasMoreToLoad = true,
activeFilter = PollHistoryFilter.PAST,
),
aPollHistoryState(
activeFilter = PollHistoryFilter.ONGOING,
currentItems = emptyList(),
),
aPollHistoryState(
activeFilter = PollHistoryFilter.PAST,
currentItems = emptyList(),
),
)
}
private fun aPollHistoryState(
internal fun aPollHistoryState(
isLoading: Boolean = false,
hasMoreToLoad: Boolean = false,
activeFilter: PollHistoryFilter = PollHistoryFilter.ONGOING,
currentItems: ImmutableList<PollHistoryItem> = persistentListOf(
currentItems: List<PollHistoryItem> = listOf(
aPollHistoryItem(),
),
eventSink: (PollHistoryEvents) -> Unit = {},
) = PollHistoryState(
isLoading = isLoading,
hasMoreToLoad = hasMoreToLoad,
activeFilter = activeFilter,
pollHistoryItems = PollHistoryItems(
ongoing = currentItems,
past = currentItems,
ongoing = currentItems.toPersistentList(),
past = currentItems.toPersistentList(),
),
eventSink = {},
eventSink = eventSink,
)
private fun aPollHistoryItem(
internal fun aPollHistoryItem(
formattedDate: String = "01/12/2023",
state: PollContentState = aPollContentState(),
) = PollHistoryItem(

View file

@ -4,10 +4,16 @@
<string name="screen_create_poll_anonymous_desc">"只在投票結束後顯示結果"</string>
<string name="screen_create_poll_anonymous_headline">"隱藏票數"</string>
<string name="screen_create_poll_answer_hint">"選項 %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"變更尚未儲存,您確定要返回嗎?"</string>
<string name="screen_create_poll_question_desc">"問題或主題"</string>
<string name="screen_create_poll_question_hint">"投什麼?"</string>
<string name="screen_create_poll_title">"建立投票"</string>
<string name="screen_edit_poll_delete_confirmation">"您確定要刪除投票嗎?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"刪除投票"</string>
<string name="screen_edit_poll_title">"編輯投票"</string>
<string name="screen_polls_history_empty_ongoing">"沒有進行中的投票。"</string>
<string name="screen_polls_history_empty_past">"沒有已結束的投票。"</string>
<string name="screen_polls_history_filter_ongoing">"進行中"</string>
<string name="screen_polls_history_filter_past">"已結束"</string>
<string name="screen_polls_history_title">"所有投票"</string>
</resources>

View file

@ -0,0 +1,192 @@
/*
* Copyright (c) 2024 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 io.element.android.features.poll.impl.history
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.poll.api.pollcontent.aPollContentState
import io.element.android.features.poll.impl.R
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class PollHistoryViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>(expectEvents = false)
ensureCalledOnce {
rule.setPollHistoryViewView(
aPollHistoryState(
eventSink = eventsRecorder
),
goBack = it
)
rule.pressBack()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on edit poll invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>(expectEvents = false)
val eventId = EventId("\$anEventId")
val state = aPollHistoryState(
currentItems = listOf(
aPollHistoryItem(
state = aPollContentState(
eventId = eventId,
isMine = true,
isEnded = false,
)
)
),
eventSink = eventsRecorder
)
ensureCalledOnceWithParam(eventId) {
rule.setPollHistoryViewView(
state = state,
onEditPoll = it
)
rule.clickOn(CommonStrings.action_edit_poll)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on poll end emits the expected Event`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>()
val eventId = EventId("\$anEventId")
val state = aPollHistoryState(
currentItems = listOf(
aPollHistoryItem(
state = aPollContentState(
eventId = eventId,
isMine = true,
isEnded = false,
isPollEditable = false,
)
)
),
eventSink = eventsRecorder
)
rule.setPollHistoryViewView(
state = state,
)
rule.clickOn(CommonStrings.action_end_poll)
// Cancel the dialog
rule.clickOn(CommonStrings.action_cancel)
// Do it again, and confirm the dialog
rule.clickOn(CommonStrings.action_end_poll)
eventsRecorder.assertEmpty()
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(
PollHistoryEvents.PollEndClicked(eventId)
)
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on poll answer emits the expected Event`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>()
val eventId = EventId("\$anEventId")
val state = aPollHistoryState(
currentItems = listOf(
aPollHistoryItem(
state = aPollContentState(
eventId = eventId,
isMine = true,
isEnded = false,
isPollEditable = false,
)
)
),
eventSink = eventsRecorder
)
val answer = state.pollHistoryItems.ongoing.first().state.answerItems.first().answer
rule.setPollHistoryViewView(
state = state,
)
rule.onNodeWithText(answer.text).performClick()
eventsRecorder.assertSingle(
PollHistoryEvents.PollAnswerSelected(eventId, answer.id)
)
}
@Test
fun `clicking on past tab emits the expected Event`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>()
rule.setPollHistoryViewView(
aPollHistoryState(
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_polls_history_filter_past)
eventsRecorder.assertSingle(
PollHistoryEvents.OnFilterSelected(filter = PollHistoryFilter.PAST)
)
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on load more emits the expected Event`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>()
rule.setPollHistoryViewView(
aPollHistoryState(
hasMoreToLoad = true,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_load_more)
eventsRecorder.assertSingle(
PollHistoryEvents.LoadMore
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPollHistoryViewView(
state: PollHistoryState,
onEditPoll: (EventId) -> Unit = EnsureNeverCalledWithParam(),
goBack: () -> Unit = EnsureNeverCalled(),
) {
setContent {
PollHistoryView(
state = state,
onEditPoll = onEditPoll,
goBack = goBack,
)
}
}

View file

@ -22,6 +22,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.logout.api.direct.DirectLogoutPresenter
@ -39,6 +40,8 @@ import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -86,6 +89,12 @@ class PreferencesRootPresenter @Inject constructor(
mutableStateOf(null)
}
val showBlockedUsersItem by produceState(initialValue = false) {
matrixClient.ignoredUsersFlow
.onEach { value = it.isNotEmpty() }
.launchIn(this)
}
val directLogoutState = directLogoutPresenter.present()
LaunchedEffect(Unit) {
@ -106,6 +115,7 @@ class PreferencesRootPresenter @Inject constructor(
showDeveloperSettings = showDeveloperSettings,
showNotificationSettings = showNotificationSettings.value,
showLockScreenSettings = showLockScreenSettings.value,
showBlockedUsersItem = showBlockedUsersItem,
directLogoutState = directLogoutState,
snackbarMessage = snackbarMessage,
)

View file

@ -33,6 +33,7 @@ data class PreferencesRootState(
val showDeveloperSettings: Boolean,
val showLockScreenSettings: Boolean,
val showNotificationSettings: Boolean,
val showBlockedUsersItem: Boolean,
val directLogoutState: DirectLogoutState,
val snackbarMessage: SnackbarMessage?,
)

View file

@ -33,6 +33,7 @@ fun aPreferencesRootState() = PreferencesRootState(
showDeveloperSettings = true,
showNotificationSettings = true,
showLockScreenSettings = true,
showBlockedUsersItem = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
directLogoutState = aDirectLogoutState(),
)

View file

@ -122,11 +122,13 @@ fun PreferencesRootView(
onClick = onOpenNotificationSettings,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
)
if (state.showBlockedUsersItem) {
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_blocked_users)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = onOpenBlockedUsers,
)
}
ListItem(
headlineContent = { Text(stringResource(id = CommonStrings.common_report_a_problem)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_advanced_settings_send_read_receipts">"Потвърждения за прочитане"</string>
<string name="screen_advanced_settings_share_presence">"Споделяне на присъствието"</string>
<string name="screen_blocked_users_unblock_alert_action">"Отблокиране"</string>
<string name="screen_blocked_users_unblock_alert_title">"Отблокиране на потребителя"</string>
<string name="screen_edit_profile_display_name">"Име"</string>

View file

@ -11,6 +11,7 @@
<string name="screen_advanced_settings_share_presence">"Sdílejte přítomnost"</string>
<string name="screen_advanced_settings_share_presence_description">"Pokud je tato funkce vypnutá, nebudete moci odesílat ani přijímat potvrzení o přečtení ani upozornění na psaní"</string>
<string name="screen_advanced_settings_view_source_description">"Povolit možnost zobrazení zdroje zprávy na časové ose."</string>
<string name="screen_blocked_users_empty">"Nemáte žádné blokované uživatele"</string>
<string name="screen_blocked_users_unblock_alert_action">"Odblokovat"</string>
<string name="screen_blocked_users_unblock_alert_description">"Znovu uvidíte všechny zprávy od nich."</string>
<string name="screen_blocked_users_unblock_alert_title">"Odblokovat uživatele"</string>

View file

@ -25,7 +25,9 @@
<string name="screen_notification_settings_additional_settings_section_title">"Zusätzliche Einstellungen"</string>
<string name="screen_notification_settings_calls_label">"Audio- und Videoanrufe"</string>
<string name="screen_notification_settings_configuration_mismatch">"Konfiguration stimmt nicht überein"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Wir haben die Einstellungen für Benachrichtigungen vereinfacht. Einige Einstellungen, die du gewählt hast, werden hier nicht angezeigt, sind aber immer noch aktiv. Wenn du fortfährst, können sich einige deiner Einstellungen ändern."</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Wir haben die Einstellungen für Benachrichtigungen vereinfacht. Einige Einstellungen, die du gewählt hast, werden hier nicht angezeigt, sind aber immer noch aktiv.
Wenn du fortfährst, können sich einige deiner Einstellungen ändern."</string>
<string name="screen_notification_settings_direct_chats">"Direktnachrichten"</string>
<string name="screen_notification_settings_edit_custom_settings_section_title">"Benutzerdefinierte Einstellung pro Chat"</string>
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Beim Aktualisieren der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."</string>

View file

@ -24,9 +24,7 @@
<string name="screen_notification_settings_additional_settings_section_title">"Réglages supplémentaires"</string>
<string name="screen_notification_settings_calls_label">"Appels audio et vidéo"</string>
<string name="screen_notification_settings_configuration_mismatch">"Incompatibilité de configuration"</string>
<string name="screen_notification_settings_configuration_mismatch_description">"Nous avons simplifié les paramètres des notifications pour que les options soient plus faciles à trouver.
Certains paramètres personnalisés que vous avez choisis par le passé ne sont pas affichés ici, mais ils sont toujours actifs.
<string name="screen_notification_settings_configuration_mismatch_description">"Nous avons simplifié les paramètres des notifications pour que les options soient plus faciles à trouver. Certains paramètres personnalisés que vous avez choisis par le passé ne sont pas affichés ici, mais ils sont toujours actifs.
Si vous continuez, il est possible que certains de vos paramètres soient modifiés."</string>
<string name="screen_notification_settings_direct_chats">"Discussions directes"</string>

View file

@ -11,6 +11,7 @@
<string name="screen_advanced_settings_share_presence">"Поделиться присутствием"</string>
<string name="screen_advanced_settings_share_presence_description">"Если выключено, вы не сможете отправлять, получать уведомления о прочтении и наборе текста"</string>
<string name="screen_advanced_settings_view_source_description">"Включить опцию просмотра источника сообщения в ленте."</string>
<string name="screen_blocked_users_empty">"У вас нет заблокированных пользователей"</string>
<string name="screen_blocked_users_unblock_alert_action">"Разблокировать"</string>
<string name="screen_blocked_users_unblock_alert_description">"Вы снова сможете увидеть все сообщения."</string>
<string name="screen_blocked_users_unblock_alert_title">"Разблокировать пользователя"</string>

View file

@ -2,6 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_advanced_settings_developer_mode">"Utvecklarläge"</string>
<string name="screen_advanced_settings_developer_mode_description">"Aktivera för att ha tillgång till funktionalitet för utvecklare."</string>
<string name="screen_advanced_settings_rich_text_editor_description">"Inaktivera rik-text-redigeraren för att skriva Markdown manuellt."</string>
<string name="screen_blocked_users_unblock_alert_action">"Avblockera"</string>
<string name="screen_blocked_users_unblock_alert_description">"Du kommer att kunna se alla meddelanden från dem igen."</string>
<string name="screen_blocked_users_unblock_alert_title">"Avblockera användare"</string>

View file

@ -9,7 +9,7 @@
<string name="screen_advanced_settings_send_read_receipts">"Читати журнали"</string>
<string name="screen_advanced_settings_send_read_receipts_description">"Якщо вимкнено, ваші сповіщення про прочитання нікому не надсилатимуться. Ви все одно отримуватимете сповіщення про прочитання від інших користувачів."</string>
<string name="screen_advanced_settings_share_presence">"Поділіться присутністю"</string>
<string name="screen_advanced_settings_share_presence_description">"Якщо вимкнено, ви не зможете надсилати або отримувати сповіщення про прочитання або сповіщення про введення тексту"</string>
<string name="screen_advanced_settings_share_presence_description">"Якщо вимкнено, ви не зможете надсилати та отримувати сповіщення про прочитання або введення тексту"</string>
<string name="screen_advanced_settings_view_source_description">"Увімкнути опцію для перегляду коду повідомлення в стрічці"</string>
<string name="screen_blocked_users_empty">"У вас немає заблокованих користувачів."</string>
<string name="screen_blocked_users_unblock_alert_action">"Розблокувати"</string>
@ -28,12 +28,12 @@
<string name="screen_notification_settings_configuration_mismatch_description">"Ми спростили налаштування сповіщень, щоб полегшити пошук параметрів. Деякі користувацькі налаштування, які ви вибрали раніше, тут не відображаються, але вони все ще активні.
Якщо ви продовжите, деякі з ваших налаштувань можуть змінитися."</string>
<string name="screen_notification_settings_direct_chats">"Прямі чати"</string>
<string name="screen_notification_settings_direct_chats">"Особисті чати"</string>
<string name="screen_notification_settings_edit_custom_settings_section_title">"Користувальницькі налаштування для чату"</string>
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Під час оновлення налаштувань сповіщень сталася помилка."</string>
<string name="screen_notification_settings_edit_mode_all_messages">"Всі повідомлення"</string>
<string name="screen_notification_settings_edit_mode_mentions_and_keywords">"Тільки згадки та ключові слова"</string>
<string name="screen_notification_settings_edit_screen_direct_section_header">"У прямих чатах повідомляти мене про"</string>
<string name="screen_notification_settings_edit_screen_direct_section_header">"В особистих чатах сповіщати про"</string>
<string name="screen_notification_settings_edit_screen_group_section_header">"У групових чатах повідомляти мене про"</string>
<string name="screen_notification_settings_enable_notifications">"Увімкнути сповіщення на цьому пристрої"</string>
<string name="screen_notification_settings_failed_fixing_configuration">"Конфігурацію не виправлено, спробуйте ще раз."</string>
@ -44,7 +44,7 @@
<string name="screen_notification_settings_mode_all">"Усі"</string>
<string name="screen_notification_settings_mode_mentions">"Згадки"</string>
<string name="screen_notification_settings_notification_section_title">"Повідомляти мене про"</string>
<string name="screen_notification_settings_room_mention_label">"Згадки про мене в @room"</string>
<string name="screen_notification_settings_room_mention_label">"Сповіщати про @room"</string>
<string name="screen_notification_settings_system_notifications_action_required">"Щоб отримувати сповіщення, будь ласка, змініть свої %1$s."</string>
<string name="screen_notification_settings_system_notifications_action_required_content_link">"системні налаштування"</string>
<string name="screen_notification_settings_system_notifications_turned_off">"Системні сповіщення вимкнені"</string>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_advanced_settings_developer_mode">"開發者模式"</string>
<string name="screen_advanced_settings_share_presence">"分享動態"</string>
<string name="screen_blocked_users_unblock_alert_action">"解除封鎖"</string>
<string name="screen_blocked_users_unblock_alert_title">"解除封鎖使用者"</string>
<string name="screen_edit_profile_display_name">"顯示名稱"</string>

View file

@ -46,6 +46,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.matrix.api)
api(libs.squareup.seismic)
api(projects.features.rageshake.api)
implementation(libs.androidx.datastore.preferences)

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.SdkMetadata
import io.element.android.libraries.network.useragent.UserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ -57,6 +58,10 @@ import java.io.File
import java.io.IOException
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Locale
import javax.inject.Inject
import javax.inject.Provider
@ -78,6 +83,7 @@ class DefaultBugReporter @Inject constructor(
private val sessionStore: SessionStore,
private val buildMeta: BuildMeta,
private val bugReporterUrlProvider: BugReporterUrlProvider,
private val sdkMetadata: SdkMetadata,
) : BugReporter {
companion object {
// filenames
@ -158,6 +164,9 @@ class DefaultBugReporter @Inject constructor(
.addFormDataPart("device_id", deviceId)
.addFormDataPart("device", Build.MODEL.trim())
.addFormDataPart("locale", Locale.getDefault().toString())
.addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha)
.addFormDataPart("local_time", LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME))
.addFormDataPart("utc_time", LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC).format(DateTimeFormatter.ISO_DATE_TIME))
currentTracingFilter?.let {
builder.addFormDataPart("tracing_filter", it)
}

View file

@ -20,6 +20,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.rageshake.api.reporter.BugReporterListener
import io.element.android.features.rageshake.test.crash.FakeCrashDataStore
import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder
import io.element.android.libraries.matrix.test.FakeSdkMetadata
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.network.useragent.DefaultUserAgentProvider
import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore
@ -150,11 +151,12 @@ class DefaultBugReporterTest {
userAgentProvider = DefaultUserAgentProvider(buildMeta),
sessionStore = InMemorySessionStore(),
buildMeta = buildMeta,
bugReporterUrlProvider = { server.url("/") }
bugReporterUrlProvider = { server.url("/") },
sdkMetadata = FakeSdkMetadata("123456789"),
)
}
companion object {
private const val EXPECTED_NUMBER_OF_PROGRESS_VALUE = 12
private const val EXPECTED_NUMBER_OF_PROGRESS_VALUE = 15
}
}

View file

@ -36,6 +36,7 @@ import io.element.android.features.roomdetails.impl.members.RoomMemberListNode
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode
import io.element.android.features.roomdetails.impl.members.details.avatar.AvatarPreviewNode
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
@ -91,6 +92,9 @@ class RoomDetailsFlowNode @AssistedInject constructor(
@Parcelize
data object PollHistory : NavTarget
@Parcelize
data object AdminSettings : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -120,6 +124,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
override fun openPollHistory() {
backstack.push(NavTarget.PollHistory)
}
override fun openAdminSettings() {
backstack.push(NavTarget.AdminSettings)
}
}
createNode<RoomDetailsNode>(buildContext, listOf(roomDetailsCallback))
}
@ -189,6 +197,10 @@ class RoomDetailsFlowNode @AssistedInject constructor(
is NavTarget.PollHistory -> {
pollHistoryEntryPoint.createNode(this, buildContext)
}
is NavTarget.AdminSettings -> {
createNode<RolesAndPermissionsFlowNode>(buildContext)
}
}
}

View file

@ -53,6 +53,7 @@ class RoomDetailsNode @AssistedInject constructor(
fun openRoomNotificationSettings()
fun openAvatarPreview(name: String, url: String)
fun openPollHistory()
fun openAdminSettings()
}
private val callbacks = plugins<Callback>()
@ -119,6 +120,10 @@ class RoomDetailsNode @AssistedInject constructor(
callbacks.forEach { it.openAvatarPreview(name, url) }
}
private fun openAdminSettings() {
callbacks.forEach { it.openAdminSettings() }
}
@Composable
override fun View(modifier: Modifier) {
val context = LocalContext.current
@ -151,6 +156,7 @@ class RoomDetailsNode @AssistedInject constructor(
invitePeople = ::invitePeople,
openAvatarPreview = ::openAvatarPreview,
openPollHistory = ::openPollHistory,
openAdminSettings = this::openAdminSettings,
)
}
}

View file

@ -47,6 +47,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.powerlevels.canSendState
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.coroutines.CoroutineScope
@ -71,6 +72,7 @@ class RoomDetailsPresenter @Inject constructor(
val leaveRoomState = leaveRoomPresenter.present()
val canShowNotificationSettings = remember { mutableStateOf(false) }
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
val isUserAdmin = room.isOwnUserAdmin()
val roomAvatar by remember { derivedStateOf { roomInfo?.avatarUrl ?: room.avatarUrl } }
@ -78,6 +80,10 @@ class RoomDetailsPresenter @Inject constructor(
val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } }
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } }
val isRoomModerationEnabled by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
}
LaunchedEffect(Unit) {
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
if (canShowNotificationSettings.value) {
@ -150,6 +156,7 @@ class RoomDetailsPresenter @Inject constructor(
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite,
displayRolesAndPermissionsSettings = isRoomModerationEnabled && !room.isDm && isUserAdmin,
eventSink = ::handleEvents,
)
}

View file

@ -37,6 +37,7 @@ data class RoomDetailsState(
val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?,
val isFavorite: Boolean,
val displayRolesAndPermissionsSettings: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit
)

View file

@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState> {
override val values: Sequence<RoomDetailsState>
get() = sequenceOf(
aRoomDetailsState(),
aRoomDetailsState(displayAdminSettings = true),
aRoomDetailsState(roomTopic = RoomTopicState.Hidden),
aRoomDetailsState(roomTopic = RoomTopicState.CanAddTopic),
aRoomDetailsState(isEncrypted = false),
@ -92,6 +92,7 @@ fun aRoomDetailsState(
leaveRoomState: LeaveRoomState = aLeaveRoomState(),
roomNotificationSettings: RoomNotificationSettings = aRoomNotificationSettings(),
isFavorite: Boolean = false,
displayAdminSettings: Boolean = false,
eventSink: (RoomDetailsEvent) -> Unit = {},
) = RoomDetailsState(
roomId = roomId,
@ -109,6 +110,7 @@ fun aRoomDetailsState(
leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettings,
isFavorite = isFavorite,
displayRolesAndPermissionsSettings = displayAdminSettings,
eventSink = eventSink
)

View file

@ -97,6 +97,7 @@ fun RoomDetailsView(
invitePeople: () -> Unit,
openAvatarPreview: (name: String, url: String) -> Unit,
openPollHistory: () -> Unit,
openAdminSettings: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onShareMember() {
@ -160,30 +161,45 @@ fun RoomDetailsView(
)
}
if (state.canShowNotificationSettings && state.roomNotificationSettings != null) {
NotificationSection(
isDefaultMode = state.roomNotificationSettings.isDefault,
openRoomNotificationSettings = openRoomNotificationSettings
PreferenceCategory {
if (state.canShowNotificationSettings && state.roomNotificationSettings != null) {
NotificationItem(
isDefaultMode = state.roomNotificationSettings.isDefault,
openRoomNotificationSettings = openRoomNotificationSettings
)
}
FavoriteItem(
isFavorite = state.isFavorite,
onFavoriteChanges = {
state.eventSink(RoomDetailsEvent.SetFavorite(it))
}
)
if (state.displayRolesAndPermissionsSettings) {
ListItem(
headlineContent = { Text("Roles and permissions") },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
onClick = openAdminSettings,
)
}
}
FavoriteSection(
isFavorite = state.isFavorite,
onFavoriteChanges = {
state.eventSink(RoomDetailsEvent.SetFavorite(it))
}
)
if (state.roomType is RoomDetailsType.Room) {
MembersSection(
memberCount = state.memberCount,
openRoomMemberList = openRoomMemberList,
)
if (state.canInvite) {
InviteSection(
invitePeople = invitePeople
)
val displayMemberListItem = state.roomType is RoomDetailsType.Room
val displayInviteMembersItem = state.canInvite
if (displayMemberListItem || displayInviteMembersItem) {
PreferenceCategory {
if (displayMemberListItem) {
MembersItem(
memberCount = state.memberCount,
openRoomMemberList = openRoomMemberList,
)
}
if (displayInviteMembersItem) {
InviteItem(
invitePeople = invitePeople
)
}
}
}
@ -349,7 +365,7 @@ private fun TopicSection(
}
@Composable
private fun NotificationSection(
private fun NotificationItem(
isDefaultMode: Boolean,
openRoomNotificationSettings: () -> Unit,
) {
@ -358,58 +374,49 @@ private fun NotificationSection(
} else {
stringResource(R.string.screen_room_details_notification_mode_custom)
}
PreferenceCategory {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_room_details_notification_title)) },
supportingContent = { Text(text = subtitle) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
onClick = openRoomNotificationSettings,
)
}
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_room_details_notification_title)) },
supportingContent = { Text(text = subtitle) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Notifications())),
onClick = openRoomNotificationSettings,
)
}
@Composable
private fun FavoriteSection(
private fun FavoriteItem(
isFavorite: Boolean,
onFavoriteChanges: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
PreferenceCategory(modifier = modifier) {
PreferenceSwitch(
icon = CompoundIcons.Favourite(),
title = stringResource(id = CommonStrings.common_favourite),
isChecked = isFavorite,
onCheckedChange = onFavoriteChanges
)
}
PreferenceSwitch(
icon = CompoundIcons.Favourite(),
title = stringResource(id = CommonStrings.common_favourite),
isChecked = isFavorite,
onCheckedChange = onFavoriteChanges
)
}
@Composable
private fun MembersSection(
private fun MembersItem(
memberCount: Long,
openRoomMemberList: () -> Unit,
) {
PreferenceCategory {
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
trailingContent = ListItemContent.Text(memberCount.toString()),
onClick = openRoomMemberList,
)
}
ListItem(
headlineContent = { Text(stringResource(CommonStrings.common_people)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.User())),
trailingContent = ListItemContent.Text(memberCount.toString()),
onClick = openRoomMemberList,
)
}
@Composable
private fun InviteSection(
private fun InviteItem(
invitePeople: () -> Unit,
) {
PreferenceCategory {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
onClick = invitePeople,
)
}
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_details_invite_people_title)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
onClick = invitePeople,
)
}
@Composable
@ -481,5 +488,6 @@ private fun ContentToPreview(state: RoomDetailsState) {
invitePeople = {},
openAvatarPreview = { _, _ -> },
openPollHistory = {},
openAdminSettings = {},
)
}

View file

@ -54,7 +54,7 @@ class RoomInviteMembersPresenter @Inject constructor(
val searchResults = remember { mutableStateOf<SearchBarResultState<ImmutableList<InvitableUser>>>(SearchBarResultState.Initial()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
var showSearchLoader = rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
fetchMembers(roomMembers)
@ -99,7 +99,7 @@ class RoomInviteMembersPresenter @Inject constructor(
@JvmName("toggleUserInSelectedUsers")
private fun MutableState<ImmutableList<MatrixUser>>.toggleUser(user: MatrixUser) {
value = if (value.contains(user)) {
value.filterNot { it == user }
value.filterNot { it.userId == user.userId }
} else {
value + user
}.toImmutableList()

View file

@ -48,7 +48,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.CheckableUserRow
import io.element.android.libraries.matrix.ui.components.CheckableUserRowData
import io.element.android.libraries.matrix.ui.components.SelectedUsersList
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
@ -97,7 +97,7 @@ fun RoomInviteMembersView(
)
if (!state.isSearchActive) {
SelectedUsersList(
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = state.selectedUsers,
autoScroll = true,
@ -157,7 +157,7 @@ private fun RoomInviteMembersSearchBar(
placeHolderTitle = placeHolderTitle,
contentPrefix = {
if (selectedUsers.isNotEmpty()) {
SelectedUsersList(
SelectedUsersRowList(
modifier = Modifier.fillMaxWidth(),
selectedUsers = selectedUsers,
autoScroll = true,

View file

@ -16,7 +16,10 @@
package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMemberListEvents {
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents
}

View file

@ -35,15 +35,16 @@ import io.element.android.services.analytics.api.AnalyticsService
class RoomMemberListNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RoomMemberListPresenter,
presenterFactory: RoomMemberListPresenter.Factory,
private val analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins), RoomMemberListNavigator {
interface Callback : Plugin {
fun openRoomMemberDetails(roomMemberId: UserId)
fun openInviteMembers()
}
private val callbacks = plugins<Callback>()
private val presenter = presenterFactory.create(this)
init {
lifecycle.subscribe(
@ -53,27 +54,35 @@ class RoomMemberListNode @AssistedInject constructor(
)
}
private fun openRoomMemberDetails(roomMemberId: UserId) {
override fun openRoomMemberDetails(roomMemberId: UserId) {
callbacks.forEach {
it.openRoomMemberDetails(roomMemberId)
}
}
private fun openInviteMembers() {
override fun openInviteMembers() {
callbacks.forEach {
it.openInviteMembers()
}
}
override fun exitRoomMemberList() {
navigateUp()
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RoomMemberListView(
state = state,
modifier = modifier,
onBackPressed = { navigateUp() },
onMemberSelected = this::openRoomMemberDetails,
onInvitePressed = this::openInviteMembers,
navigator = this,
)
}
}
interface RoomMemberListNavigator {
fun exitRoomMemberList() {}
fun openRoomMemberDetails(roomMemberId: UserId) {}
fun openInviteMembers() {}
}

View file

@ -23,32 +23,45 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationEvents
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationPresenter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canInvite
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
class RoomMemberListPresenter @Inject constructor(
class RoomMemberListPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val roomMemberListDataSource: RoomMemberListDataSource,
private val coroutineDispatchers: CoroutineDispatchers,
private val featureFlagService: FeatureFlagService,
private val roomMembersModerationPresenter: RoomMembersModerationPresenter,
@Assisted private val navigator: RoomMemberListNavigator,
) : Presenter<RoomMemberListState> {
@AssistedFactory
interface Factory {
fun create(navigator: RoomMemberListNavigator): RoomMemberListPresenter
}
@Composable
override fun present(): RoomMemberListState {
val coroutineScope = rememberCoroutineScope()
var roomMembers by remember { mutableStateOf<AsyncData<RoomMembers>>(AsyncData.Loading()) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchResults by remember {
@ -61,18 +74,14 @@ class RoomMemberListPresenter @Inject constructor(
value = room.canInvite().getOrElse { false }
}
val canDisplayBannedUsers by produceState(initialValue = false) {
val roomIsNotDmAndUserCanBan = !room.isDm && room.canBan().getOrElse { false }
if (roomIsNotDmAndUserCanBan) {
room.membersStateFlow
.onEach { members ->
val hasBannedUsers = members.roomMembers()?.any { it.membership == RoomMembershipState.BAN }.orFalse()
value = hasBannedUsers
}
.collect()
} else {
value = false
}
val isRoomModerationEnabled by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
}
val roomModerationState = if (isRoomModerationEnabled) {
roomMembersModerationPresenter.present()
} else {
remember { roomMembersModerationPresenter.dummyState() }
}
LaunchedEffect(membersState) {
@ -116,19 +125,28 @@ class RoomMemberListPresenter @Inject constructor(
}
}
fun handleEvents(event: RoomMemberListEvents) {
when (event) {
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
is RoomMemberListEvents.RoomMemberSelected -> coroutineScope.launch {
if (roomMembersModerationPresenter.canDisplayModerationActions()) {
roomModerationState.eventSink(RoomMembersModerationEvents.SelectRoomMember(event.roomMember))
} else {
navigator.openRoomMemberDetails(event.roomMember.userId)
}
}
}
}
return RoomMemberListState(
roomMembers = roomMembers,
searchQuery = searchQuery,
searchResults = searchResults,
isSearchActive = isSearchActive,
canInvite = canInvite,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = { event ->
when (event) {
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
}
},
moderationState = roomModerationState,
eventSink = { handleEvents(it) },
)
}
}

View file

@ -16,6 +16,7 @@
package io.element.android.features.roomdetails.impl.members
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember
@ -27,7 +28,7 @@ data class RoomMemberListState(
val searchResults: SearchBarResultState<RoomMembers>,
val isSearchActive: Boolean,
val canInvite: Boolean,
val canDisplayBannedUsers: Boolean,
val moderationState: RoomMembersModerationState,
val eventSink: (RoomMemberListEvents) -> Unit,
)

View file

@ -17,6 +17,8 @@
package io.element.android.features.roomdetails.impl.members
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationState
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
@ -57,30 +59,20 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound()
),
aRoomMemberListState().copy(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor(), aWalter()),
joined = persistentListOf(anAlice(), aBob(), aWalter()),
banned = persistentListOf(),
)
),
canDisplayBannedUsers = true,
),
)
}
internal fun aRoomMemberListState(
roomMembers: AsyncData<RoomMembers> = AsyncData.Uninitialized,
searchResults: SearchBarResultState<RoomMembers> = SearchBarResultState.Initial(),
canDisplayBannedUsers: Boolean = false,
moderationState: RoomMembersModerationState = aRoomMembersModerationState(),
) = RoomMemberListState(
roomMembers = roomMembers,
searchQuery = "",
searchResults = searchResults,
isSearchActive = false,
canInvite = false,
canDisplayBannedUsers = canDisplayBannedUsers,
moderationState = moderationState,
eventSink = {}
)

View file

@ -34,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -46,6 +47,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.moderation.RoomMembersModerationView
import io.element.android.features.roomdetails.impl.members.moderation.aRoomMembersModerationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton
@ -76,14 +79,12 @@ private enum class SelectedSection {
@Composable
fun RoomMemberListView(
state: RoomMemberListState,
onBackPressed: () -> Unit,
onInvitePressed: () -> Unit,
onMemberSelected: (UserId) -> Unit,
navigator: RoomMemberListNavigator,
modifier: Modifier = Modifier,
initialSelectedSectionIndex: Int = 0,
) {
fun onUserSelected(roomMember: RoomMember) {
onMemberSelected(roomMember.userId)
state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
}
Scaffold(
@ -92,13 +93,18 @@ fun RoomMemberListView(
if (!state.isSearchActive) {
RoomMemberListTopBar(
canInvite = state.canInvite,
onBackPressed = onBackPressed,
onInvitePressed = onInvitePressed,
onBackPressed = navigator::exitRoomMemberList,
onInvitePressed = navigator::openInviteMembers,
)
}
}
) { padding ->
var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) }
if (!state.moderationState.canDisplayBannedUsers && selectedSection == SelectedSection.BANNED) {
SideEffect {
selectedSection = SelectedSection.MEMBERS
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@ -123,7 +129,7 @@ fun RoomMemberListView(
RoomMemberList(
roomMembers = state.roomMembers.data,
showMembersCount = true,
canDisplayBannedUsersControls = state.canDisplayBannedUsers,
canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers,
selectedSection = selectedSection,
onSelectedSectionChanged = { selectedSection = it },
onUserSelected = ::onUserSelected,
@ -136,6 +142,11 @@ fun RoomMemberListView(
}
}
}
RoomMembersModerationView(
state = state.moderationState,
onDisplayMemberProfile = navigator::openRoomMemberDetails
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@ -328,9 +339,7 @@ private fun RoomMemberSearchBar(
internal fun RoomMemberListPreview(@PreviewParameter(RoomMemberListStateProvider::class) state: RoomMemberListState) = ElementPreview {
RoomMemberListView(
state = state,
onBackPressed = {},
onMemberSelected = {},
onInvitePressed = {},
navigator = object : RoomMemberListNavigator {},
)
}
@ -351,10 +360,8 @@ internal fun RoomMemberBannedListPreview() = ElementPreview {
),
)
),
canDisplayBannedUsers = true,
moderationState = aRoomMembersModerationState(canDisplayBannedUsers = true),
),
onBackPressed = {},
onMemberSelected = {},
onInvitePressed = {},
navigator = object : RoomMemberListNavigator {},
)
}

View file

@ -0,0 +1,181 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.finally
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.powerlevels.canBan
import io.element.android.libraries.matrix.api.room.powerlevels.canKick
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import javax.inject.Inject
@ContributesBinding(RoomScope::class)
class DefaultRoomMembersModerationPresenter @Inject constructor(
private val room: MatrixRoom,
private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
) : RoomMembersModerationPresenter {
private var selectedMember by mutableStateOf<RoomMember?>(null)
private suspend fun canBan() = room.canBan().getOrDefault(false)
private suspend fun canKick() = room.canKick().getOrDefault(false)
override suspend fun canDisplayModerationActions(): Boolean {
val isRoomModerationEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration)
val isDm = room.isDm && room.isEncrypted
return isRoomModerationEnabled && !isDm && (canBan() || canKick())
}
@Composable
override fun present(): RoomMembersModerationState {
val coroutineScope = rememberCoroutineScope()
var moderationActions by remember { mutableStateOf(persistentListOf<ModerationAction>()) }
val kickUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val banUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val unbanUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction<Unit>) }
val canDisplayBannedUsers by produceState(initialValue = false) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.RoomModeration) && !room.isDm && canBan()
}
fun handleEvent(event: RoomMembersModerationEvents) {
when (event) {
is RoomMembersModerationEvents.SelectRoomMember -> {
coroutineScope.launch {
selectedMember = event.roomMember
if (event.roomMember.membership == RoomMembershipState.BAN && canBan()) {
unbanUserAsyncAction.value = AsyncAction.Confirming
} else {
moderationActions = buildList {
add(ModerationAction.DisplayProfile(event.roomMember.userId))
val currentUserMemberPowerLevel = room.userRole(room.sessionId).getOrDefault(RoomMember.Role.USER).powerLevel
if (currentUserMemberPowerLevel > event.roomMember.powerLevel) {
if (canKick()) {
add(ModerationAction.KickUser(event.roomMember.userId))
}
if (canBan()) {
add(ModerationAction.BanUser(event.roomMember.userId))
}
}
}.toPersistentList()
}
}
}
is RoomMembersModerationEvents.KickUser -> {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.kickUser(it.userId, kickUserAsyncAction)
}
}
is RoomMembersModerationEvents.BanUser -> {
if (banUserAsyncAction.value.isConfirming()) {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.banUser(it.userId, banUserAsyncAction)
}
} else {
banUserAsyncAction.value = AsyncAction.Confirming
}
}
is RoomMembersModerationEvents.UnbanUser -> {
if (unbanUserAsyncAction.value.isConfirming()) {
moderationActions = persistentListOf()
selectedMember?.let {
coroutineScope.unbanUser(it.userId, unbanUserAsyncAction)
}
} else {
unbanUserAsyncAction.value = AsyncAction.Confirming
}
}
is RoomMembersModerationEvents.Reset -> {
selectedMember = null
moderationActions = persistentListOf()
kickUserAsyncAction.value = AsyncAction.Uninitialized
banUserAsyncAction.value = AsyncAction.Uninitialized
unbanUserAsyncAction.value = AsyncAction.Uninitialized
}
}
}
return RoomMembersModerationState(
selectedRoomMember = selectedMember,
actions = moderationActions,
kickUserAsyncAction = kickUserAsyncAction.value,
banUserAsyncAction = banUserAsyncAction.value,
unbanUserAsyncAction = unbanUserAsyncAction.value,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = { handleEvent(it) },
)
}
private fun CoroutineScope.kickUser(
userId: UserId,
kickUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(kickUserAction) {
room.kickUser(userId).finally { selectedMember = null }
}
private fun CoroutineScope.banUser(
userId: UserId,
banUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(banUserAction) {
room.banUser(userId).finally { selectedMember = null }
}
private fun CoroutineScope.unbanUser(
userId: UserId,
unbanUserAction: MutableState<AsyncAction<Unit>>,
) = runActionAndWaitForMembershipChange(unbanUserAction) {
room.unbanUser(userId).finally { selectedMember = null }
}
private fun <T> CoroutineScope.runActionAndWaitForMembershipChange(action: MutableState<AsyncAction<T>>, block: suspend () -> Result<T>) {
launch(dispatchers.io) {
action.runUpdatingState {
val result = block()
if (result.isSuccess) {
room.membersStateFlow.drop(1).take(1)
}
result
}
}
}
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMembersModerationEvents {
data class SelectRoomMember(val roomMember: RoomMember) : RoomMembersModerationEvents
data object KickUser : RoomMembersModerationEvents
data object BanUser : RoomMembersModerationEvents
data object UnbanUser : RoomMembersModerationEvents
data object Reset : RoomMembersModerationEvents
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.persistentListOf
interface RoomMembersModerationPresenter : Presenter<RoomMembersModerationState> {
suspend fun canDisplayModerationActions(): Boolean
fun dummyState() = RoomMembersModerationState(
selectedRoomMember = null,
actions = persistentListOf(),
kickUserAsyncAction = AsyncAction.Uninitialized,
banUserAsyncAction = AsyncAction.Uninitialized,
unbanUserAsyncAction = AsyncAction.Uninitialized,
canDisplayBannedUsers = false,
eventSink = {}
)
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.members.moderation
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.ImmutableList
data class RoomMembersModerationState(
val selectedRoomMember: RoomMember?,
val actions: ImmutableList<ModerationAction>,
val kickUserAsyncAction: AsyncAction<Unit>,
val banUserAsyncAction: AsyncAction<Unit>,
val unbanUserAsyncAction: AsyncAction<Unit>,
val canDisplayBannedUsers: Boolean,
val eventSink: (RoomMembersModerationEvents) -> Unit,
)
sealed interface ModerationAction {
data class DisplayProfile(val userId: UserId) : ModerationAction
data class KickUser(val userId: UserId) : ModerationAction
data class BanUser(val userId: UserId) : ModerationAction
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.anAlice
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.collections.immutable.toPersistentList
class RoomMembersModerationStatePreviewProvider : PreviewParameterProvider<RoomMembersModerationState> {
override val values: Sequence<RoomMembersModerationState>
get() = sequenceOf(
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = listOf(
ModerationAction.DisplayProfile(anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = listOf(
ModerationAction.DisplayProfile(anAlice().userId),
ModerationAction.KickUser(userId = anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
actions = listOf(
ModerationAction.DisplayProfile(anAlice().userId),
ModerationAction.KickUser(userId = anAlice().userId),
ModerationAction.BanUser(userId = anAlice().userId),
),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
kickUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
unbanUserAsyncAction = AsyncAction.Loading,
),
aRoomMembersModerationState(
kickUserAsyncAction = AsyncAction.Failure(Exception("Failed to kick user")),
banUserAsyncAction = AsyncAction.Failure(Exception("Failed to ban user")),
unbanUserAsyncAction = AsyncAction.Failure(Exception("Failed to unban user")),
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
banUserAsyncAction = AsyncAction.Confirming,
),
aRoomMembersModerationState(
selectedRoomMember = anAlice(),
unbanUserAsyncAction = AsyncAction.Confirming,
),
aRoomMembersModerationState(
kickUserAsyncAction = AsyncAction.Success(Unit),
banUserAsyncAction = AsyncAction.Success(Unit),
unbanUserAsyncAction = AsyncAction.Success(Unit),
),
)
}
fun aRoomMembersModerationState(
selectedRoomMember: RoomMember? = null,
actions: List<ModerationAction> = emptyList(),
kickUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
banUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
unbanUserAsyncAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
canDisplayBannedUsers: Boolean = false,
eventSink: (RoomMembersModerationEvents) -> Unit = {},
) = RoomMembersModerationState(
selectedRoomMember = selectedRoomMember,
actions = actions.toPersistentList(),
kickUserAsyncAction = kickUserAsyncAction,
banUserAsyncAction = banUserAsyncAction,
unbanUserAsyncAction = unbanUserAsyncAction,
canDisplayBannedUsers = canDisplayBannedUsers,
eventSink = eventSink,
)

View file

@ -0,0 +1,316 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.members.moderation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun RoomMembersModerationView(
state: RoomMembersModerationState,
onDisplayMemberProfile: (UserId) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
if (state.actions.isNotEmpty()) {
RoomMemberActionsBottomSheet(
roomMember = state.selectedRoomMember,
actions = state.actions,
onActionSelected = { action ->
when (action) {
is ModerationAction.DisplayProfile -> {
onDisplayMemberProfile(action.userId)
}
is ModerationAction.KickUser -> {
state.eventSink(RoomMembersModerationEvents.KickUser)
}
is ModerationAction.BanUser -> {
state.eventSink(RoomMembersModerationEvents.BanUser)
}
}
},
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) },
)
}
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), state = asyncIndicatorState)
when (val action = state.kickUserAsyncAction) {
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_removing_user, userDisplayName))
}
}
}
is AsyncAction.Failure -> {
Timber.e(action.error, "Failed to kick user.")
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_failed),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
}
when (val action = state.banUserAsyncAction) {
is AsyncAction.Confirming -> {
ConfirmationDialog(
title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title),
content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description),
submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action),
onSubmitClicked = { state.selectedRoomMember?.userId?.let { state.eventSink(RoomMembersModerationEvents.BanUser) } },
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }
)
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_banning_user, userDisplayName))
}
}
}
is AsyncAction.Failure -> {
Timber.e(action.error, "Failed to ban user.")
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_failed),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
}
when (val action = state.unbanUserAsyncAction) {
is AsyncAction.Confirming -> {
state.selectedRoomMember?.let {
ConfirmationDialog(
title = stringResource(R.string.screen_room_member_list_manage_member_unban_title),
content = stringResource(R.string.screen_room_member_list_manage_member_unban_message),
submitText = stringResource(R.string.screen_room_member_list_manage_member_unban_action),
onSubmitClicked = { state.eventSink(RoomMembersModerationEvents.UnbanUser) },
onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) },
)
}
}
is AsyncAction.Loading -> {
LaunchedEffect(action) {
val userDisplayName = state.selectedRoomMember?.getBestName().orEmpty()
asyncIndicatorState.enqueue {
AsyncIndicator.Loading(text = stringResource(R.string.screen_room_member_list_unbanning_user, userDisplayName))
}
}
}
is AsyncAction.Failure -> {
Timber.e(action.error, "Failed to unban user.")
LaunchedEffect(action) {
asyncIndicatorState.enqueue(AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Failure(
text = stringResource(CommonStrings.common_failed),
)
}
}
}
is AsyncAction.Success -> {
LaunchedEffect(action) { asyncIndicatorState.clear() }
}
else -> Unit
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RoomMemberActionsBottomSheet(
roomMember: RoomMember?,
actions: ImmutableList<ModerationAction>,
onActionSelected: (ModerationAction) -> Unit,
onDismiss: () -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
if (roomMember != null && actions.isNotEmpty()) {
val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
modifier = Modifier.systemBarsPadding(),
sheetState = bottomSheetState,
onDismissRequest = {
coroutineScope.launch {
bottomSheetState.hide()
onDismiss()
}
},
) {
Column(
modifier = Modifier.padding(vertical = 16.dp)
) {
Avatar(
avatarData = AvatarData(
id = roomMember.userId.value,
name = roomMember.displayName,
url = roomMember.avatarUrl,
size = AvatarSize.RoomListManageUser,
),
modifier = Modifier
.padding(bottom = 28.dp)
.align(Alignment.CenterHorizontally)
)
roomMember.displayName?.let {
Text(
text = it,
style = ElementTheme.typography.fontHeadingLgBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
.fillMaxWidth()
)
}
Text(
text = roomMember.userId.toString(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
for (action in actions) {
when (action) {
is ModerationAction.DisplayProfile -> {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_list_manage_member_user_info)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())),
onClick = {
coroutineScope.launch {
onActionSelected(action)
bottomSheetState.hide()
}
}
)
}
is ModerationAction.KickUser -> {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_list_manage_member_remove)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
onClick = {
coroutineScope.launch {
bottomSheetState.hide()
onActionSelected(action)
}
}
)
}
is ModerationAction.BanUser -> {
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_member_list_manage_member_remove_confirmation_ban)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Block())),
style = ListItemStyle.Destructive,
onClick = {
coroutineScope.launch {
bottomSheetState.hide()
onActionSelected(action)
}
}
)
}
}
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun RoomMembersModerationViewPreview(@PreviewParameter(RoomMembersModerationStatePreviewProvider::class) state: RoomMembersModerationState) {
ElementPreview {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
) {
RoomMembersModerationView(
state = state,
onDisplayMemberProfile = {},
)
}
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RolesAndPermissionsEvents {
data object ChangeOwnRole : RolesAndPermissionsEvents
data class DemoteSelfTo(val role: RoomMember.Role) : RolesAndPermissionsEvents
data object CancelPendingAction : RolesAndPermissionsEvents
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles.ChangeRolesNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class RolesAndPermissionsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BaseFlowNode<RolesAndPermissionsFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.AdminSettings,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object AdminSettings : NavTarget
@Parcelize
data object AdminList : NavTarget
@Parcelize
data object ModeratorList : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.AdminSettings -> {
val callback = object : RolesAndPermissionsNode.Callback {
override fun openAdminList() {
backstack.push(NavTarget.AdminList)
}
override fun openModeratorList() {
backstack.push(NavTarget.ModeratorList)
}
}
createNode<RolesAndPermissionsNode>(
buildContext = buildContext,
plugins = listOf(callback),
)
}
is NavTarget.AdminList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Admins)
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(inputs),
)
}
is NavTarget.ModeratorList -> {
val inputs = ChangeRolesNode.Inputs(ChangeRolesNode.ListType.Moderators)
createNode<ChangeRolesNode>(
buildContext = buildContext,
plugins = listOf(inputs),
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2023 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 io.element.android.features.roomdetails.impl.rolesandpermissions
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
@ContributesNode(RoomScope::class)
class RolesAndPermissionsNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: RolesAndPermissionsPresenter,
private val room: MatrixRoom,
) : Node(buildContext, plugins = plugins), RoomDetailsAdminSettingsNavigator {
interface Callback : Plugin {
fun openAdminList()
fun openModeratorList()
}
private val callback = plugins<Callback>().first()
override fun onBackPressed() = navigateUp()
override fun openAdminList() = callback.openAdminList()
override fun openModeratorList() = callback.openModeratorList()
override fun onBuilt() {
super.onBuilt()
// Reload members when the user sees this screen
lifecycle.addObserver(object : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
if (event == Lifecycle.Event.ON_RESUME) {
lifecycleScope.launch { room.updateMembers() }
}
}
})
// If the user is not an admin anymore, exit this section since they won't have permissions to use it
lifecycleScope.launch {
room.membersStateFlow
.map { state ->
state.roomMembers().orEmpty().find { it.userId == room.sessionId }
}
.filter { it?.role != RoomMember.Role.ADMIN }
.take(1)
.onEach { navigateUp() }
.collect()
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
RolesAndPermissionsView(
state = state,
roomDetailsAdminSettingsNavigator = this,
modifier = modifier,
)
}
}
interface RoomDetailsAdminSettingsNavigator {
fun onBackPressed() {}
fun openAdminList() {}
fun openModeratorList() {}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class RolesAndPermissionsPresenter @Inject constructor(
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
) : Presenter<RolesAndPermissionsState> {
@Composable
override fun present(): RolesAndPermissionsState {
val coroutineScope = rememberCoroutineScope()
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
val moderatorCount by remember {
derivedStateOf {
roomInfo.userCountWithRole(RoomMember.Role.MODERATOR)
}
}
val adminCount by remember {
derivedStateOf {
roomInfo.userCountWithRole(RoomMember.Role.ADMIN)
}
}
val changeOwnRoleAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
fun handleEvent(event: RolesAndPermissionsEvents) {
when (event) {
is RolesAndPermissionsEvents.ChangeOwnRole -> {
changeOwnRoleAction.value = AsyncAction.Confirming
}
is RolesAndPermissionsEvents.CancelPendingAction -> {
changeOwnRoleAction.value = AsyncAction.Uninitialized
}
is RolesAndPermissionsEvents.DemoteSelfTo -> coroutineScope.demoteSelfTo(
role = event.role,
changeOwnRoleAction = changeOwnRoleAction,
)
}
}
return RolesAndPermissionsState(
adminCount = adminCount,
moderatorCount = moderatorCount,
changeOwnRoleAction = changeOwnRoleAction.value,
eventSink = { handleEvent(it) },
)
}
private fun CoroutineScope.demoteSelfTo(
role: RoomMember.Role,
changeOwnRoleAction: MutableState<AsyncAction<Unit>>,
) = launch(dispatchers.io) {
runUpdatingState(changeOwnRoleAction) {
room.updateUsersRoles(listOf(UserRoleChange(room.sessionId, role)))
}
}
private fun MatrixRoomInfo?.userCountWithRole(role: RoomMember.Role): Int {
return if (this != null) {
userPowerLevels.count { (_, level) -> RoomMember.Role.forPowerLevel(level) == role }
} else {
0
}
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions
import io.element.android.libraries.architecture.AsyncAction
data class RolesAndPermissionsState(
val adminCount: Int,
val moderatorCount: Int,
val changeOwnRoleAction: AsyncAction<Unit>,
val eventSink: (RolesAndPermissionsEvents) -> Unit,
)

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
class RolesAndPermissionsStateProvider : PreviewParameterProvider<RolesAndPermissionsState> {
override val values: Sequence<RolesAndPermissionsState>
get() = sequenceOf(
aRolesAndPermissionsState(),
aRolesAndPermissionsState(adminCount = 1, moderatorCount = 2),
aRolesAndPermissionsState(
adminCount = 1,
moderatorCount = 2,
changeOwnRoleAction = AsyncAction.Confirming,
),
aRolesAndPermissionsState(
adminCount = 1,
moderatorCount = 2,
changeOwnRoleAction = AsyncAction.Loading,
),
aRolesAndPermissionsState(
adminCount = 1,
moderatorCount = 2,
changeOwnRoleAction = AsyncAction.Failure(IllegalStateException("Failed to change role")),
),
)
}
internal fun aRolesAndPermissionsState(
adminCount: Int = 0,
moderatorCount: Int = 0,
changeOwnRoleAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (RolesAndPermissionsEvents) -> Unit = {},
) = RolesAndPermissionsState(
adminCount = adminCount,
moderatorCount = moderatorCount,
changeOwnRoleAction = changeOwnRoleAction,
eventSink = eventSink,
)

View file

@ -0,0 +1,178 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.sheetStateForPreview
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ListSectionHeader
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RolesAndPermissionsView(
state: RolesAndPermissionsState,
roomDetailsAdminSettingsNavigator: RoomDetailsAdminSettingsNavigator,
modifier: Modifier = Modifier,
) {
PreferencePage(
modifier = modifier,
title = stringResource(R.string.screen_room_roles_and_permissions_title),
onBackPressed = roomDetailsAdminSettingsNavigator::onBackPressed,
) {
ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_roles_header), hasDivider = false)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_admins)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())),
trailingContent = ListItemContent.Text("${state.adminCount}"),
onClick = { roomDetailsAdminSettingsNavigator.openAdminList() },
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_moderators)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())),
trailingContent = ListItemContent.Text("${state.moderatorCount}"),
onClick = { roomDetailsAdminSettingsNavigator.openModeratorList() },
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) },
onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Edit()))
)
HorizontalDivider()
}
when (state.changeOwnRoleAction) {
is AsyncAction.Confirming -> {
ChangeOwnRoleBottomSheet(
eventSink = state.eventSink,
)
}
is AsyncAction.Loading -> {
ProgressDialog()
}
is AsyncAction.Failure -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(RolesAndPermissionsEvents.CancelPendingAction) }
)
}
else -> Unit
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChangeOwnRoleBottomSheet(
eventSink: (RolesAndPermissionsEvents) -> Unit,
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = if (LocalInspectionMode.current) {
sheetStateForPreview()
} else {
rememberModalBottomSheetState(skipPartiallyExpanded = true)
}
fun dismiss() {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.CancelPendingAction)
}
}
ModalBottomSheet(
modifier = Modifier
.systemBarsPadding()
.navigationBarsPadding(),
sheetState = sheetState,
onDismissRequest = ::dismiss,
) {
Text(
modifier = Modifier.padding(14.dp),
text = stringResource(R.string.screen_room_roles_and_permissions_change_my_role),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
Text(
modifier = Modifier.padding(start = 14.dp, end = 14.dp, bottom = 16.dp),
text = stringResource(R.string.screen_room_change_role_confirm_demote_self_description),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.MODERATOR))
}
},
style = ListItemStyle.Destructive,
)
ListItem(
headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.USER))
}
},
style = ListItemStyle.Destructive,
)
ListItem(
headlineContent = { Text(stringResource(CommonStrings.action_cancel)) },
onClick = {
sheetState.hide(coroutineScope) {
eventSink(RolesAndPermissionsEvents.CancelPendingAction)
}
},
style = ListItemStyle.Primary,
)
}
}
@PreviewsDayNight
@Composable
internal fun RoomDetailsAdminSettingsViewPreview(@PreviewParameter(RolesAndPermissionsStateProvider::class) state: RolesAndPermissionsState) {
ElementPreview {
RolesAndPermissionsView(
state = state,
roomDetailsAdminSettingsNavigator = object : RoomDetailsAdminSettingsNavigator {},
)
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface ChangeRolesEvent {
data object ToggleSearchActive : ChangeRolesEvent
data class QueryChanged(val query: String?) : ChangeRolesEvent
data class UserSelectionToggled(val roomMember: RoomMember) : ChangeRolesEvent
data object Save : ChangeRolesEvent
data object Exit : ChangeRolesEvent
data object CancelExit : ChangeRolesEvent
data object ClearError : ChangeRolesEvent
data object CancelSave : ChangeRolesEvent
}

View file

@ -0,0 +1,71 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.RoomMember
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
class ChangeRolesNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: ChangeRolesPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
sealed interface ListType : Parcelable {
@Parcelize
data object Admins : ListType
@Parcelize
data object Moderators : ListType
}
@Parcelize
data class Inputs(
val listType: ListType,
) : NodeInputs, Parcelable
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.run {
val role = when (inputs.listType) {
is ListType.Admins -> RoomMember.Role.ADMIN
is ListType.Moderators -> RoomMember.Role.MODERATOR
}
create(role)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
ChangeRolesView(
modifier = modifier,
state = state,
onBackPressed = this::navigateUp,
)
}
}

View file

@ -0,0 +1,215 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class ChangeRolesPresenter @AssistedInject constructor(
@Assisted private val role: RoomMember.Role,
private val room: MatrixRoom,
private val dispatchers: CoroutineDispatchers,
) : Presenter<ChangeRolesState> {
@AssistedFactory
interface Factory {
fun create(role: RoomMember.Role): ChangeRolesPresenter
}
@Composable
override fun present(): ChangeRolesState {
val coroutineScope = rememberCoroutineScope()
val dataSource = remember { RoomMemberListDataSource(room, dispatchers) }
var query by rememberSaveable { mutableStateOf<String?>(null) }
var searchActive by rememberSaveable { mutableStateOf(false) }
var searchResults by remember {
mutableStateOf<SearchBarResultState<ImmutableList<RoomMember>>>(SearchBarResultState.Initial())
}
val selectedUsers = remember {
mutableStateOf<ImmutableList<MatrixUser>>(persistentListOf())
}
val exitState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val saveState: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
val usersWithRole = produceState(initialValue = persistentListOf()) {
room.usersWithRole(role)
.map { members -> members.map { it.toMatrixUser() } }
.onEach { users ->
val previous: PersistentList<MatrixUser> = value
value = users.toPersistentList()
// Users who were selected but didn't have the role, so their role change was pending
val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } }
// Users who no longer have the role
val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }
selectedUsers.value = (users + toAdd - toRemove).toImmutableList()
}
.launchIn(this)
}
val roomMemberState by room.membersStateFlow.collectAsState()
// Update search results for every query change
LaunchedEffect(query, roomMemberState) {
val results = dataSource
.search(query.orEmpty())
.sorted()
searchResults = if (results.isEmpty()) {
SearchBarResultState.NoResultsFound()
} else {
SearchBarResultState.Results(results)
}
}
val hasPendingChanges = usersWithRole.value != selectedUsers.value
val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
fun canChangeMemberRole(userId: UserId): Boolean {
// An admin can't remove or demote another admin
val powerLevel = roomInfo?.userPowerLevels?.get(userId) ?: 0L
return RoomMember.Role.forPowerLevel(powerLevel) != RoomMember.Role.ADMIN
}
fun handleEvent(event: ChangeRolesEvent) {
when (event) {
is ChangeRolesEvent.ToggleSearchActive -> {
searchActive = !searchActive
}
is ChangeRolesEvent.QueryChanged -> {
query = event.query
}
is ChangeRolesEvent.UserSelectionToggled -> {
val newList = selectedUsers.value.toMutableList()
val index = newList.indexOfFirst { it.userId == event.roomMember.userId }
if (index >= 0) {
newList.removeAt(index)
} else {
newList.add(event.roomMember.toMatrixUser())
}
selectedUsers.value = newList.toImmutableList()
}
is ChangeRolesEvent.Save -> {
if (role == RoomMember.Role.ADMIN && selectedUsers != usersWithRole && !saveState.value.isConfirming()) {
// Confirm adding admin
saveState.value = AsyncAction.Confirming
} else if (!saveState.value.isLoading()) {
coroutineScope.save(usersWithRole.value, selectedUsers, saveState)
}
}
is ChangeRolesEvent.ClearError -> {
saveState.value = AsyncAction.Uninitialized
}
is ChangeRolesEvent.Exit -> {
exitState.value = if (exitState.value.isUninitialized() && hasPendingChanges) {
// Has pending changes, confirm exit
AsyncAction.Confirming
} else {
// No pending changes, exit immediately
AsyncAction.Success(Unit)
}
}
is ChangeRolesEvent.CancelExit -> {
exitState.value = AsyncAction.Uninitialized
}
is ChangeRolesEvent.CancelSave -> {
saveState.value = AsyncAction.Uninitialized
}
}
}
return ChangeRolesState(
role = role,
query = query,
isSearchActive = searchActive,
searchResults = searchResults,
selectedUsers = selectedUsers.value,
hasPendingChanges = hasPendingChanges,
exitState = exitState.value,
savingState = saveState.value,
canChangeMemberRole = ::canChangeMemberRole,
eventSink = ::handleEvent,
)
}
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
}
private fun RoomMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
)
private fun CoroutineScope.save(
usersWithRole: ImmutableList<MatrixUser>,
selectedUsers: MutableState<ImmutableList<MatrixUser>>,
saveState: MutableState<AsyncAction<Unit>>,
) = launch {
saveState.value = AsyncAction.Loading
val toAdd = selectedUsers.value - usersWithRole
val toRemove = usersWithRole - selectedUsers.value
val changes: List<UserRoleChange> = buildList {
for (selectedUser in toAdd) {
add(UserRoleChange(selectedUser.userId, role))
}
for (selectedUser in toRemove) {
add(UserRoleChange(selectedUser.userId, RoomMember.Role.USER))
}
}
room.updateUsersRoles(changes)
.onFailure {
saveState.value = AsyncAction.Failure(it)
}
.onSuccess {
saveState.value = AsyncAction.Success(Unit)
}
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ChangeRolesState(
val role: RoomMember.Role,
val query: String?,
val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<RoomMember>>,
val selectedUsers: ImmutableList<MatrixUser>,
val hasPendingChanges: Boolean,
val exitState: AsyncAction<Unit>,
val savingState: AsyncAction<Unit>,
val canChangeMemberRole: (UserId) -> Boolean,
val eventSink: (ChangeRolesEvent) -> Unit,
)

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomdetails.impl.members.aRoomMemberList
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
override val values: Sequence<ChangeRolesState>
get() = sequenceOf(
aChangeRolesState(),
aChangeRolesState(role = RoomMember.Role.MODERATOR),
aChangeRolesStateWithSelectedUsers().copy(hasPendingChanges = false),
aChangeRolesStateWithSelectedUsers(),
aChangeRolesStateWithSelectedUsers().copy(
selectedUsers = aMatrixUserList().take(2).toImmutableList(),
),
aChangeRolesStateWithSelectedUsers().copy(
query = "Alice",
isSearchActive = true,
searchResults = SearchBarResultState.Results(aRoomMemberList().take(1).toImmutableList()),
selectedUsers = aMatrixUserList().take(1).toImmutableList(),
),
aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.Confirming),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Confirming),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))),
)
}
internal fun aChangeRolesState(
role: RoomMember.Role = RoomMember.Role.ADMIN,
query: String? = null,
isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<RoomMember>> = SearchBarResultState.NoResultsFound(),
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
hasPendingChanges: Boolean = false,
exitState: AsyncAction<Unit> = AsyncAction.Uninitialized,
savingState: AsyncAction<Unit> = AsyncAction.Uninitialized,
canRemoveMember: (UserId) -> Boolean = { true },
) = ChangeRolesState(
role = role,
query = query,
isSearchActive = isSearchActive,
searchResults = searchResults,
selectedUsers = selectedUsers,
hasPendingChanges = hasPendingChanges,
exitState = exitState,
savingState = savingState,
canChangeMemberRole = canRemoveMember,
eventSink = {},
)
internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
selectedUsers = aMatrixUserList().toImmutableList(),
searchResults = SearchBarResultState.Results(aRoomMemberList().toImmutableList()),
hasPendingChanges = true,
canRemoveMember = { it != UserId("@alice:server.org") },
)

View file

@ -0,0 +1,295 @@
/*
* Copyright (c) 2024 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 io.element.android.features.roomdetails.impl.rolesandpermissions.changeroles
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.roomdetails.impl.R
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost
import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChangeRolesView(
state: ChangeRolesState,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
val updatedOnBackPressed by rememberUpdatedState(newValue = onBackPressed)
BackHandler {
if (state.isSearchActive) {
state.eventSink(ChangeRolesEvent.ToggleSearchActive)
} else {
state.eventSink(ChangeRolesEvent.Exit)
}
}
Box(modifier = modifier) {
Scaffold(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding(),
topBar = {
AnimatedVisibility(visible = !state.isSearchActive) {
TopAppBar(
title = {
val title = when (state.role) {
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_change_role_administrators_title)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_change_role_moderators_title)
RoomMember.Role.USER -> error("This should never be reached")
}
Text(
text = title,
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = { state.eventSink(ChangeRolesEvent.Exit) })
},
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
enabled = state.hasPendingChanges,
onClick = { state.eventSink(ChangeRolesEvent.Save) }
)
}
)
}
}
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues),
) {
val lazyListState = rememberLazyListState()
SearchBar(
modifier = Modifier.padding(bottom = 16.dp),
placeHolderTitle = stringResource(CommonStrings.common_search_for_someone),
query = state.query.orEmpty(),
onQueryChange = { state.eventSink(ChangeRolesEvent.QueryChanged(it)) },
active = state.isSearchActive,
onActiveChange = { state.eventSink(ChangeRolesEvent.ToggleSearchActive) },
resultState = state.searchResults,
) { members ->
SearchResultsList(
isSearchActive = true,
lazyListState = lazyListState,
searchResults = members,
selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole,
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
selectedUsersList = {},
)
}
AnimatedVisibility(
visible = !state.isSearchActive,
enter = fadeIn(),
exit = fadeOut()
) {
Column {
SearchResultsList(
isSearchActive = false,
lazyListState = lazyListState,
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: persistentListOf(),
selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole,
onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) },
selectedUsersList = { users ->
SelectedUsersRowList(
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
selectedUsers = users,
onUserRemoved = {
state.eventSink(ChangeRolesEvent.UserSelectionToggled(aRoomMember(it.userId)))
},
canDeselect = { state.canChangeMemberRole(it.userId) },
)
}
)
}
}
}
}
val asyncIndicatorState = rememberAsyncIndicatorState()
AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState)
AsyncActionView(
async = state.exitState,
onSuccess = { updatedOnBackPressed() },
confirmationDialog = {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClicked = { state.eventSink(ChangeRolesEvent.Exit) },
onDismiss = { state.eventSink(ChangeRolesEvent.CancelExit) }
)
},
onErrorDismiss = { /* Cannot happen */ },
)
when (state.savingState) {
is AsyncAction.Confirming -> {
if (state.role == RoomMember.Role.ADMIN) {
// Confirm adding new admins dialogs
ConfirmationDialog(
title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title),
content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description),
onSubmitClicked = { state.eventSink(ChangeRolesEvent.Save) },
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
}
is AsyncAction.Loading -> {
ProgressDialog()
}
is AsyncAction.Failure -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }
)
}
is AsyncAction.Success -> {
LaunchedEffect(state.savingState) {
asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) {
AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes))
}
}
}
else -> Unit
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchResultsList(
isSearchActive: Boolean,
searchResults: ImmutableList<RoomMember>,
selectedUsers: ImmutableList<MatrixUser>,
canRemoveMember: (UserId) -> Boolean,
onSelectionToggled: (RoomMember) -> Unit,
lazyListState: LazyListState,
selectedUsersList: @Composable (ImmutableList<MatrixUser>) -> Unit,
) {
LazyColumn(
state = lazyListState,
) {
item {
selectedUsersList(selectedUsers)
}
stickyHeader {
val textResId = if (isSearchActive) {
CommonStrings.common_search_results
} else {
R.string.screen_room_member_list_room_members_header_title
}
Text(
modifier = Modifier
.background(ElementTheme.colors.bgCanvasDefault)
.padding(horizontal = 16.dp, vertical = 8.dp)
.fillMaxWidth(),
text = stringResource(textResId),
style = ElementTheme.typography.fontBodyLgMedium,
)
}
items(searchResults, key = { it.userId }) { roomMember ->
val canToggle = canRemoveMember(roomMember.userId)
val trailingContent: @Composable (() -> Unit)? = if (canToggle) {
{
Checkbox(
checked = selectedUsers.any { it.userId == roomMember.userId },
onCheckedChange = { onSelectionToggled(roomMember) },
)
}
} else {
null
}
MatrixUserRow(
modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }),
matrixUser = MatrixUser(
userId = roomMember.userId,
displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl,
),
trailingContent = trailingContent,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun ChangeRolesViewPreview(@PreviewParameter(ChangeRolesStateProvider::class) state: ChangeRolesState) {
ElementPreview {
ChangeRolesView(
state = state,
onBackPressed = {},
)
}
}

View file

@ -9,6 +9,7 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Пры абнаўленні налад апавяшчэнняў адбылася памылка."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Ваш хатні сервер не падтрымлівае гэтую опцыю ў зашыфраваных пакоях, вы можаце не атрымаць апавяшчэнне ў некаторых пакоях."</string>
<string name="screen_polls_history_title">"Апытанні"</string>
<string name="screen_room_change_permissions_everyone">"Усе"</string>
<string name="screen_room_details_add_topic_title">"Дадаць тэму"</string>
<string name="screen_room_details_already_a_member">"Ужо ўдзельнік"</string>
<string name="screen_room_details_already_invited">"Ужо запрасілі"</string>

View file

@ -9,6 +9,29 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Při aktualizaci nastavení oznámení došlo k chybě."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Váš domovský server tuto možnost v zašifrovaných místnostech nepodporuje, v některých místnostech nemusíte být upozorněni."</string>
<string name="screen_polls_history_title">"Hlasování"</string>
<string name="screen_room_change_permissions_administrators">"Pouze správci"</string>
<string name="screen_room_change_permissions_ban_people">"Vykázat lidi"</string>
<string name="screen_room_change_permissions_delete_messages">"Odstranit zprávy"</string>
<string name="screen_room_change_permissions_everyone">"Všichni"</string>
<string name="screen_room_change_permissions_invite_people">"Pozvat lidi"</string>
<string name="screen_room_change_permissions_member_moderation">"Moderování členů"</string>
<string name="screen_room_change_permissions_messages_and_content">"Zprávy a obsah"</string>
<string name="screen_room_change_permissions_moderators">"Správci a moderátoři"</string>
<string name="screen_room_change_permissions_remove_people">"Odebrat lidi"</string>
<string name="screen_room_change_permissions_room_avatar">"Změnit avatar místnosti"</string>
<string name="screen_room_change_permissions_room_details">"Podrobnosti místnosti"</string>
<string name="screen_room_change_permissions_room_name">"Změnit název místnosti"</string>
<string name="screen_room_change_permissions_room_topic">"Změnit téma místnosti"</string>
<string name="screen_room_change_permissions_send_messages">"Odeslat zprávy"</string>
<string name="screen_room_change_role_administrators_title">"Upravit správce"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Tuto akci nebudete moci vrátit zpět. Upravujete oprávnění uživatele, tak aby měl stejnou úroveň jako vy."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Přidat správce?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Degradovat"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Tuto změnu nebudete moci vrátit zpět, protože sami degradujete, pokud jste posledním privilegovaným uživatelem v místnosti, nebude možné znovu získat oprávnění."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Degradovat se?"</string>
<string name="screen_room_change_role_moderators_title">"Upravit moderátory"</string>
<string name="screen_room_change_role_unsaved_changes_description">"Máte neuložené změny."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Uložit změny?"</string>
<string name="screen_room_details_add_topic_title">"Přidat téma"</string>
<string name="screen_room_details_already_a_member">"Již členem"</string>
<string name="screen_room_details_already_invited">"Již pozván(a)"</string>
@ -26,25 +49,31 @@
<string name="screen_room_details_notification_mode_custom">"Vlastní"</string>
<string name="screen_room_details_notification_mode_default">"Výchozí"</string>
<string name="screen_room_details_notification_title">"Oznámení"</string>
<string name="screen_room_details_roles_and_permissions">"Role a oprávnění"</string>
<string name="screen_room_details_room_name_label">"Název místnosti"</string>
<string name="screen_room_details_security_title">"Zabezpečení"</string>
<string name="screen_room_details_share_room_title">"Sdílet místnost"</string>
<string name="screen_room_details_topic_title">"Téma"</string>
<string name="screen_room_details_updating_room">"Aktualizace místnosti…"</string>
<string name="screen_room_member_list_ban_member_confirmation_action">"Vykázat"</string>
<string name="screen_room_member_list_ban_member_confirmation_description">"Nebudou se moci znovu připojit k této místnosti, pokud budou pozváni."</string>
<string name="screen_room_member_list_ban_member_confirmation_title">"Jste si jisti, že chcete vykázat tohoto člena?"</string>
<string name="screen_room_member_list_banned_empty">"V této místnosti nejsou žádní vykázaní uživatelé."</string>
<string name="screen_room_member_list_banning_user">"Vykazování %1$s"</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d osoba"</item>
<item quantity="few">"%1$d osoby"</item>
<item quantity="other">"%1$d osob"</item>
</plurals>
<string name="screen_room_member_list_manage_member_remove">"Odebrat člena"</string>
<string name="screen_room_member_list_manage_member_ban">"Odebrat a vykázat člena"</string>
<string name="screen_room_member_list_manage_member_remove">"Odebrat z místnosti"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Odebrat a vykázat člena"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Pouze odebrat člena"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_title">"Odebrat člena a zakázat mu připojení v budoucnu?"</string>
<string name="screen_room_member_list_manage_member_unban_action">"Zrušit vykázání"</string>
<string name="screen_room_member_list_manage_member_unban_message">"Pokud budou pozváni, budou se moci do této místnosti znovu připojit."</string>
<string name="screen_room_member_list_manage_member_unban_title">"Zrušit vykázání uživatele"</string>
<string name="screen_room_member_list_manage_member_user_info">"Zobrazit informace o uživateli"</string>
<string name="screen_room_member_list_manage_member_user_info">"Zobrazit profil"</string>
<string name="screen_room_member_list_mode_banned">"Vykázaní"</string>
<string name="screen_room_member_list_mode_members">"Členové"</string>
<string name="screen_room_member_list_pending_header_title">"Nevyřízeno"</string>
@ -67,5 +96,19 @@
<string name="screen_room_notification_settings_mode_all_messages">"Všechny zprávy"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Pouze zmínky a klíčová slova"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"V této místnosti mě upozornit na"</string>
<string name="screen_room_roles_and_permissions_admins">"Správci"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Změnit moji roli"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Degradovat na člena"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Degradovat na moderátora"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Moderování členů"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Zprávy a obsah"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderátoři"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Oprávnění"</string>
<string name="screen_room_roles_and_permissions_reset">"Obnovit oprávnění"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Po obnovení oprávnění ztratíte aktuální nastavení."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Obnovit oprávnění?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Role"</string>
<string name="screen_room_roles_and_permissions_room_details">"Podrobnosti místnosti"</string>
<string name="screen_room_roles_and_permissions_title">"Role a oprávnění"</string>
<string name="screen_start_chat_error_starting_chat">"Při pokusu o zahájení chatu došlo k chybě"</string>
</resources>

View file

@ -9,6 +9,15 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Beim Aktualisieren der Benachrichtigungseinstellungen ist ein Fehler aufgetreten."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Dein Homeserver unterstützt diese Option in verschlüsselten Räumen nicht. In einigen Räumen wirst du möglicherweise nicht benachrichtigt."</string>
<string name="screen_polls_history_title">"Umfragen"</string>
<string name="screen_room_change_permissions_everyone">"Alle"</string>
<string name="screen_room_change_permissions_member_moderation">"Moderation der Mitglieder"</string>
<string name="screen_room_change_permissions_messages_and_content">"Nachrichten und Inhalte"</string>
<string name="screen_room_change_permissions_room_details">"Raumdetails"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Du vergibst das selbe Rolle, die auch Du hast. Diese Aktion kann daher nicht mehr rückgängig gemacht werden."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Als Administrator hinzufügen?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Zurückstufen"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Du stufst dich selbst herab. Diese Änderung kann nicht rückgängig gemacht werden. Wenn du der letzte Benutzer mit dieser Rolle bist, ist es nicht möglich, diese Rolle wiederzuerlangen."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Möchtest Du Dich selbst herabstufen?"</string>
<string name="screen_room_details_add_topic_title">"Thema hinzufügen"</string>
<string name="screen_room_details_already_a_member">"Bereits Mitglied"</string>
<string name="screen_room_details_already_invited">"Bereits eingeladen"</string>
@ -67,5 +76,14 @@
<string name="screen_room_notification_settings_mode_all_messages">"Alle Nachrichten"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Nur Erwähnungen und Schlüsselwörter"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Benachrichtige mich in diesem Raum bei"</string>
<string name="screen_room_roles_and_permissions_admins">"Administratoren"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Moderation der Mitglieder"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Nachrichten und Inhalte"</string>
<string name="screen_room_roles_and_permissions_moderators">"Moderatoren"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Berechtigungen"</string>
<string name="screen_room_roles_and_permissions_reset">"Rollen und Berechtigungen zurücksetzen"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Rollen"</string>
<string name="screen_room_roles_and_permissions_room_details">"Raumdetails"</string>
<string name="screen_room_roles_and_permissions_title">"Rollen und Berechtigungen"</string>
<string name="screen_start_chat_error_starting_chat">"Beim Versuch, einen Chat zu starten, ist ein Fehler aufgetreten"</string>
</resources>

View file

@ -9,6 +9,7 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Se ha producido un error al actualizar la configuración de notificaciones."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Tu servidor principal no admite esta opción en salas cifradas, puede que no recibas notificaciones en algunas salas."</string>
<string name="screen_polls_history_title">"Encuestas"</string>
<string name="screen_room_change_permissions_everyone">"Todos"</string>
<string name="screen_room_details_add_topic_title">"Añadir tema"</string>
<string name="screen_room_details_already_a_member">"Ya eres miembro"</string>
<string name="screen_room_details_already_invited">"Ya estás invitado"</string>

View file

@ -9,6 +9,16 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Une erreur sest produite lors de la mise à jour du paramètre de notification."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Votre serveur daccueil ne supporte pas cette option pour les salons chiffrés, vous pourriez ne pas être notifié(e) dans certains salons."</string>
<string name="screen_polls_history_title">"Sondages"</string>
<string name="screen_room_change_permissions_everyone">"Tout le monde"</string>
<string name="screen_room_change_permissions_room_avatar">"Changer lavatar du salon"</string>
<string name="screen_room_change_permissions_room_details">"Détails du salon"</string>
<string name="screen_room_change_permissions_room_name">"Changer le nom du salon"</string>
<string name="screen_room_change_permissions_room_topic">"Changer le sujet du salon"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Vous ne pourrez pas annuler cette action. Vous êtes en train de promouvoir lutilisateur pour quil ait le même niveau que vous."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Ajouter un administrateur ?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Rétrograder"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Vous ne pourrez pas annuler ce changement car vous vous rétrogradez, si vous êtes le dernier utilisateur privilégié du salon il sera impossible de retrouver les privilèges."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Vous rétrograder ?"</string>
<string name="screen_room_details_add_topic_title">"Ajouter un sujet"</string>
<string name="screen_room_details_already_a_member">"Déjà membre"</string>
<string name="screen_room_details_already_invited">"Déjà invité(e)"</string>
@ -26,6 +36,7 @@
<string name="screen_room_details_notification_mode_custom">"Personnalisé"</string>
<string name="screen_room_details_notification_mode_default">"Défaut"</string>
<string name="screen_room_details_notification_title">"Notifications"</string>
<string name="screen_room_details_roles_and_permissions">"Rôles et autorisations"</string>
<string name="screen_room_details_room_name_label">"Nom du salon"</string>
<string name="screen_room_details_security_title">"Sécurité"</string>
<string name="screen_room_details_share_room_title">"Partager le salon"</string>
@ -64,5 +75,11 @@
<string name="screen_room_notification_settings_mode_all_messages">"Tous les messages"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Mentions et mots clés uniquement"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"Dans ce salon, prévenez-moi pour"</string>
<string name="screen_room_roles_and_permissions_admins">"Administrateurs"</string>
<string name="screen_room_roles_and_permissions_moderators">"Modérateurs"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Autorisations"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Rôles"</string>
<string name="screen_room_roles_and_permissions_room_details">"Détails du salon"</string>
<string name="screen_room_roles_and_permissions_title">"Rôles et autorisations"</string>
<string name="screen_start_chat_error_starting_chat">"Une erreur sest produite lors de la tentative de création de la discussion"</string>
</resources>

View file

@ -9,6 +9,8 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Hiba történt az értesítési beállítás frissítésekor."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"A Matrix-kiszolgálója nem támogatja ezt a beállítást a titkosított szobákban, előfordulhat, hogy egyes szobákban nem kap értesítést."</string>
<string name="screen_polls_history_title">"Szavazások"</string>
<string name="screen_room_change_permissions_everyone">"Mindenki"</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Adminisztrátor hozzáadása?"</string>
<string name="screen_room_details_add_topic_title">"Téma hozzáadása"</string>
<string name="screen_room_details_already_a_member">"Már tag"</string>
<string name="screen_room_details_already_invited">"Már meghívták"</string>

View file

@ -9,6 +9,7 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Terjadi kesalahan saat memperbarui pengaturan pemberitahuan."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Homeserver Anda tidak mendukung opsi ini dalam ruangan terenkripsi, Anda mungkin tidak diberi tahu dalam beberapa ruangan."</string>
<string name="screen_polls_history_title">"Pemungutan suara"</string>
<string name="screen_room_change_permissions_everyone">"Semua orang"</string>
<string name="screen_room_details_add_topic_title">"Tambahkan topik"</string>
<string name="screen_room_details_already_a_member">"Sudah menjadi anggota"</string>
<string name="screen_room_details_already_invited">"Sudah diundang"</string>

View file

@ -9,6 +9,7 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"Si è verificato un errore durante l\'aggiornamento delle impostazioni di notifica."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Il tuo homeserver non supporta questa opzione nelle stanze criptate, quindi potresti non ricevere notifiche in alcune stanze."</string>
<string name="screen_polls_history_title">"Sondaggi"</string>
<string name="screen_room_change_permissions_everyone">"Tutti"</string>
<string name="screen_room_details_add_topic_title">"Aggiungi argomento"</string>
<string name="screen_room_details_already_a_member">"Già membro"</string>
<string name="screen_room_details_already_invited">"Già invitato"</string>

View file

@ -9,6 +9,7 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"A apărut o eroare în timpul actualizării setărilor pentru notificari."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Serverul dumneavoastră nu acceptă această opțiune în camerele criptate, este posibil să nu primiți notificări în unele camere."</string>
<string name="screen_polls_history_title">"Sondaje"</string>
<string name="screen_room_change_permissions_everyone">"Toți"</string>
<string name="screen_room_details_add_topic_title">"Adăugare subiect"</string>
<string name="screen_room_details_already_a_member">"Deja membru"</string>
<string name="screen_room_details_already_invited">"Deja invitat"</string>

View file

@ -9,6 +9,29 @@
<string name="screen_notification_settings_edit_failed_updating_default_mode">"При обновлении настроек уведомления произошла ошибка."</string>
<string name="screen_notification_settings_mentions_only_disclaimer">"Ваш домашний сервер не поддерживает эту опцию в зашифрованных комнатах, в некоторых комнатах вы можете не получать уведомления."</string>
<string name="screen_polls_history_title">"Опросы"</string>
<string name="screen_room_change_permissions_administrators">"Только для администраторов"</string>
<string name="screen_room_change_permissions_ban_people">"Заблокировать людей"</string>
<string name="screen_room_change_permissions_delete_messages">"Удалить сообщения"</string>
<string name="screen_room_change_permissions_everyone">"Для всех"</string>
<string name="screen_room_change_permissions_invite_people">"Пригласить людей"</string>
<string name="screen_room_change_permissions_member_moderation">"Модерация участников"</string>
<string name="screen_room_change_permissions_messages_and_content">"Сообщения и содержание"</string>
<string name="screen_room_change_permissions_moderators">"Администраторы и модераторы"</string>
<string name="screen_room_change_permissions_remove_people">"Удалить людей"</string>
<string name="screen_room_change_permissions_room_avatar">"Изменить изображение комнаты"</string>
<string name="screen_room_change_permissions_room_details">"Информация о комнате"</string>
<string name="screen_room_change_permissions_room_name">"Изменить название комнаты"</string>
<string name="screen_room_change_permissions_room_topic">"Сменить тему комнаты"</string>
<string name="screen_room_change_permissions_send_messages">"Отправить сообщение"</string>
<string name="screen_room_change_role_administrators_title">"Редактировать роль администраторов"</string>
<string name="screen_room_change_role_confirm_add_admin_description">"Вы не сможете отменить это действие. Вы устанавливаете уровень пользователю соответствующий вашему."</string>
<string name="screen_room_change_role_confirm_add_admin_title">"Добавить администратора?"</string>
<string name="screen_room_change_role_confirm_demote_self_action">"Понизить уровень"</string>
<string name="screen_room_change_role_confirm_demote_self_description">"Вы не сможете отменить это изменение, так как понижаете себя статус. Если вы являетесь последним привилегированным пользователем в комнате, восстановить привилегии будет невозможно."</string>
<string name="screen_room_change_role_confirm_demote_self_title">"Понизить свой уровень?"</string>
<string name="screen_room_change_role_moderators_title">"Редактировать роль модераторов"</string>
<string name="screen_room_change_role_unsaved_changes_description">"У вас есть несохраненные изменения."</string>
<string name="screen_room_change_role_unsaved_changes_title">"Сохранить изменения?"</string>
<string name="screen_room_details_add_topic_title">"Добавить тему"</string>
<string name="screen_room_details_already_a_member">"Уже зарегистрирован"</string>
<string name="screen_room_details_already_invited">"Уже приглашены"</string>
@ -26,22 +49,31 @@
<string name="screen_room_details_notification_mode_custom">"Пользовательский"</string>
<string name="screen_room_details_notification_mode_default">"По умолчанию"</string>
<string name="screen_room_details_notification_title">"Уведомления"</string>
<string name="screen_room_details_roles_and_permissions">"Роли и разрешения"</string>
<string name="screen_room_details_room_name_label">"Название комнаты"</string>
<string name="screen_room_details_security_title">"Безопасность"</string>
<string name="screen_room_details_share_room_title">"Поделиться комнатой"</string>
<string name="screen_room_details_topic_title">"Тема"</string>
<string name="screen_room_details_updating_room">"Обновление комнаты…"</string>
<string name="screen_room_member_list_ban_member_confirmation_action">"Заблокировать"</string>
<string name="screen_room_member_list_ban_member_confirmation_description">"Они не смогут снова присоединиться к этой комнате, если их пригласят."</string>
<string name="screen_room_member_list_ban_member_confirmation_title">"Вы уверены, что хотите заблокировать этого участника?"</string>
<string name="screen_room_member_list_banned_empty">"В этой комнате нет заблокированных пользователей."</string>
<string name="screen_room_member_list_banning_user">"Блокировка %1$s"</string>
<plurals name="screen_room_member_list_header_title">
<item quantity="one">"%1$d пользователь"</item>
<item quantity="few">"%1$d пользователя"</item>
<item quantity="many">"%1$d пользователей"</item>
</plurals>
<string name="screen_room_member_list_manage_member_remove">"Удалить участника"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Удалить и заблокировать участника"</string>
<string name="screen_room_member_list_manage_member_ban">"Удалить и заблокировать участника"</string>
<string name="screen_room_member_list_manage_member_remove">"Удалить участника из комнаты"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_ban">"Удалить и запретить участника"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_kick">"Только удалить участника"</string>
<string name="screen_room_member_list_manage_member_remove_confirmation_title">"Удалить участника и запретить присоединяться в будущем?"</string>
<string name="screen_room_member_list_manage_member_unban_action">"Разблокировать"</string>
<string name="screen_room_member_list_manage_member_user_info">"Посмотреть информацию о пользователе"</string>
<string name="screen_room_member_list_manage_member_unban_message">"Они снова смогут присоединиться в эту комнату если их пригласят."</string>
<string name="screen_room_member_list_manage_member_unban_title">"Разбанить пользователя?"</string>
<string name="screen_room_member_list_manage_member_user_info">"Посмотреть профиль"</string>
<string name="screen_room_member_list_mode_banned">"Заблокирован"</string>
<string name="screen_room_member_list_mode_members">"Участники"</string>
<string name="screen_room_member_list_pending_header_title">"В ожидании"</string>
@ -49,6 +81,7 @@
<string name="screen_room_member_list_role_administrator">"Администратор"</string>
<string name="screen_room_member_list_role_moderator">"Модератор"</string>
<string name="screen_room_member_list_room_members_header_title">"Участники комнаты"</string>
<string name="screen_room_member_list_unbanning_user">"Разблокировка %1$s"</string>
<string name="screen_room_notification_settings_allow_custom">"Разрешить пользовательские настройки"</string>
<string name="screen_room_notification_settings_allow_custom_footnote">"Включение этого параметра отменяет настройки по умолчанию"</string>
<string name="screen_room_notification_settings_custom_settings_title">"Уведомить меня в этом чате"</string>
@ -63,5 +96,19 @@
<string name="screen_room_notification_settings_mode_all_messages">"Все сообщения"</string>
<string name="screen_room_notification_settings_mode_mentions_and_keywords">"Только упоминания и ключевые слова"</string>
<string name="screen_room_notification_settings_room_custom_settings_title">"В этой комнате уведомить меня о"</string>
<string name="screen_room_roles_and_permissions_admins">"Администраторы"</string>
<string name="screen_room_roles_and_permissions_change_my_role">"Измените мою роль"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_member">"Понижение до участника"</string>
<string name="screen_room_roles_and_permissions_change_role_demote_to_moderator">"Понизить до модератора"</string>
<string name="screen_room_roles_and_permissions_member_moderation">"Модерация участников"</string>
<string name="screen_room_roles_and_permissions_messages_and_content">"Сообщения и содержание"</string>
<string name="screen_room_roles_and_permissions_moderators">"Модераторы"</string>
<string name="screen_room_roles_and_permissions_permissions_header">"Разрешения"</string>
<string name="screen_room_roles_and_permissions_reset">"Сбросить разрешения"</string>
<string name="screen_room_roles_and_permissions_reset_confirm_description">"Как только вы сбросите разрешения, вы потеряете текущие настройки."</string>
<string name="screen_room_roles_and_permissions_reset_confirm_title">"Сбросить разрешения?"</string>
<string name="screen_room_roles_and_permissions_roles_header">"Роли"</string>
<string name="screen_room_roles_and_permissions_room_details">"Информация о комнате"</string>
<string name="screen_room_roles_and_permissions_title">"Роли и разрешения"</string>
<string name="screen_start_chat_error_starting_chat">"Произошла ошибка при попытке открытия комнаты"</string>
</resources>

Some files were not shown because too many files have changed in this diff Show more