From b3423e9cacf6f02c50a6c67be9d77cfde0de98f6 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Fri, 22 Nov 2024 15:07:10 +0000 Subject: [PATCH 01/32] Extract stable memory map so it can store additional datasets (#6876) --- Cargo.lock | 12 ++ Cargo.toml | 1 + backend/canisters/community/CHANGELOG.md | 1 + backend/canisters/community/impl/Cargo.toml | 1 + .../community/impl/src/lifecycle/init.rs | 4 +- .../impl/src/lifecycle/post_upgrade.rs | 4 +- .../canisters/community/impl/src/memory.rs | 6 +- backend/canisters/group/CHANGELOG.md | 1 + backend/canisters/group/impl/Cargo.toml | 1 + .../group/impl/src/lifecycle/init.rs | 4 +- .../group/impl/src/lifecycle/post_upgrade.rs | 4 +- backend/canisters/group/impl/src/memory.rs | 6 +- backend/canisters/user/CHANGELOG.md | 1 + backend/canisters/user/impl/Cargo.toml | 1 + .../canisters/user/impl/src/lifecycle/init.rs | 4 +- .../user/impl/src/lifecycle/post_upgrade.rs | 4 +- backend/canisters/user/impl/src/memory.rs | 6 +- backend/libraries/chat_events/Cargo.toml | 1 + .../libraries/chat_events/src/chat_events.rs | 5 - .../chat_events/src/chat_events_list.rs | 6 +- .../chat_events/src/stable_storage/key.rs | 87 +++++------- .../chat_events/src/stable_storage/mod.rs | 131 +++++------------- .../chat_events/src/stable_storage/tests.rs | 11 +- .../libraries/stable_memory_map/Cargo.toml | 10 ++ .../libraries/stable_memory_map/src/lib.rs | 56 ++++++++ 25 files changed, 188 insertions(+), 180 deletions(-) create mode 100644 backend/libraries/stable_memory_map/Cargo.toml create mode 100644 backend/libraries/stable_memory_map/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 44f288f5fe..0202ffa6c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1369,6 +1369,7 @@ dependencies = [ "serde", "serde_bytes", "sha2 0.10.8", + "stable_memory_map", "test-case", "testing", "tracing", @@ -1627,6 +1628,7 @@ dependencies = [ "serde_bytes", "serde_repr", "stable_memory", + "stable_memory_map", "storage_bucket_client", "timer_job_queues", "tracing", @@ -2857,6 +2859,7 @@ dependencies = [ "serde", "serde_bytes", "stable_memory", + "stable_memory_map", "storage_bucket_client", "timer_job_queues", "tracing", @@ -7437,6 +7440,14 @@ dependencies = [ "ic-stable-structures", ] +[[package]] +name = "stable_memory_map" +version = "0.1.0" +dependencies = [ + "ic-cdk 0.16.0", + "ic-stable-structures", +] + [[package]] name = "stacker" version = "0.1.15" @@ -8500,6 +8511,7 @@ dependencies = [ "sonic_canister", "sonic_canister_c2c_client", "stable_memory", + "stable_memory_map", "storage_bucket_client", "timer_job_queues", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 57380b8a46..ba78081317 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,6 +151,7 @@ members = [ "backend/libraries/search", "backend/libraries/sha256", "backend/libraries/stable_memory", + "backend/libraries/stable_memory_map", "backend/libraries/testing", "backend/libraries/timer_job_queues", "backend/libraries/ts_export", diff --git a/backend/canisters/community/CHANGELOG.md b/backend/canisters/community/CHANGELOG.md index 2966593b00..02a726aa87 100644 --- a/backend/canisters/community/CHANGELOG.md +++ b/backend/canisters/community/CHANGELOG.md @@ -30,6 +30,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Disallow promoting bots to owners ([#6865](https://github.com/open-chat-labs/open-chat/pull/6865)) - Reduce the number of events stored on the heap in the `HybridMap` ([#6867](https://github.com/open-chat-labs/open-chat/pull/6867)) - Return `FailedToDeserialize` and log error if unable to read event ([#6873](https://github.com/open-chat-labs/open-chat/pull/6873)) +- Extract stable memory map so it can store additional datasets ([#6876](https://github.com/open-chat-labs/open-chat/pull/6876)) ### Removed diff --git a/backend/canisters/community/impl/Cargo.toml b/backend/canisters/community/impl/Cargo.toml index 4b79f83255..9f6c59044f 100644 --- a/backend/canisters/community/impl/Cargo.toml +++ b/backend/canisters/community/impl/Cargo.toml @@ -57,6 +57,7 @@ serde = { workspace = true } serde_bytes = { workspace = true } serde_repr = { workspace = true } stable_memory = { path = "../../../libraries/stable_memory" } +stable_memory_map = { path = "../../../libraries/stable_memory_map" } storage_bucket_client = { path = "../../../libraries/storage_bucket_client" } timer_job_queues = { path = "../../../libraries/timer_job_queues" } tracing = { workspace = true } diff --git a/backend/canisters/community/impl/src/lifecycle/init.rs b/backend/canisters/community/impl/src/lifecycle/init.rs index a399cc6dd5..dbb86faf93 100644 --- a/backend/canisters/community/impl/src/lifecycle/init.rs +++ b/backend/canisters/community/impl/src/lifecycle/init.rs @@ -1,5 +1,5 @@ use crate::lifecycle::{init_env, init_state}; -use crate::memory::get_chat_events_memory; +use crate::memory::get_stable_memory_map_memory; use crate::updates::import_group::commit_group_to_import; use crate::{mutate_state, Data}; use canister_tracing_macros::trace; @@ -12,7 +12,7 @@ use utils::env::Environment; #[trace] fn init(args: Args) { canister_logger::init(args.test_mode); - chat_events::ChatEvents::init_stable_storage(get_chat_events_memory()); + stable_memory_map::init(get_stable_memory_map_memory()); let mut env = init_env([0; 32]); diff --git a/backend/canisters/community/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/community/impl/src/lifecycle/post_upgrade.rs index fc59961ff4..99164bcccf 100644 --- a/backend/canisters/community/impl/src/lifecycle/post_upgrade.rs +++ b/backend/canisters/community/impl/src/lifecycle/post_upgrade.rs @@ -1,6 +1,6 @@ use crate::jobs::import_groups::finalize_group_import; use crate::lifecycle::{init_env, init_state}; -use crate::memory::{get_chat_events_memory, get_upgrades_memory}; +use crate::memory::{get_stable_memory_map_memory, get_upgrades_memory}; use crate::{mutate_state, read_state, Data}; use canister_logger::LogEntry; use canister_tracing_macros::trace; @@ -14,7 +14,7 @@ use types::CanisterId; #[post_upgrade] #[trace] fn post_upgrade(args: Args) { - chat_events::ChatEvents::init_stable_storage(get_chat_events_memory()); + stable_memory_map::init(get_stable_memory_map_memory()); let memory = get_upgrades_memory(); let reader = get_reader(&memory); diff --git a/backend/canisters/community/impl/src/memory.rs b/backend/canisters/community/impl/src/memory.rs index e616b97973..fc49f56e3b 100644 --- a/backend/canisters/community/impl/src/memory.rs +++ b/backend/canisters/community/impl/src/memory.rs @@ -6,7 +6,7 @@ use ic_stable_structures::{ const UPGRADES: MemoryId = MemoryId::new(0); const INSTRUCTION_COUNTS_INDEX: MemoryId = MemoryId::new(1); const INSTRUCTION_COUNTS_DATA: MemoryId = MemoryId::new(2); -const CHAT_EVENTS: MemoryId = MemoryId::new(3); +const STABLE_MEMORY_MAP: MemoryId = MemoryId::new(3); pub type Memory = VirtualMemory; @@ -27,8 +27,8 @@ pub fn get_instruction_counts_data_memory() -> Memory { get_memory(INSTRUCTION_COUNTS_DATA) } -pub fn get_chat_events_memory() -> Memory { - get_memory(CHAT_EVENTS) +pub fn get_stable_memory_map_memory() -> Memory { + get_memory(STABLE_MEMORY_MAP) } fn get_memory(id: MemoryId) -> Memory { diff --git a/backend/canisters/group/CHANGELOG.md b/backend/canisters/group/CHANGELOG.md index d095a686de..a920ccf67b 100644 --- a/backend/canisters/group/CHANGELOG.md +++ b/backend/canisters/group/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Disallow promoting bots to owners ([#6865](https://github.com/open-chat-labs/open-chat/pull/6865)) - Reduce the number of events stored on the heap in the `HybridMap` ([#6867](https://github.com/open-chat-labs/open-chat/pull/6867)) - Return `FailedToDeserialize` and log error if unable to read event ([#6873](https://github.com/open-chat-labs/open-chat/pull/6873)) +- Extract stable memory map so it can store additional datasets ([#6876](https://github.com/open-chat-labs/open-chat/pull/6876)) ### Removed diff --git a/backend/canisters/group/impl/Cargo.toml b/backend/canisters/group/impl/Cargo.toml index 396c65e4fc..e08d632efe 100644 --- a/backend/canisters/group/impl/Cargo.toml +++ b/backend/canisters/group/impl/Cargo.toml @@ -53,6 +53,7 @@ search = { path = "../../../libraries/search" } serde = { workspace = true } serde_bytes = { workspace = true } stable_memory = { path = "../../../libraries/stable_memory" } +stable_memory_map = { path = "../../../libraries/stable_memory_map" } storage_bucket_client = { path = "../../../libraries/storage_bucket_client" } timer_job_queues = { path = "../../../libraries/timer_job_queues" } tracing = { workspace = true } diff --git a/backend/canisters/group/impl/src/lifecycle/init.rs b/backend/canisters/group/impl/src/lifecycle/init.rs index 389fb44772..8b7eb911e8 100644 --- a/backend/canisters/group/impl/src/lifecycle/init.rs +++ b/backend/canisters/group/impl/src/lifecycle/init.rs @@ -1,5 +1,5 @@ use crate::lifecycle::{init_env, init_state}; -use crate::memory::get_chat_events_memory; +use crate::memory::get_stable_memory_map_memory; use crate::Data; use canister_tracing_macros::trace; use group_canister::init::Args; @@ -12,7 +12,7 @@ use utils::env::Environment; #[trace] fn init(args: Args) { canister_logger::init(args.test_mode); - chat_events::ChatEvents::init_stable_storage(get_chat_events_memory()); + stable_memory_map::init(get_stable_memory_map_memory()); let mut env = init_env([0; 32]); diff --git a/backend/canisters/group/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/group/impl/src/lifecycle/post_upgrade.rs index 0993d8ded0..9f6ce435cb 100644 --- a/backend/canisters/group/impl/src/lifecycle/post_upgrade.rs +++ b/backend/canisters/group/impl/src/lifecycle/post_upgrade.rs @@ -1,5 +1,5 @@ use crate::lifecycle::{init_env, init_state}; -use crate::memory::{get_chat_events_memory, get_upgrades_memory}; +use crate::memory::{get_stable_memory_map_memory, get_upgrades_memory}; use crate::{mutate_state, read_state, Data}; use canister_logger::LogEntry; use canister_tracing_macros::trace; @@ -13,7 +13,7 @@ use types::CanisterId; #[post_upgrade] #[trace] fn post_upgrade(args: Args) { - chat_events::ChatEvents::init_stable_storage(get_chat_events_memory()); + stable_memory_map::init(get_stable_memory_map_memory()); let memory = get_upgrades_memory(); let reader = get_reader(&memory); diff --git a/backend/canisters/group/impl/src/memory.rs b/backend/canisters/group/impl/src/memory.rs index 15974152cd..11acfd76ae 100644 --- a/backend/canisters/group/impl/src/memory.rs +++ b/backend/canisters/group/impl/src/memory.rs @@ -6,7 +6,7 @@ use ic_stable_structures::{ const UPGRADES: MemoryId = MemoryId::new(0); const INSTRUCTION_COUNTS_INDEX: MemoryId = MemoryId::new(1); const INSTRUCTION_COUNTS_DATA: MemoryId = MemoryId::new(2); -const CHAT_EVENTS: MemoryId = MemoryId::new(3); +const STABLE_MEMORY_MAP: MemoryId = MemoryId::new(3); pub type Memory = VirtualMemory; @@ -27,8 +27,8 @@ pub fn get_instruction_counts_data_memory() -> Memory { get_memory(INSTRUCTION_COUNTS_DATA) } -pub fn get_chat_events_memory() -> Memory { - get_memory(CHAT_EVENTS) +pub fn get_stable_memory_map_memory() -> Memory { + get_memory(STABLE_MEMORY_MAP) } fn get_memory(id: MemoryId) -> Memory { diff --git a/backend/canisters/user/CHANGELOG.md b/backend/canisters/user/CHANGELOG.md index a4b9712455..acd991a0d4 100644 --- a/backend/canisters/user/CHANGELOG.md +++ b/backend/canisters/user/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `bot_api_gateway` canisterId to the canister state ([#6842](https://github.com/open-chat-labs/open-chat/pull/6842)) - Prize message validation takes account of 5% fee ([#6854](https://github.com/open-chat-labs/open-chat/pull/6854)) - Return `FailedToDeserialize` and log error if unable to read event ([#6873](https://github.com/open-chat-labs/open-chat/pull/6873)) +- Extract stable memory map so it can store additional datasets ([#6876](https://github.com/open-chat-labs/open-chat/pull/6876)) ### Removed diff --git a/backend/canisters/user/impl/Cargo.toml b/backend/canisters/user/impl/Cargo.toml index 8ea16e1d02..e2c2bcd004 100644 --- a/backend/canisters/user/impl/Cargo.toml +++ b/backend/canisters/user/impl/Cargo.toml @@ -70,6 +70,7 @@ sns_governance_canister_c2c_client = { path = "../../../external_canisters/sns_g sonic_canister = { path = "../../../external_canisters/sonic/api" } sonic_canister_c2c_client = { path = "../../../external_canisters/sonic/c2c_client" } stable_memory = { path = "../../../libraries/stable_memory" } +stable_memory_map = { path = "../../../libraries/stable_memory_map" } storage_bucket_client = { path = "../../../libraries/storage_bucket_client" } timer_job_queues = { path = "../../../libraries/timer_job_queues" } tracing = { workspace = true } diff --git a/backend/canisters/user/impl/src/lifecycle/init.rs b/backend/canisters/user/impl/src/lifecycle/init.rs index 608137ead0..2f5dcca32a 100644 --- a/backend/canisters/user/impl/src/lifecycle/init.rs +++ b/backend/canisters/user/impl/src/lifecycle/init.rs @@ -1,5 +1,5 @@ use crate::lifecycle::{init_env, init_state}; -use crate::memory::get_chat_events_memory; +use crate::memory::get_stable_memory_map_memory; use crate::{mutate_state, openchat_bot, Data}; use canister_tracing_macros::trace; use ic_cdk::init; @@ -11,7 +11,7 @@ use utils::env::Environment; #[trace] fn init(args: Args) { canister_logger::init(args.test_mode); - chat_events::ChatEvents::init_stable_storage(get_chat_events_memory()); + stable_memory_map::init(get_stable_memory_map_memory()); let env = init_env([0; 32]); let now = env.now(); diff --git a/backend/canisters/user/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/user/impl/src/lifecycle/post_upgrade.rs index 9e96c1d4a4..73d9c0dee6 100644 --- a/backend/canisters/user/impl/src/lifecycle/post_upgrade.rs +++ b/backend/canisters/user/impl/src/lifecycle/post_upgrade.rs @@ -1,5 +1,5 @@ use crate::lifecycle::{init_env, init_state}; -use crate::memory::{get_chat_events_memory, get_upgrades_memory}; +use crate::memory::{get_stable_memory_map_memory, get_upgrades_memory}; use crate::{mutate_state, Data}; use canister_logger::LogEntry; use canister_tracing_macros::trace; @@ -12,7 +12,7 @@ use user_canister::post_upgrade::Args; #[post_upgrade] #[trace] fn post_upgrade(args: Args) { - chat_events::ChatEvents::init_stable_storage(get_chat_events_memory()); + stable_memory_map::init(get_stable_memory_map_memory()); let memory = get_upgrades_memory(); let reader = get_reader(&memory); diff --git a/backend/canisters/user/impl/src/memory.rs b/backend/canisters/user/impl/src/memory.rs index 9b74767c5c..c2f3d831c5 100644 --- a/backend/canisters/user/impl/src/memory.rs +++ b/backend/canisters/user/impl/src/memory.rs @@ -4,7 +4,7 @@ use ic_stable_structures::{ }; const UPGRADES: MemoryId = MemoryId::new(0); -const CHAT_EVENTS: MemoryId = MemoryId::new(3); +const STABLE_MEMORY_MAP: MemoryId = MemoryId::new(3); pub type Memory = VirtualMemory; @@ -17,8 +17,8 @@ pub fn get_upgrades_memory() -> Memory { get_memory(UPGRADES) } -pub fn get_chat_events_memory() -> Memory { - get_memory(CHAT_EVENTS) +pub fn get_stable_memory_map_memory() -> Memory { + get_memory(STABLE_MEMORY_MAP) } fn get_memory(id: MemoryId) -> Memory { diff --git a/backend/libraries/chat_events/Cargo.toml b/backend/libraries/chat_events/Cargo.toml index ec8dd5557a..3da4f9031d 100644 --- a/backend/libraries/chat_events/Cargo.toml +++ b/backend/libraries/chat_events/Cargo.toml @@ -20,6 +20,7 @@ search = { path = "../search" } serde = { workspace = true } serde_bytes = { workspace = true } sha2 = { workspace = true } +stable_memory_map = { path = "../stable_memory_map" } tracing = { workspace = true } types = { path = "../types" } utils = { path = "../utils" } diff --git a/backend/libraries/chat_events/src/chat_events.rs b/backend/libraries/chat_events/src/chat_events.rs index 1cb26c4bf7..055f92030b 100644 --- a/backend/libraries/chat_events/src/chat_events.rs +++ b/backend/libraries/chat_events/src/chat_events.rs @@ -4,7 +4,6 @@ use crate::last_updated_timestamps::LastUpdatedTimestamps; use crate::metrics::{ChatMetricsInternal, MetricKey}; use crate::search_index::SearchIndex; use crate::stable_storage::key::KeyPrefix; -use crate::stable_storage::Memory; use crate::*; use event_store_producer::{EventBuilder, EventStoreClient, Runtime}; use rand::rngs::StdRng; @@ -78,10 +77,6 @@ impl ChatEvents { false } - pub fn init_stable_storage(memory: Memory) { - stable_storage::init(memory) - } - pub fn init_maps(&mut self) { self.main.init_hybrid_map(self.chat, None); for (message_index, thread) in self.threads.iter_mut() { diff --git a/backend/libraries/chat_events/src/chat_events_list.rs b/backend/libraries/chat_events/src/chat_events_list.rs index c1cb0f84dc..d7fa06ef68 100644 --- a/backend/libraries/chat_events/src/chat_events_list.rs +++ b/backend/libraries/chat_events/src/chat_events_list.rs @@ -611,6 +611,8 @@ mod tests { use crate::{ChatEvents, MessageContentInternal, PushMessageArgs, TextContentInternal}; use candid::Principal; use event_store_producer::NullRuntime; + use ic_stable_structures::memory_manager::{MemoryId, MemoryManager}; + use ic_stable_structures::DefaultMemoryImpl; use rand::random; use std::mem::size_of; use types::{EventsTimeToLiveUpdated, Milliseconds}; @@ -769,7 +771,9 @@ mod tests { } fn setup_events(events_ttl: Option) -> ChatEvents { - ChatEvents::init_stable_storage(ic_stable_structures::VectorMemory::default()); + let memory = MemoryManager::init(DefaultMemoryImpl::default()); + stable_memory_map::init(memory.get(MemoryId::new(1))); + let mut events = ChatEvents::new_direct_chat(Principal::from_slice(&[1]).into(), events_ttl, random(), 1); push_events(&mut events, 0); diff --git a/backend/libraries/chat_events/src/stable_storage/key.rs b/backend/libraries/chat_events/src/stable_storage/key.rs index 9b963996f0..3c0e8b9c87 100644 --- a/backend/libraries/chat_events/src/stable_storage/key.rs +++ b/backend/libraries/chat_events/src/stable_storage/key.rs @@ -2,6 +2,7 @@ use candid::Principal; use ic_stable_structures::storable::Bound; use ic_stable_structures::Storable; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use stable_memory_map::KeyType; use std::borrow::Cow; use types::{CanisterId, ChannelId, Chat, EventIndex, MessageIndex, UserId}; @@ -21,31 +22,6 @@ pub enum KeyPrefix { ChannelThread(ChannelThreadKeyPrefix), } -#[derive(Copy, Clone)] -#[repr(u8)] -enum KeyType { - DirectChat = 1, - GroupChat = 2, - Channel = 3, - DirectChatThread = 4, - GroupChatThread = 5, - ChannelThread = 6, -} - -impl From for KeyType { - fn from(value: u8) -> Self { - match value { - 1 => KeyType::DirectChat, - 2 => KeyType::GroupChat, - 3 => KeyType::Channel, - 4 => KeyType::DirectChatThread, - 5 => KeyType::GroupChatThread, - 6 => KeyType::ChannelThread, - _ => unreachable!(), - } - } -} - impl Key { pub fn new(prefix: KeyPrefix, event_index: EventIndex) -> Key { Key { prefix, event_index } @@ -66,6 +42,10 @@ impl Key { pub fn matches_chat(&self, chat: Chat) -> bool { self.prefix.matches_chat(chat) } + + pub fn key_type(&self) -> KeyType { + self.prefix.key_type() + } } impl KeyPrefix { @@ -102,7 +82,7 @@ impl KeyPrefix { } } - fn key_type(&self) -> KeyType { + pub fn key_type(&self) -> KeyType { match self { KeyPrefix::DirectChat(_) => KeyType::DirectChat, KeyPrefix::GroupChat(_) => KeyType::GroupChat, @@ -114,28 +94,29 @@ impl KeyPrefix { } } -impl Storable for Key { - fn to_bytes(&self) -> Cow<[u8]> { - let mut bytes = self.prefix.to_bytes().to_vec(); +impl Key { + pub fn to_vec(&self) -> Vec { + let mut bytes = self.prefix.to_vec(); bytes.extend_from_slice(&u32::from(self.event_index).to_be_bytes()); - Cow::Owned(bytes) + bytes } +} - fn from_bytes(bytes: Cow<[u8]>) -> Self { +impl TryFrom<&[u8]> for Key { + type Error = (); + + fn try_from(bytes: &[u8]) -> Result { let len = bytes.len(); - let prefix = KeyPrefix::from_bytes(Cow::Borrowed(&bytes[..len - 4])); + let prefix = KeyPrefix::try_from(&bytes[..len - 4])?; let event_index = u32::from_be_bytes(bytes[(len - 4)..].try_into().unwrap()).into(); - Key { prefix, event_index } + Ok(Key { prefix, event_index }) } - - const BOUND: Bound = Bound::Unbounded; } -impl Storable for KeyPrefix { - fn to_bytes(&self) -> Cow<[u8]> { +impl KeyPrefix { + pub fn to_vec(&self) -> Vec { let mut bytes = Vec::new(); bytes.push(self.key_type() as u8); - bytes.extend_from_slice( match self { KeyPrefix::DirectChat(k) => k.to_bytes(), @@ -147,25 +128,27 @@ impl Storable for KeyPrefix { } .as_ref(), ); - - Cow::Owned(bytes) + bytes } +} - fn from_bytes(bytes: Cow<[u8]>) -> Self { +impl TryFrom<&[u8]> for KeyPrefix { + type Error = (); + + fn try_from(bytes: &[u8]) -> Result { let key_type = KeyType::from(bytes[0]); - let bytes = Cow::Borrowed(&bytes.as_ref()[1..]); + let bytes = Cow::Borrowed(&bytes[1..]); match key_type { - KeyType::DirectChat => KeyPrefix::DirectChat(DirectChatKeyPrefix::from_bytes(bytes)), - KeyType::GroupChat => KeyPrefix::GroupChat(GroupChatKeyPrefix::from_bytes(bytes)), - KeyType::Channel => KeyPrefix::Channel(ChannelKeyPrefix::from_bytes(bytes)), - KeyType::DirectChatThread => KeyPrefix::DirectChatThread(DirectChatThreadKeyPrefix::from_bytes(bytes)), - KeyType::GroupChatThread => KeyPrefix::GroupChatThread(GroupChatThreadKeyPrefix::from_bytes(bytes)), - KeyType::ChannelThread => KeyPrefix::ChannelThread(ChannelThreadKeyPrefix::from_bytes(bytes)), + KeyType::DirectChat => Ok(KeyPrefix::DirectChat(DirectChatKeyPrefix::from_bytes(bytes))), + KeyType::GroupChat => Ok(KeyPrefix::GroupChat(GroupChatKeyPrefix::from_bytes(bytes))), + KeyType::Channel => Ok(KeyPrefix::Channel(ChannelKeyPrefix::from_bytes(bytes))), + KeyType::DirectChatThread => Ok(KeyPrefix::DirectChatThread(DirectChatThreadKeyPrefix::from_bytes(bytes))), + KeyType::GroupChatThread => Ok(KeyPrefix::GroupChatThread(GroupChatThreadKeyPrefix::from_bytes(bytes))), + KeyType::ChannelThread => Ok(KeyPrefix::ChannelThread(ChannelThreadKeyPrefix::from_bytes(bytes))), + _ => Err(()), } } - - const BOUND: Bound = Bound::Unbounded; } #[derive(Clone, Eq, PartialEq, Ord, PartialOrd)] @@ -341,7 +324,7 @@ impl Serialize for KeyPrefix { where S: Serializer, { - let bytes = self.to_bytes(); + let bytes = self.to_vec(); serializer.serialize_bytes(&bytes) } } @@ -352,6 +335,6 @@ impl<'de> Deserialize<'de> for KeyPrefix { D: Deserializer<'de>, { let bytes: Vec = Vec::deserialize(deserializer)?; - Ok(KeyPrefix::from_bytes(Cow::Owned(bytes))) + Ok(KeyPrefix::try_from(bytes.as_slice()).unwrap()) } } diff --git a/backend/libraries/chat_events/src/stable_storage/mod.rs b/backend/libraries/chat_events/src/stable_storage/mod.rs index b0abdc31b0..82d69999f2 100644 --- a/backend/libraries/chat_events/src/stable_storage/mod.rs +++ b/backend/libraries/chat_events/src/stable_storage/mod.rs @@ -1,11 +1,8 @@ use crate::stable_storage::key::{Key, KeyPrefix}; use crate::{ChatEventInternal, EventsMap}; -use ic_stable_structures::storable::Bound; -use ic_stable_structures::{StableBTreeMap, Storable}; use serde::{Deserialize, Serialize}; use serde_bytes::ByteBuf; -use std::borrow::Cow; -use std::cell::RefCell; +use stable_memory_map::{with_map, with_map_mut}; use std::cmp::min; use std::collections::VecDeque; use std::ops::RangeBounds; @@ -18,26 +15,6 @@ pub mod key; #[cfg(test)] mod tests; -#[cfg(not(test))] -pub type Memory = ic_stable_structures::memory_manager::VirtualMemory; - -#[cfg(test)] -pub type Memory = ic_stable_structures::VectorMemory; - -struct ChatEventsStableStorageInner { - map: StableBTreeMap, -} - -struct Value(Vec); - -thread_local! { - static MAP: RefCell> = RefCell::default(); -} - -pub fn init(memory: Memory) { - MAP.set(Some(ChatEventsStableStorageInner::init(memory))); -} - pub fn garbage_collect(prefix: KeyPrefix) -> Result { let start = Key::new(prefix.clone(), EventIndex::default()); let mut total_count = 0; @@ -45,8 +22,8 @@ pub fn garbage_collect(prefix: KeyPrefix) -> Result { // If < 1B instructions have been used so far, delete another 100 keys, or exit if complete while ic_cdk::api::instruction_counter() < 1_000_000_000 { let keys: Vec<_> = m - .range(&start..) - .map(|(k, _)| k) + .range(start.to_vec()..) + .map_while(|(k, _)| Key::try_from(k.as_slice()).ok()) .take_while(|k| *k.prefix() == prefix) .take(100) .collect(); @@ -54,7 +31,7 @@ pub fn garbage_collect(prefix: KeyPrefix) -> Result { let batch_count = keys.len() as u32; total_count += batch_count; for key in keys { - m.remove(&key); + m.remove(&key.to_vec()); } // If batch count < 100 then we are finished if batch_count < 100 { @@ -76,15 +53,16 @@ pub fn read_events_as_bytes(chat: Chat, after: Option, max_bytes: }; with_map(|m| { let mut total_bytes = 0; - m.range(key..) + m.range(key.to_vec()..) + .map_while(|(k, v)| Key::try_from(k.as_slice()).ok().map(|k| (k, v))) .take_while(|(k, v)| { if !k.matches_chat(chat) { return false; } - total_bytes += v.0.len(); + total_bytes += v.len(); total_bytes < max_bytes }) - .map(|(k, v)| (EventContext::new(k.thread_root_message_index(), k.event_index()), v.0.into())) + .map(|(k, v)| (EventContext::new(k.thread_root_message_index(), k.event_index()), v.into())) .collect() }) } @@ -93,31 +71,15 @@ pub fn write_events_as_bytes(chat: Chat, events: Vec<(EventContext, ByteBuf)>) { with_map_mut(|m| { for (context, bytes) in events { let prefix = KeyPrefix::new(chat, context.thread_root_message_index); - let key = Key::new(prefix, context.event_index); - let value = Value(bytes.into_vec()); + let key = Key::new(prefix, context.event_index).to_vec(); + let value = bytes.into_vec(); // Check the event is valid. We could remove this once we're more confident - let _ = EventWrapperInternal::from(&value); + let _ = bytes_to_event(&value); m.insert(key, value); } }); } -impl ChatEventsStableStorageInner { - fn init(memory: Memory) -> ChatEventsStableStorageInner { - ChatEventsStableStorageInner { - map: StableBTreeMap::init(memory), - } - } -} - -fn with_map) -> R, R>(f: F) -> R { - MAP.with_borrow(|m| f(&m.as_ref().unwrap().map)) -} - -fn with_map_mut) -> R, R>(f: F) -> R { - MAP.with_borrow_mut(|m| f(&mut m.as_mut().unwrap().map)) -} - #[derive(Serialize, Deserialize)] pub struct ChatEventsStableStorage { prefix: KeyPrefix, @@ -155,9 +117,9 @@ impl ChatEventsStableStorage { Iter::new(prefix, start, end) } - fn get_internal(&self, event_index: EventIndex) -> Option { + fn get_internal(&self, event_index: EventIndex) -> Option> { let key = self.key(event_index); - with_map(|m| m.get(&key)) + with_map(|m| m.get(&key.to_vec())) } } @@ -167,17 +129,17 @@ impl EventsMap for ChatEventsStableStorage { } fn get(&self, event_index: EventIndex) -> Option> { - self.get_internal(event_index).map(|v| (&v).into()) + self.get_internal(event_index).map(|v| bytes_to_event(&v)) } fn insert(&mut self, event: EventWrapperInternal) { let key = self.key(event.index); - with_map_mut(|m| m.insert(key, event.into())); + with_map_mut(|m| m.insert(key.to_vec(), event_to_bytes(event))); } fn remove(&mut self, event_index: EventIndex) -> Option> { let key = self.key(event_index); - with_map_mut(|m| m.remove(&key)).map(|v| (&v).into()) + with_map_mut(|m| m.remove(&key.to_vec())).map(|v| bytes_to_event(&v)) } fn range>( @@ -196,42 +158,27 @@ impl EventsMap for ChatEventsStableStorage { } } -impl Storable for Value { - fn to_bytes(&self) -> Cow<[u8]> { - Cow::Borrowed(&self.0) - } - - fn from_bytes(bytes: Cow<[u8]>) -> Self { - Value(bytes.to_vec()) - } - - const BOUND: Bound = Bound::Unbounded; +fn event_to_bytes(value: EventWrapperInternal) -> Vec { + msgpack::serialize_then_unwrap(&value) } -impl From<&Value> for EventWrapperInternal { - fn from(value: &Value) -> Self { - match msgpack::deserialize(value.0.as_slice()) { - Ok(result) => result, - Err(error) => { - ic_cdk::eprintln!("Failed to deserialize event from stable memory: {error:?}"); - match msgpack::deserialize::(value.0.as_slice()) { - Ok(fallback) => fallback.into(), - Err(fallback_error) => { - panic!("Failed to deserialize event from stable memory. Error: {error:?}. Fallback error: {fallback_error:?}"); - } +fn bytes_to_event(bytes: &[u8]) -> EventWrapperInternal { + match msgpack::deserialize(bytes) { + Ok(result) => result, + Err(error) => { + ic_cdk::eprintln!("Failed to deserialize event from stable memory: {error:?}"); + match msgpack::deserialize::(bytes) { + Ok(fallback) => fallback.into(), + Err(fallback_error) => { + panic!( + "Failed to deserialize event from stable memory. Error: {error:?}. Fallback error: {fallback_error:?}" + ); } } } } } -impl From> for Value { - fn from(value: EventWrapperInternal) -> Self { - let bytes = msgpack::serialize_then_unwrap(&value); - Value(bytes) - } -} - const DEFAULT_BUFFER_SIZE: usize = 20; const MAX_BUFFER_SIZE: usize = 1000; @@ -241,7 +188,7 @@ struct Iter { next_back: EventIndex, is_forward_buffer: bool, next_buffer_size: usize, - buffer: VecDeque<(EventIndex, Value)>, + buffer: VecDeque<(EventIndex, Vec)>, finished: bool, } @@ -278,10 +225,6 @@ impl Iter { Key::new(self.prefix.clone(), self.next_back) } - fn range_bounds(&self) -> impl RangeBounds { - self.next_key()..=self.next_back_key() - } - fn check_buffer_direction(&mut self, forward: bool) { if self.is_forward_buffer == forward { self.buffer.clear(); @@ -299,18 +242,18 @@ impl Iterator for EventIter { type Item = EventWrapperInternal; fn next(&mut self) -> Option { - self.iter.next().map(|(_, v)| (&v).into()) + self.iter.next().map(|(_, v)| bytes_to_event(&v)) } } impl DoubleEndedIterator for EventIter { fn next_back(&mut self) -> Option { - self.iter.next_back().map(|(_, v)| (&v).into()) + self.iter.next_back().map(|(_, v)| bytes_to_event(&v)) } } impl Iterator for Iter { - type Item = (EventIndex, Value); + type Item = (EventIndex, Vec); fn next(&mut self) -> Option { if self.finished { @@ -319,9 +262,9 @@ impl Iterator for Iter { self.check_buffer_direction(true); if self.buffer.is_empty() { self.buffer = with_map(|m| { - m.range(self.range_bounds()) + m.range(self.next_key().to_vec()..=self.next_back_key().to_vec()) + .map_while(|(k, v)| Key::try_from(k.as_slice()).ok().map(|k| (k.event_index(), v))) .take(self.next_buffer_size) - .map(|(k, v)| (k.event_index(), v)) .collect() }); self.next_buffer_size = min(self.next_buffer_size * 2, MAX_BUFFER_SIZE); @@ -344,10 +287,10 @@ impl DoubleEndedIterator for Iter { self.check_buffer_direction(false); if self.buffer.is_empty() { self.buffer = with_map(|m| { - m.range(self.range_bounds()) + m.range(self.next_key().to_vec()..=self.next_back_key().to_vec()) .rev() + .map_while(|(k, v)| Key::try_from(k.as_slice()).ok().map(|k| (k.event_index(), v))) .take(self.next_buffer_size) - .map(|(k, v)| (k.event_index(), v)) .collect() }); self.next_buffer_size = min(self.next_buffer_size * 2, MAX_BUFFER_SIZE); diff --git a/backend/libraries/chat_events/src/stable_storage/tests.rs b/backend/libraries/chat_events/src/stable_storage/tests.rs index 7cd4e81ef2..20d6ebb1a8 100644 --- a/backend/libraries/chat_events/src/stable_storage/tests.rs +++ b/backend/libraries/chat_events/src/stable_storage/tests.rs @@ -3,7 +3,7 @@ use crate::stable_storage::tests::test_values::{ AUDIO1, CRYPTO1, CUSTOM1, DELETED1, FILE1, GIPHY1, GOVERNANCE_PROPOSAL1, IMAGE1, MESSAGE_REMINDER1, MESSAGE_REMINDER_CREATED1, P2P_SWAP1, POLL1, PRIZE1, PRIZE_WINNER1, REPORTED_MESSAGE1, TEXT1, VIDEO1, VIDEO_CALL1, }; -use crate::stable_storage::Value; +use crate::stable_storage::{bytes_to_event, event_to_bytes}; use crate::{ AudioContentInternal, BlobReferenceInternal, CallParticipantInternal, ChatEventInternal, ChatInternal, CompletedCryptoTransactionInternal, CryptoContentInternal, CustomContentInternal, DeletedByInternal, FileContentInternal, @@ -12,9 +12,7 @@ use crate::{ PollContentInternal, PrizeContentInternal, PrizeWinnerContentInternal, ProposalContentInternal, ReplyContextInternal, ReportedMessageInternal, TextContentInternal, ThreadSummaryInternal, VideoCallContentInternal, VideoContentInternal, }; -use ic_stable_structures::Storable; use rand::random; -use std::borrow::Cow; use testing::rng::{random_from_principal, random_from_u128, random_from_u32, random_principal, random_string}; use types::{ Cryptocurrency, EventIndex, EventWrapperInternal, MessageReport, P2PSwapCompleted, P2PSwapStatus, Proposal, @@ -430,7 +428,7 @@ fn custom_content() { } fn test_deserialization(bytes: &[u8]) -> MessageContentInternal { - let value = EventWrapperInternal::from(&Value::from_bytes(Cow::Borrowed(bytes))); + let value = bytes_to_event(bytes); assert!(value.index > EventIndex::default()); if let ChatEventInternal::Message(m) = value.event { m.content @@ -440,10 +438,10 @@ fn test_deserialization(bytes: &[u8]) -> MessageContentInternal { } fn generate_then_serialize_value(content: MessageContentInternal) -> Vec { - generate_value(content).to_bytes().to_vec() + event_to_bytes(generate_value(content)) } -fn generate_value(content: MessageContentInternal) -> Value { +fn generate_value(content: MessageContentInternal) -> EventWrapperInternal { EventWrapperInternal { index: random_from_u32(), timestamp: random(), @@ -490,5 +488,4 @@ fn generate_value(content: MessageContentInternal) -> Value { block_level_markdown: true, })), } - .into() } diff --git a/backend/libraries/stable_memory_map/Cargo.toml b/backend/libraries/stable_memory_map/Cargo.toml new file mode 100644 index 0000000000..fd0b3d5455 --- /dev/null +++ b/backend/libraries/stable_memory_map/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "stable_memory_map" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +ic-cdk = { workspace = true } +ic-stable-structures = { workspace = true } diff --git a/backend/libraries/stable_memory_map/src/lib.rs b/backend/libraries/stable_memory_map/src/lib.rs new file mode 100644 index 0000000000..b7f43351df --- /dev/null +++ b/backend/libraries/stable_memory_map/src/lib.rs @@ -0,0 +1,56 @@ +use ic_stable_structures::memory_manager::VirtualMemory; +use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap}; +use std::cell::RefCell; + +pub type Memory = VirtualMemory; + +struct StableMemoryMap { + map: StableBTreeMap, Vec, Memory>, +} + +thread_local! { + static MAP: RefCell> = RefCell::default(); +} + +pub fn init(memory: Memory) { + MAP.set(Some(StableMemoryMap { + map: StableBTreeMap::init(memory), + })); +} + +pub fn with_map, Vec, Memory>) -> R, R>(f: F) -> R { + MAP.with_borrow(|m| f(&m.as_ref().unwrap().map)) +} + +pub fn with_map_mut, Vec, Memory>) -> R, R>(f: F) -> R { + MAP.with_borrow_mut(|m| f(&mut m.as_mut().unwrap().map)) +} + +#[derive(Copy, Clone)] +#[repr(u8)] +pub enum KeyType { + DirectChat = 1, + GroupChat = 2, + Channel = 3, + DirectChatThread = 4, + GroupChatThread = 5, + ChannelThread = 6, + ChatMember = 7, + CommunityMember = 8, +} + +impl From for KeyType { + fn from(value: u8) -> Self { + match value { + 1 => KeyType::DirectChat, + 2 => KeyType::GroupChat, + 3 => KeyType::Channel, + 4 => KeyType::DirectChatThread, + 5 => KeyType::GroupChatThread, + 6 => KeyType::ChannelThread, + 7 => KeyType::ChatMember, + 8 => KeyType::CommunityMember, + _ => unreachable!(), + } + } +} From 2247e3700af7f927ac4b56c06769270a36f25b12 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Fri, 22 Nov 2024 16:09:45 +0000 Subject: [PATCH 02/32] Avoid iterating all users when determining who to notify of new message (#6877) --- backend/canisters/community/CHANGELOG.md | 1 + .../community/impl/src/model/channels.rs | 19 ++-- .../impl/src/updates/add_reaction.rs | 2 +- .../src/updates/c2c_set_user_suspended.rs | 12 ++- .../impl/src/updates/report_message.rs | 2 +- .../impl/src/updates/send_message.rs | 16 +--- backend/canisters/group/CHANGELOG.md | 1 + backend/canisters/group/impl/src/lib.rs | 10 +- .../group/impl/src/queries/summary_updates.rs | 2 +- .../group/impl/src/updates/accept_p2p_swap.rs | 2 +- .../group/impl/src/updates/add_reaction.rs | 2 +- .../src/updates/c2c_set_user_suspended.rs | 14 +-- .../c2c_start_import_into_community.rs | 2 +- .../group/impl/src/updates/cancel_invites.rs | 2 +- .../group/impl/src/updates/claim_prize.rs | 2 +- .../src/updates/convert_into_community.rs | 2 +- .../impl/src/updates/disable_invite_code.rs | 2 +- .../group/impl/src/updates/edit_message.rs | 2 +- .../impl/src/updates/enable_invite_code.rs | 2 +- .../impl/src/updates/register_poll_vote.rs | 2 +- .../src/updates/register_proposal_vote.rs | 2 +- .../src/updates/register_proposal_vote_v2.rs | 2 +- .../impl/src/updates/remove_participant.rs | 2 +- .../group/impl/src/updates/report_message.rs | 2 +- .../group/impl/src/updates/send_message.rs | 2 +- .../src/updates/toggle_mute_notifications.rs | 15 +-- .../group/impl/src/updates/unblock_user.rs | 2 +- .../impl/src/updates/undelete_messages.rs | 2 +- .../integration_tests/src/mentions_tests.rs | 51 ++++++++++ .../src/notification_tests.rs | 54 ++++++++++- backend/libraries/group_chat_core/src/lib.rs | 88 ++++++++--------- .../libraries/group_chat_core/src/members.rs | 96 ++++++++++++++++++- .../group_chat_core/src/members/proptests.rs | 24 +++++ 33 files changed, 325 insertions(+), 116 deletions(-) diff --git a/backend/canisters/community/CHANGELOG.md b/backend/canisters/community/CHANGELOG.md index 02a726aa87..146970a7f5 100644 --- a/backend/canisters/community/CHANGELOG.md +++ b/backend/canisters/community/CHANGELOG.md @@ -31,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Reduce the number of events stored on the heap in the `HybridMap` ([#6867](https://github.com/open-chat-labs/open-chat/pull/6867)) - Return `FailedToDeserialize` and log error if unable to read event ([#6873](https://github.com/open-chat-labs/open-chat/pull/6873)) - Extract stable memory map so it can store additional datasets ([#6876](https://github.com/open-chat-labs/open-chat/pull/6876)) +- Avoid iterating all users when determining who to notify of new message ([#6877](https://github.com/open-chat-labs/open-chat/pull/6877)) ### Removed diff --git a/backend/canisters/community/impl/src/model/channels.rs b/backend/canisters/community/impl/src/model/channels.rs index 8314784206..1874d7b01b 100644 --- a/backend/canisters/community/impl/src/model/channels.rs +++ b/backend/canisters/community/impl/src/model/channels.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use types::{ ChannelId, ChannelMatch, CommunityCanisterChannelSummary, CommunityCanisterChannelSummaryUpdates, CommunityId, GroupMembership, GroupMembershipUpdates, GroupPermissionRole, GroupPermissions, MultiUserChat, Rules, TimestampMillis, - Timestamped, UserId, UserType, MAX_THREADS_IN_SUMMARY, + UserId, UserType, MAX_THREADS_IN_SUMMARY, }; use super::members::CommunityMembers; @@ -268,7 +268,7 @@ impl Channel { joined: m.date_added(), role: m.role().value.into(), mentions: chat.most_recent_mentions(m, None), - notifications_muted: m.notifications_muted.value, + notifications_muted: m.notifications_muted().value, my_metrics: chat .events .user_metrics(&m.user_id(), None) @@ -356,7 +356,7 @@ impl Channel { let membership = member.map(|m| GroupMembershipUpdates { role: updates.role_changed.then_some(m.role().value.into()), mentions: updates.mentions, - notifications_muted: m.notifications_muted.if_set_after(since).cloned(), + notifications_muted: m.notifications_muted().if_set_after(since).cloned(), my_metrics: self.chat.events.user_metrics(&m.user_id(), Some(since)).map(|m| m.hydrate()), latest_threads: m .followed_threads @@ -404,15 +404,10 @@ impl Channel { pub fn mute_notifications(&mut self, mute: bool, user_id: UserId, now: TimestampMillis) -> MuteChannelResult { use MuteChannelResult::*; - if let Some(channel_member) = self.chat.members.get_mut(&user_id) { - if channel_member.notifications_muted.value != mute { - channel_member.notifications_muted = Timestamped::new(mute, now); - Success - } else { - Unchanged - } - } else { - UserNotFound + match self.chat.members.toggle_notifications_muted(user_id, mute, now) { + Some(true) => Success, + Some(false) => Unchanged, + None => UserNotFound, } } diff --git a/backend/canisters/community/impl/src/updates/add_reaction.rs b/backend/canisters/community/impl/src/updates/add_reaction.rs index 50835b1290..c50a993b1c 100644 --- a/backend/canisters/community/impl/src/updates/add_reaction.rs +++ b/backend/canisters/community/impl/src/updates/add_reaction.rs @@ -69,7 +69,7 @@ fn add_reaction_impl(args: Args, state: &mut RuntimeState) -> Response { .chat .members .get(&message.sender) - .map_or(true, |m| m.notifications_muted.value || m.suspended.value); + .map_or(true, |m| m.notifications_muted().value || m.suspended().value); if !notifications_muted { let notification = Notification::ChannelReactionAdded(ChannelReactionAddedNotification { diff --git a/backend/canisters/community/impl/src/updates/c2c_set_user_suspended.rs b/backend/canisters/community/impl/src/updates/c2c_set_user_suspended.rs index 9a791b8a46..0c4df3ab07 100644 --- a/backend/canisters/community/impl/src/updates/c2c_set_user_suspended.rs +++ b/backend/canisters/community/impl/src/updates/c2c_set_user_suspended.rs @@ -14,10 +14,16 @@ fn c2c_set_user_suspended(args: Args) -> Response { } fn c2c_set_user_suspended_impl(args: Args, state: &mut RuntimeState) -> Response { - if let Some(user) = state.data.members.get_by_user_id_mut(&args.user_id) { - if user.suspended.value != args.suspended { + if let Some(member) = state.data.members.get_by_user_id_mut(&args.user_id) { + if member.suspended.value != args.suspended { let now = state.env.now(); - user.suspended = Timestamped::new(args.suspended, now); + member.suspended = Timestamped::new(args.suspended, now); + + for channel_id in member.channels.iter() { + if let Some(channel) = state.data.channels.get_mut(channel_id) { + channel.chat.members.set_suspended(member.user_id, args.suspended, now); + } + } } Success } else { diff --git a/backend/canisters/community/impl/src/updates/report_message.rs b/backend/canisters/community/impl/src/updates/report_message.rs index 59aad0844f..9b4f9331a6 100644 --- a/backend/canisters/community/impl/src/updates/report_message.rs +++ b/backend/canisters/community/impl/src/updates/report_message.rs @@ -62,7 +62,7 @@ fn build_c2c_args(args: &Args, state: &RuntimeState) -> Result<(c2c_report_messa return Err(UserNotInChannel); }; - if channel_member.suspended.value { + if channel_member.suspended().value { return Err(UserSuspended); } else if channel_member.lapsed().value { return Err(UserLapsed); diff --git a/backend/canisters/community/impl/src/updates/send_message.rs b/backend/canisters/community/impl/src/updates/send_message.rs index 3213e40813..2d6d8d8d3b 100644 --- a/backend/canisters/community/impl/src/updates/send_message.rs +++ b/backend/canisters/community/impl/src/updates/send_message.rs @@ -214,20 +214,6 @@ fn process_send_message_result( let message_index = message_event.event.message_index; let message_id = message_event.event.message_id; let expires_at = message_event.expires_at; - - // Exclude suspended members and bots from notification - let users_to_notify: Vec = result - .users_to_notify - .into_iter() - .filter(|u| { - state - .data - .members - .get_by_user_id(u) - .map_or(false, |m| !m.suspended.value && !m.user_type.is_bot()) - }) - .collect(); - let content = &message_event.event.content; let community_id = state.env.canister_id().into(); let sender_is_human = state @@ -255,7 +241,7 @@ fn process_send_message_result( channel_avatar_id, crypto_transfer: content.notification_crypto_transfer_details(&users_mentioned.mentioned_directly), }); - state.push_notification(users_to_notify, notification); + state.push_notification(result.users_to_notify, notification); register_timer_jobs(channel_id, thread_root_message_index, message_event, now, &mut state.data); diff --git a/backend/canisters/group/CHANGELOG.md b/backend/canisters/group/CHANGELOG.md index a920ccf67b..91fa1641e5 100644 --- a/backend/canisters/group/CHANGELOG.md +++ b/backend/canisters/group/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Reduce the number of events stored on the heap in the `HybridMap` ([#6867](https://github.com/open-chat-labs/open-chat/pull/6867)) - Return `FailedToDeserialize` and log error if unable to read event ([#6873](https://github.com/open-chat-labs/open-chat/pull/6873)) - Extract stable memory map so it can store additional datasets ([#6876](https://github.com/open-chat-labs/open-chat/pull/6876)) +- Avoid iterating all users when determining who to notify of new message ([#6877](https://github.com/open-chat-labs/open-chat/pull/6877)) ### Removed diff --git a/backend/canisters/group/impl/src/lib.rs b/backend/canisters/group/impl/src/lib.rs index 8d7ea0997c..6c165e380b 100644 --- a/backend/canisters/group/impl/src/lib.rs +++ b/backend/canisters/group/impl/src/lib.rs @@ -172,7 +172,7 @@ impl RuntimeState { joined: member.date_added(), role: member.role().value.into(), mentions: chat.most_recent_mentions(member, None), - notifications_muted: member.notifications_muted.value, + notifications_muted: member.notifications_muted().value, my_metrics: chat .events .user_metrics(&member.user_id(), None) @@ -581,7 +581,13 @@ impl Data { } pub fn lookup_user_id(&self, user_id_or_principal: Principal) -> Option { - self.get_member(user_id_or_principal).map(|m| m.user_id()) + let user_id = self + .principal_to_user_id_map + .get(&user_id_or_principal) + .copied() + .unwrap_or(user_id_or_principal.into()); + + self.chat.members.contains(&user_id).then_some(user_id) } pub fn get_member(&self, user_id_or_principal: Principal) -> Option<&GroupMemberInternal> { diff --git a/backend/canisters/group/impl/src/queries/summary_updates.rs b/backend/canisters/group/impl/src/queries/summary_updates.rs index 6a2c97e285..46027bb9c7 100644 --- a/backend/canisters/group/impl/src/queries/summary_updates.rs +++ b/backend/canisters/group/impl/src/queries/summary_updates.rs @@ -41,7 +41,7 @@ fn summary_updates_impl(updates_since: TimestampMillis, on_behalf_of: Option Result Response { .chat .members .get(&message.sender) - .map_or(true, |p| p.notifications_muted.value || p.suspended.value); + .map_or(true, |p| p.notifications_muted().value || p.suspended().value); if !notifications_muted { state.push_notification( diff --git a/backend/canisters/group/impl/src/updates/c2c_set_user_suspended.rs b/backend/canisters/group/impl/src/updates/c2c_set_user_suspended.rs index 9cce8ea570..0b828ed71f 100644 --- a/backend/canisters/group/impl/src/updates/c2c_set_user_suspended.rs +++ b/backend/canisters/group/impl/src/updates/c2c_set_user_suspended.rs @@ -3,7 +3,6 @@ use crate::{mutate_state, run_regular_jobs, RuntimeState}; use canister_api_macros::update; use canister_tracing_macros::trace; use group_canister::c2c_set_user_suspended::{Response::*, *}; -use types::Timestamped; #[update(guard = "caller_is_user_index", msgpack = true)] #[trace] @@ -14,11 +13,14 @@ fn c2c_set_user_suspended(args: Args) -> Response { } fn c2c_set_user_suspended_impl(args: Args, state: &mut RuntimeState) -> Response { - if let Some(user) = state.data.chat.members.get_mut(&args.user_id) { - if user.suspended.value != args.suspended { - let now = state.env.now(); - user.suspended = Timestamped::new(args.suspended, now); - } + let now = state.env.now(); + if state + .data + .chat + .members + .set_suspended(args.user_id, args.suspended, now) + .is_some() + { Success } else { UserNotInGroup diff --git a/backend/canisters/group/impl/src/updates/c2c_start_import_into_community.rs b/backend/canisters/group/impl/src/updates/c2c_start_import_into_community.rs index 2d415f5a07..455c5b27bf 100644 --- a/backend/canisters/group/impl/src/updates/c2c_start_import_into_community.rs +++ b/backend/canisters/group/impl/src/updates/c2c_start_import_into_community.rs @@ -15,7 +15,7 @@ fn c2c_start_import_into_community(args: Args) -> Response { fn c2c_start_import_into_community_impl(args: Args, state: &mut RuntimeState) -> Response { if args.user_id != state.data.proposals_bot_user_id { if let Some(member) = state.data.chat.members.get(&args.user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; diff --git a/backend/canisters/group/impl/src/updates/cancel_invites.rs b/backend/canisters/group/impl/src/updates/cancel_invites.rs index 5d986c0d16..2f73ae4dd1 100644 --- a/backend/canisters/group/impl/src/updates/cancel_invites.rs +++ b/backend/canisters/group/impl/src/updates/cancel_invites.rs @@ -20,7 +20,7 @@ fn cancel_invites_impl(args: Args, state: &mut RuntimeState) -> Response { return NotAuthorized; }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; diff --git a/backend/canisters/group/impl/src/updates/claim_prize.rs b/backend/canisters/group/impl/src/updates/claim_prize.rs index b2c5e1db17..5882ff23f8 100644 --- a/backend/canisters/group/impl/src/updates/claim_prize.rs +++ b/backend/canisters/group/impl/src/updates/claim_prize.rs @@ -61,7 +61,7 @@ fn prepare(args: &Args, state: &mut RuntimeState) -> Result Result { let caller = state.env.caller(); if let Some(member) = state.data.get_member(caller) { - if member.suspended.value { + if member.suspended().value { Err(UserSuspended) } else if member.lapsed().value { return Err(UserLapsed); diff --git a/backend/canisters/group/impl/src/updates/disable_invite_code.rs b/backend/canisters/group/impl/src/updates/disable_invite_code.rs index ea6c557f8e..968a5ffa8d 100644 --- a/backend/canisters/group/impl/src/updates/disable_invite_code.rs +++ b/backend/canisters/group/impl/src/updates/disable_invite_code.rs @@ -21,7 +21,7 @@ fn disable_invite_code_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.get_member(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } diff --git a/backend/canisters/group/impl/src/updates/edit_message.rs b/backend/canisters/group/impl/src/updates/edit_message.rs index 53405d8f0a..4310056279 100644 --- a/backend/canisters/group/impl/src/updates/edit_message.rs +++ b/backend/canisters/group/impl/src/updates/edit_message.rs @@ -21,7 +21,7 @@ fn edit_message_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.get_member(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } diff --git a/backend/canisters/group/impl/src/updates/enable_invite_code.rs b/backend/canisters/group/impl/src/updates/enable_invite_code.rs index 5c95221b47..da9f916f6a 100644 --- a/backend/canisters/group/impl/src/updates/enable_invite_code.rs +++ b/backend/canisters/group/impl/src/updates/enable_invite_code.rs @@ -99,7 +99,7 @@ fn prepare(state: &RuntimeState) -> Result { let caller = state.env.caller(); if let Some(member) = state.data.get_member(caller) { - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); } diff --git a/backend/canisters/group/impl/src/updates/register_poll_vote.rs b/backend/canisters/group/impl/src/updates/register_poll_vote.rs index 5813ebeb1a..ba0a8f08a2 100644 --- a/backend/canisters/group/impl/src/updates/register_poll_vote.rs +++ b/backend/canisters/group/impl/src/updates/register_poll_vote.rs @@ -22,7 +22,7 @@ fn register_poll_vote_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.get_member(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; diff --git a/backend/canisters/group/impl/src/updates/register_proposal_vote.rs b/backend/canisters/group/impl/src/updates/register_proposal_vote.rs index d19f64073e..a98ee4eaa0 100644 --- a/backend/canisters/group/impl/src/updates/register_proposal_vote.rs +++ b/backend/canisters/group/impl/src/updates/register_proposal_vote.rs @@ -61,7 +61,7 @@ fn prepare(args: &Args, state: &RuntimeState) -> Result None => return Err(CallerNotInGroup), }; - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); } else if member.lapsed().value { return Err(UserLapsed); diff --git a/backend/canisters/group/impl/src/updates/register_proposal_vote_v2.rs b/backend/canisters/group/impl/src/updates/register_proposal_vote_v2.rs index fc227ec7d8..7a47301295 100644 --- a/backend/canisters/group/impl/src/updates/register_proposal_vote_v2.rs +++ b/backend/canisters/group/impl/src/updates/register_proposal_vote_v2.rs @@ -25,7 +25,7 @@ fn register_proposal_vote_impl(args: Args, state: &mut RuntimeState) -> Response None => return CallerNotInGroup, }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; diff --git a/backend/canisters/group/impl/src/updates/remove_participant.rs b/backend/canisters/group/impl/src/updates/remove_participant.rs index c5502da776..dfdf594adf 100644 --- a/backend/canisters/group/impl/src/updates/remove_participant.rs +++ b/backend/canisters/group/impl/src/updates/remove_participant.rs @@ -62,7 +62,7 @@ fn prepare(user_to_remove: UserId, block: bool, state: &RuntimeState) -> Result< let caller = state.env.caller(); if let Some(member) = state.data.get_member(caller) { - if member.suspended.value { + if member.suspended().value { Err(UserSuspended) } else if member.lapsed().value { return Err(UserLapsed); diff --git a/backend/canisters/group/impl/src/updates/report_message.rs b/backend/canisters/group/impl/src/updates/report_message.rs index bfc134c67f..92d8db6241 100644 --- a/backend/canisters/group/impl/src/updates/report_message.rs +++ b/backend/canisters/group/impl/src/updates/report_message.rs @@ -43,7 +43,7 @@ fn build_c2c_args(args: &Args, state: &RuntimeState) -> Result<(c2c_report_messa if let Some(member) = state.data.get_member(caller) { let chat = &state.data.chat; - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); } else if member.lapsed().value { return Err(UserLapsed); diff --git a/backend/canisters/group/impl/src/updates/send_message.rs b/backend/canisters/group/impl/src/updates/send_message.rs index e271ea74c9..ee603aca5c 100644 --- a/backend/canisters/group/impl/src/updates/send_message.rs +++ b/backend/canisters/group/impl/src/updates/send_message.rs @@ -121,7 +121,7 @@ fn validate_caller(caller_override: Option, state: &RuntimeState) -> let caller = caller_override.unwrap_or_else(|| state.env.caller()); if let Some(member) = state.data.get_member(caller) { - if member.suspended.value { + if member.suspended().value { Err(UserSuspended) } else { Ok(Caller { diff --git a/backend/canisters/group/impl/src/updates/toggle_mute_notifications.rs b/backend/canisters/group/impl/src/updates/toggle_mute_notifications.rs index 174bf4f7ea..7262f829f0 100644 --- a/backend/canisters/group/impl/src/updates/toggle_mute_notifications.rs +++ b/backend/canisters/group/impl/src/updates/toggle_mute_notifications.rs @@ -2,7 +2,6 @@ use crate::{mutate_state, run_regular_jobs, RuntimeState}; use canister_api_macros::update; use canister_tracing_macros::trace; use group_canister::toggle_mute_notifications::{Response::*, *}; -use types::Timestamped; #[update(candid = true, msgpack = true)] #[trace] @@ -15,13 +14,15 @@ fn toggle_mute_notifications(args: Args) -> Response { fn toggle_mute_notifications_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); let now = state.env.now(); - match state.data.get_member_mut(caller) { - Some(member) => { - member.notifications_muted = Timestamped::new(args.mute, now); - let user_id = member.user_id(); + if let Some(user_id) = state.data.lookup_user_id(caller) { + if matches!( + state.data.chat.members.toggle_notifications_muted(user_id, args.mute, now), + Some(true) + ) { state.data.mark_group_updated_in_user_canister(user_id); - Success } - None => CallerNotInGroup, + Success + } else { + CallerNotInGroup } } diff --git a/backend/canisters/group/impl/src/updates/unblock_user.rs b/backend/canisters/group/impl/src/updates/unblock_user.rs index 878539b27c..e6485dc6a9 100644 --- a/backend/canisters/group/impl/src/updates/unblock_user.rs +++ b/backend/canisters/group/impl/src/updates/unblock_user.rs @@ -24,7 +24,7 @@ fn unblock_user_impl(args: Args, state: &mut RuntimeState) -> Response { if !state.data.chat.is_public.value { GroupNotPublic } else if let Some(caller_member) = state.data.get_member(caller) { - if caller_member.suspended.value { + if caller_member.suspended().value { return UserSuspended; } else if caller_member.lapsed().value { return UserLapsed; diff --git a/backend/canisters/group/impl/src/updates/undelete_messages.rs b/backend/canisters/group/impl/src/updates/undelete_messages.rs index 2c00750ba0..0d1fc903cc 100644 --- a/backend/canisters/group/impl/src/updates/undelete_messages.rs +++ b/backend/canisters/group/impl/src/updates/undelete_messages.rs @@ -21,7 +21,7 @@ fn undelete_messages_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.get_member(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } diff --git a/backend/integration_tests/src/mentions_tests.rs b/backend/integration_tests/src/mentions_tests.rs index e23cad5ff2..8386a2e0e3 100644 --- a/backend/integration_tests/src/mentions_tests.rs +++ b/backend/integration_tests/src/mentions_tests.rs @@ -147,6 +147,57 @@ fn mention_everyone_only_succeeds_if_authorized(authorized: bool) { } } +#[test] +fn mentioned_in_thread_adds_user_as_follower() { + let mut wrapper = ENV.deref().get(); + let TestEnv { + env, + canister_ids, + controller, + .. + } = wrapper.env(); + + let TestData { + user1, + user2, + user3: _, + group_id, + } = init_test_data(env, canister_ids, *controller); + + client::group::happy_path::send_text_message(env, &user1, group_id, None, random_string(), None); + client::group::happy_path::send_text_message(env, &user1, group_id, Some(0.into()), random_string(), None); + + client::group::send_message_v2( + env, + user1.principal, + group_id.into(), + &group_canister::send_message_v2::Args { + thread_root_message_index: Some(0.into()), + message_id: random_from_u128(), + content: MessageContentInitial::Text(TextContent { + text: format!("Hello @UserId({})!", user2.user_id), + }), + sender_name: user1.username(), + sender_display_name: None, + replies_to: None, + mentioned: vec![types::User { + user_id: user2.user_id, + username: user2.username(), + }], + forwarding: false, + block_level_markdown: false, + rules_accepted: None, + message_filter_failed: None, + new_achievement: false, + correlation_id: 0, + }, + ); + + let summary = client::group::happy_path::summary(env, &user2, group_id); + assert_eq!(summary.mentions.len(), 1); + assert_eq!(summary.latest_threads.len(), 1); +} + fn init_test_data(env: &mut PocketIc, canister_ids: &CanisterIds, controller: Principal) -> TestData { let user1 = client::register_diamond_user(env, canister_ids, controller); let user2 = client::register_user(env, canister_ids); diff --git a/backend/integration_tests/src/notification_tests.rs b/backend/integration_tests/src/notification_tests.rs index d1279c798f..ec1f23a521 100644 --- a/backend/integration_tests/src/notification_tests.rs +++ b/backend/integration_tests/src/notification_tests.rs @@ -4,7 +4,9 @@ use candid::Principal; use itertools::Itertools; use pocket_ic::PocketIc; use std::ops::Deref; -use testing::rng::random_string; +use test_case::test_case; +use testing::rng::{random_from_u128, random_string}; +use types::{MessageContentInitial, TextContent}; #[test] fn direct_message_notification_succeeds() { @@ -112,8 +114,14 @@ fn direct_message_notification_muted() { assert!(notifications_response.notifications.is_empty()); } -#[test] -fn group_message_notification_muted() { +#[test_case(1)] +#[test_case(2)] +#[test_case(3)] +fn group_message_notification_muted(case: u32) { + // case 1: default + // case 2: @user + // case 3: @everyone + let mut wrapper = ENV.deref().get(); let TestEnv { env, @@ -142,7 +150,39 @@ fn group_message_notification_muted() { let latest_notification_index = latest_notification_index(env, canister_ids.notifications, *controller); - client::group::happy_path::send_text_message(env, &user1, group_id, None, random_string(), None); + let (text, mentioned) = match case { + 1 => (random_string(), Vec::new()), + 2 => ( + format!("@UserId({})", user2.user_id), + vec![types::User { + user_id: user2.user_id, + username: user2.username(), + }], + ), + 3 => ("@everyone".to_string(), Vec::new()), + _ => panic!(), + }; + + client::group::send_message_v2( + env, + user1.principal, + group_id.into(), + &group_canister::send_message_v2::Args { + thread_root_message_index: None, + message_id: random_from_u128(), + content: MessageContentInitial::Text(TextContent { text }), + sender_name: user1.username(), + sender_display_name: None, + replies_to: None, + mentioned, + forwarding: false, + block_level_markdown: false, + rules_accepted: None, + message_filter_failed: None, + new_achievement: false, + correlation_id: 0, + }, + ); let notifications_canister::notifications::Response::Success(notifications_response) = client::notifications::notifications( env, @@ -153,7 +193,11 @@ fn group_message_notification_muted() { }, ); - assert!(notifications_response.notifications.is_empty()); + if case == 1 { + assert!(notifications_response.notifications.is_empty()); + } else { + assert_eq!(notifications_response.notifications.len(), 1); + } } #[test] diff --git a/backend/libraries/group_chat_core/src/lib.rs b/backend/libraries/group_chat_core/src/lib.rs index c0b75be518..43344abc6f 100644 --- a/backend/libraries/group_chat_core/src/lib.rs +++ b/backend/libraries/group_chat_core/src/lib.rs @@ -171,7 +171,7 @@ impl GroupChatCore { if hidden_for_non_members { if let Some(member) = member { - if member.suspended.value { + if member.suspended().value { return MinVisibleEventIndexResult::UserSuspended; } else if member.lapsed().value { return MinVisibleEventIndexResult::UserLapsed; @@ -687,7 +687,6 @@ impl GroupChatCore { let PrepareSendMessageSuccess { min_visible_event_index, - mentions_disabled, everyone_mentioned, sender_user_type, } = match self.prepare_send_message( @@ -753,49 +752,55 @@ impl GroupChatCore { // Bump the thread timestamp for all followers member.followed_threads.insert(root_message_index, now); - if member.user_id() != sender && !member.user_type().is_bot() && !member.suspended.value { - let mentioned = !mentions_disabled - && (mentions.contains(&member.user_id()) - || (is_first_reply && member.user_id() == root_message_sender)); + let user_id = member.user_id(); + if user_id != sender { + let mentioned = + mentions.contains(&user_id) || (is_first_reply && user_id == root_message_sender); if mentioned { - // Mention this member member.mentions.add(thread_root_message_index, message_index, message_id, now); } - if mentioned || !member.notifications_muted.value { - users_to_notify.insert(member.user_id()); + if mentioned || !member.notifications_muted().value { + users_to_notify.insert(user_id); } } } } } } else { + for mentioned in mentions { + if let Some(member) = self.members.get_mut(&mentioned) { + member.mentions.add(thread_root_message_index, message_index, message_id, now); + users_to_notify.insert(mentioned); + } + } if everyone_mentioned { self.at_everyone_mentions.insert( now, AtEveryoneMention::new(sender, message_event.event.message_id, message_event.event.message_index), ); - } - - for member in self - .members - .iter_mut() - .filter(|m| m.user_id() != sender && !m.user_type().is_bot() && !m.suspended.value) - { - let mentioned = !mentions_disabled && mentions.contains(&member.user_id()); - if mentioned { - // Mention this member - member.mentions.add(thread_root_message_index, message_index, message_id, now); - } - - if !member.notifications_muted.value || mentioned || everyone_mentioned { - users_to_notify.insert(member.user_id()); - } + // Notify everyone + users_to_notify.extend(self.members.member_ids().iter().copied()); + } else { + // Notify everyone who has notifications unmuted + users_to_notify.extend(self.members.notifications_unmuted().iter().copied()); } } } + // Exclude the sender, bots, lapsed members, and suspended members from notifications + users_to_notify.remove(&sender); + for bot in self.members.bots().keys() { + users_to_notify.remove(bot); + } + for user_id in self.members.lapsed() { + users_to_notify.remove(user_id); + } + for user_id in self.members.suspended() { + users_to_notify.remove(user_id); + } + Success(SendMessageSuccess { message_event, users_to_notify: users_to_notify.iter().copied().collect(), @@ -816,7 +821,6 @@ impl GroupChatCore { if sender == OPENCHAT_BOT_USER_ID || sender == proposals_bot_user_id { return Success(PrepareSendMessageSuccess { min_visible_event_index: EventIndex::default(), - mentions_disabled: true, everyone_mentioned: false, sender_user_type: UserType::OcControlledBot, }); @@ -836,7 +840,7 @@ impl GroupChatCore { let Some(member) = self.members.get(&sender) else { return UserNotInGroup; }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; } @@ -851,7 +855,6 @@ impl GroupChatCore { Success(PrepareSendMessageSuccess { min_visible_event_index: member.min_visible_event_index(), - mentions_disabled: false, everyone_mentioned: member.role().can_mention_everyone(permissions) && is_everyone_mentioned(content), sender_user_type: member.user_type(), }) @@ -869,7 +872,7 @@ impl GroupChatCore { use AddRemoveReactionResult::*; if let Some(member) = self.members.get(&user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -909,7 +912,7 @@ impl GroupChatCore { use AddRemoveReactionResult::*; if let Some(member) = self.members.get(&user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -943,7 +946,7 @@ impl GroupChatCore { use TipMessageResult::*; if let Some(member) = self.members.get(&args.user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -972,7 +975,7 @@ impl GroupChatCore { use DeleteMessagesResult::*; let (is_admin, min_visible_event_index) = if let Some(member) = self.members.get(&user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -1039,7 +1042,7 @@ impl GroupChatCore { use UndeleteMessagesResult::*; if let Some(member) = self.members.get(&user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -1116,7 +1119,7 @@ impl GroupChatCore { use PinUnpinMessageResult::*; if let Some(member) = self.members.get(&user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -1160,7 +1163,7 @@ impl GroupChatCore { use PinUnpinMessageResult::*; if let Some(member) = self.members.get(&user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -1235,7 +1238,7 @@ impl GroupChatCore { const MAX_INVITES: usize = 100; if let Some(member) = self.members.get(&invited_by) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } @@ -1311,7 +1314,7 @@ impl GroupChatCore { use CancelInvitesResult::*; if let Some(member) = self.members.get(&cancelled_by) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -1337,7 +1340,7 @@ impl GroupChatCore { use CanLeaveResult::*; if let Some(member) = self.members.get(&user_id) { - if member.suspended.value { + if member.suspended().value { UserSuspended } else if member.role().is_owner() && self.members.owners().len() == 1 { LastOwnerCannotLeave @@ -1381,7 +1384,7 @@ impl GroupChatCore { } if let Some(member) = self.members.get(&user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -1509,7 +1512,7 @@ impl GroupChatCore { } if let Some(member) = self.members.get(user_id) { - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); } else if member.lapsed().value { return Err(UserLapsed); @@ -1734,7 +1737,7 @@ impl GroupChatCore { return UserNotInGroup; }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -1766,7 +1769,7 @@ impl GroupChatCore { return UserNotInGroup; }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; } else if member.lapsed().value { return UserLapsed; @@ -2295,7 +2298,6 @@ enum PrepareSendMessageResult { struct PrepareSendMessageSuccess { min_visible_event_index: EventIndex, - mentions_disabled: bool, everyone_mentioned: bool, sender_user_type: UserType, } diff --git a/backend/libraries/group_chat_core/src/members.rs b/backend/libraries/group_chat_core/src/members.rs index 512bc683e2..02b06521fc 100644 --- a/backend/libraries/group_chat_core/src/members.rs +++ b/backend/libraries/group_chat_core/src/members.rs @@ -30,8 +30,10 @@ pub struct GroupMembers { admins: BTreeSet, moderators: BTreeSet, bots: BTreeMap, + notifications_unmuted: BTreeSet, lapsed: BTreeSet, blocked: BTreeSet, + suspended: BTreeSet, updates: BTreeSet<(TimestampMillis, UserId, MemberUpdate)>, } @@ -50,7 +52,9 @@ impl From for GroupMembers { let mut admins = BTreeSet::new(); let mut moderators = BTreeSet::new(); let mut bots = BTreeMap::new(); + let mut notifications_unmuted = BTreeSet::new(); let mut lapsed = BTreeSet::new(); + let mut suspended = BTreeSet::new(); for member in value.members.values() { member_ids.insert(member.user_id); @@ -62,6 +66,10 @@ impl From for GroupMembers { GroupRoleInternal::Member => false, }; + if !member.notifications_muted.value { + notifications_unmuted.insert(member.user_id); + } + if member.lapsed.value { lapsed.insert(member.user_id); } @@ -69,6 +77,10 @@ impl From for GroupMembers { if member.user_type.is_bot() { bots.insert(member.user_id, member.user_type); } + + if member.suspended.value { + suspended.insert(member.user_id); + } } GroupMembers { @@ -78,7 +90,9 @@ impl From for GroupMembers { admins, moderators, bots, + notifications_unmuted, lapsed, + suspended, blocked: value.blocked, updates: value.updates, } @@ -129,7 +143,9 @@ impl GroupMembers { } else { BTreeMap::new() }, + notifications_unmuted: [creator_user_id].into_iter().collect(), lapsed: BTreeSet::new(), + suspended: BTreeSet::new(), updates: BTreeSet::new(), } } @@ -168,6 +184,9 @@ impl GroupMembers { if user_type.is_bot() { self.bots.insert(user_id, user_type); } + if !notifications_muted { + self.notifications_unmuted.insert(user_id); + } self.updates.insert((now, user_id, MemberUpdate::Added)); AddResult::Success(AddMemberSuccess { member, unlapse: false }) } else { @@ -186,9 +205,15 @@ impl GroupMembers { if member.user_type.is_bot() { self.bots.remove(&user_id); } + if !member.notifications_muted.value { + self.notifications_unmuted.remove(&user_id); + } if member.lapsed.value { self.lapsed.remove(&user_id); } + if member.suspended.value { + self.suspended.remove(&user_id); + } self.member_ids.remove(&user_id); self.updates.insert((now, user_id, MemberUpdate::Removed)); Some(member) @@ -240,7 +265,7 @@ impl GroupMembers { } pub fn contains(&self, user_id: &UserId) -> bool { - self.members.contains_key(user_id) + self.member_ids.contains(user_id) } pub fn get_mut(&mut self, user_id: &UserId) -> Option<&mut GroupMemberInternal> { @@ -344,6 +369,43 @@ impl GroupMembers { ChangeRoleResult::Success(ChangeRoleSuccess { prev_role }) } + pub fn toggle_notifications_muted( + &mut self, + user_id: UserId, + notifications_muted: bool, + now: TimestampMillis, + ) -> Option { + let member = self.members.get_mut(&user_id)?; + + if member.notifications_muted.value != notifications_muted { + member.notifications_muted = Timestamped::new(notifications_muted, now); + if notifications_muted { + self.notifications_unmuted.remove(&user_id); + } else { + self.notifications_unmuted.insert(user_id); + } + Some(true) + } else { + Some(false) + } + } + + pub fn set_suspended(&mut self, user_id: UserId, suspended: bool, now: TimestampMillis) -> Option { + let member = self.members.get_mut(&user_id)?; + + if member.suspended.value != suspended { + member.suspended = Timestamped::new(suspended, now); + if suspended { + self.suspended.insert(user_id); + } else { + self.suspended.remove(&user_id); + } + Some(true) + } else { + Some(false) + } + } + pub fn unlapse_all(&mut self, now: TimestampMillis) { for user_id in std::mem::take(&mut self.lapsed) { if let Some(member) = self.members.get_mut(&user_id) { @@ -397,10 +459,18 @@ impl GroupMembers { &self.bots } + pub fn notifications_unmuted(&self) -> &BTreeSet { + &self.notifications_unmuted + } + pub fn lapsed(&self) -> &BTreeSet { &self.lapsed } + pub fn suspended(&self) -> &BTreeSet { + &self.suspended + } + pub fn has_membership_changed(&self, since: TimestampMillis) -> bool { self.iter_latest_updates(since) .any(|(_, u)| matches!(u, MemberUpdate::Added | MemberUpdate::Removed)) @@ -424,7 +494,9 @@ impl GroupMembers { let mut owners = BTreeSet::new(); let mut admins = BTreeSet::new(); let mut moderators = BTreeSet::new(); + let mut notifications_unmuted = BTreeSet::new(); let mut lapsed = BTreeSet::new(); + let mut suspended = BTreeSet::new(); for member in self.members.values() { member_ids.insert(member.user_id); @@ -436,16 +508,26 @@ impl GroupMembers { GroupRoleInternal::Member => false, }; + if !member.notifications_muted.value { + notifications_unmuted.insert(member.user_id); + } + if member.lapsed.value { lapsed.insert(member.user_id); } + + if member.suspended.value { + suspended.insert(member.user_id); + } } assert_eq!(member_ids, self.member_ids); assert_eq!(owners, self.owners); assert_eq!(admins, self.admins); assert_eq!(moderators, self.moderators); + assert_eq!(notifications_unmuted, self.notifications_unmuted); assert_eq!(lapsed, self.lapsed); + assert_eq!(suspended, self.suspended); } } @@ -503,7 +585,7 @@ pub struct GroupMemberInternal { #[serde(rename = "r", default, skip_serializing_if = "is_default")] role: Timestamped, #[serde(rename = "n")] - pub notifications_muted: Timestamped, + notifications_muted: Timestamped, #[serde(rename = "m", default, skip_serializing_if = "mentions_are_empty")] pub mentions: Mentions, #[serde(rename = "tf", default, skip_serializing_if = "TimestampedSet::is_empty")] @@ -513,7 +595,7 @@ pub struct GroupMemberInternal { #[serde(rename = "p", default, skip_serializing_if = "BTreeMap::is_empty")] pub proposal_votes: BTreeMap>, #[serde(rename = "s", default, skip_serializing_if = "is_default")] - pub suspended: Timestamped, + suspended: Timestamped, #[serde(rename = "ra", default, skip_serializing_if = "is_default")] pub rules_accepted: Option>, #[serde(rename = "ut", default, skip_serializing_if = "is_default")] @@ -543,10 +625,18 @@ impl GroupMemberInternal { self.user_type } + pub fn notifications_muted(&self) -> &Timestamped { + &self.notifications_muted + } + pub fn lapsed(&self) -> &Timestamped { &self.lapsed } + pub fn suspended(&self) -> &Timestamped { + &self.suspended + } + pub fn last_updated(&self) -> TimestampMillis { [ self.date_added, diff --git a/backend/libraries/group_chat_core/src/members/proptests.rs b/backend/libraries/group_chat_core/src/members/proptests.rs index dc5b03582f..aa65a44850 100644 --- a/backend/libraries/group_chat_core/src/members/proptests.rs +++ b/backend/libraries/group_chat_core/src/members/proptests.rs @@ -20,6 +20,10 @@ enum Operation { Remove { user_index: usize, }, + ToggleMuteNotifications { + user_index: usize, + mute: bool, + }, Block { user_index: usize, }, @@ -33,6 +37,10 @@ enum Operation { user_index: usize, }, UnlapseAll, + SetSuspended { + user_index: usize, + suspended: bool, + }, } fn operation_strategy() -> impl Strategy { @@ -40,12 +48,15 @@ fn operation_strategy() -> impl Strategy { 50 => any::().prop_map(|user_index| Operation::Add { user_id: user_id(user_index) }), 20 => (any::(), any::(), any::()) .prop_map(|(owner_index, user_index, role_index)| Operation::ChangeRole { owner_index, user_index, role: role(role_index) }), + 10 => (any::(), any::()).prop_map(|(user_index, mute)| Operation::ToggleMuteNotifications { user_index, mute }), 10 => any::().prop_map(|user_index| Operation::Remove { user_index}), 5 => any::().prop_map(|user_index| Operation::Block { user_index}), 3 => any::().prop_map(|user_index| Operation::Unblock { user_index}), 5 => any::().prop_map(|user_index| Operation::Lapse { user_index}), 3 => any::().prop_map(|user_index| Operation::Unlapse { user_index}), 1 => Just(Operation::UnlapseAll), + 2 => any::().prop_map(|user_index| Operation::SetSuspended { user_index, suspended: true }), + 1 => any::().prop_map(|user_index| Operation::SetSuspended { user_index, suspended: false }), ] } @@ -83,6 +94,10 @@ fn execute_operation(members: &mut GroupMembers, op: Operation, timestamp: Times let user_id = get(&members.member_ids, user_index); members.change_role(owner, user_id, role, &GroupPermissions::default(), false, false, timestamp); } + Operation::ToggleMuteNotifications { user_index, mute } => { + let user_id = get(&members.member_ids, user_index); + members.toggle_notifications_muted(user_id, mute, timestamp); + } Operation::Remove { user_index } => { let user_id = get(&members.member_ids, user_index); if members.owners.len() != 1 || members.owners.first() != Some(&user_id) { @@ -115,6 +130,15 @@ fn execute_operation(members: &mut GroupMembers, op: Operation, timestamp: Times Operation::UnlapseAll => { members.unlapse_all(timestamp); } + Operation::SetSuspended { user_index, suspended } => { + if suspended { + let user_id = get(&members.member_ids, user_index); + members.set_suspended(user_id, true, timestamp); + } else if !members.suspended.is_empty() { + let user_id = get(&members.suspended, user_index); + members.set_suspended(user_id, false, timestamp); + } + } }; } From ac0bb97454730014194572512b9b60a756dd7ccd Mon Sep 17 00:00:00 2001 From: Matt Grogan Date: Fri, 22 Nov 2024 18:42:31 +0200 Subject: [PATCH 03/32] Remove diamond only access to set display name (#6879) --- backend/canisters/user_index/CHANGELOG.md | 1 + .../api/src/updates/set_display_name.rs | 1 - .../impl/src/updates/set_display_name.rs | 4 -- .../src/update_profile_tests.rs | 26 ------------ .../src/components/DisplayNameInput.svelte | 41 ++++++------------- .../components/home/upgrade/Features.svelte | 10 ----- .../UserIndexSetDisplayNameResponse.ts | 2 +- 7 files changed, 14 insertions(+), 71 deletions(-) diff --git a/backend/canisters/user_index/CHANGELOG.md b/backend/canisters/user_index/CHANGELOG.md index 4234cd0abf..048d94682d 100644 --- a/backend/canisters/user_index/CHANGELOG.md +++ b/backend/canisters/user_index/CHANGELOG.md @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Pass in `BotApiCanister` when installing a new LocalUserIndex ([#6828](https://github.com/open-chat-labs/open-chat/pull/6828)) - Simplify `inspect_message` ([#6847](https://github.com/open-chat-labs/open-chat/pull/6847)) - Allow bots to set a display name when registering ([#6850](https://github.com/open-chat-labs/open-chat/pull/6850)) +- Remove diamond only access to set display name ([#6879](https://github.com/open-chat-labs/open-chat/pull/6879)) ## [[2.0.1450](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1450-user_index)] - 2024-11-14 diff --git a/backend/canisters/user_index/api/src/updates/set_display_name.rs b/backend/canisters/user_index/api/src/updates/set_display_name.rs index 2d939f9b7a..bf759a7ce3 100644 --- a/backend/canisters/user_index/api/src/updates/set_display_name.rs +++ b/backend/canisters/user_index/api/src/updates/set_display_name.rs @@ -12,7 +12,6 @@ pub struct Args { #[derive(CandidType, Serialize, Deserialize, Debug)] pub enum Response { Success, - Unauthorized, UserNotFound, DisplayNameInvalid, DisplayNameTooShort(u16), diff --git a/backend/canisters/user_index/impl/src/updates/set_display_name.rs b/backend/canisters/user_index/impl/src/updates/set_display_name.rs index c19e693456..49cb02e555 100644 --- a/backend/canisters/user_index/impl/src/updates/set_display_name.rs +++ b/backend/canisters/user_index/impl/src/updates/set_display_name.rs @@ -26,10 +26,6 @@ fn set_display_name_impl(args: Args, state: &mut RuntimeState) -> Response { } let now = state.env.now(); - if !user.diamond_membership_details.is_active(now) && user.display_name.is_none() { - return Unauthorized; - } - let mut user_to_update = user.clone(); user_to_update.display_name.clone_from(&args.display_name); let user_id = user.user_id; diff --git a/backend/integration_tests/src/update_profile_tests.rs b/backend/integration_tests/src/update_profile_tests.rs index 1b9a5a9869..c07801a457 100644 --- a/backend/integration_tests/src/update_profile_tests.rs +++ b/backend/integration_tests/src/update_profile_tests.rs @@ -63,29 +63,3 @@ fn update_display_name_succeeds() { let updates = client::user::happy_path::updates(env, &user, now - 1); assert_eq!(updates.unwrap().display_name, OptionUpdate::SetToSome(display_name)); } - -#[test] -fn update_display_name_unauthorized_if_not_diamond_member() { - let mut wrapper = ENV.deref().get(); - let TestEnv { env, canister_ids, .. } = wrapper.env(); - - let user = client::register_user(env, canister_ids); - - env.advance_time(Duration::from_secs(10)); - - let display_name = random_string(); - - let response = client::user_index::set_display_name( - env, - user.principal, - canister_ids.user_index, - &user_index_canister::set_display_name::Args { - display_name: Some(display_name.clone()), - }, - ); - - assert!(matches!( - response, - user_index_canister::set_display_name::Response::Unauthorized - )); -} diff --git a/frontend/app/src/components/DisplayNameInput.svelte b/frontend/app/src/components/DisplayNameInput.svelte index d2716d4863..3ac4491d50 100644 --- a/frontend/app/src/components/DisplayNameInput.svelte +++ b/frontend/app/src/components/DisplayNameInput.svelte @@ -1,16 +1,12 @@ -{#if $isDiamond || originalDisplayName !== undefined} - - - -{:else} -
- -
-{/if} - - + + + diff --git a/frontend/app/src/components/home/upgrade/Features.svelte b/frontend/app/src/components/home/upgrade/Features.svelte index 9e78e2f82e..6bee118b1b 100644 --- a/frontend/app/src/components/home/upgrade/Features.svelte +++ b/frontend/app/src/components/home/upgrade/Features.svelte @@ -208,16 +208,6 @@ - -
-
- -
-
- -
-
-
diff --git a/tsBindings/userIndex/setDisplayName/UserIndexSetDisplayNameResponse.ts b/tsBindings/userIndex/setDisplayName/UserIndexSetDisplayNameResponse.ts index c8dd433ad2..f206989c44 100644 --- a/tsBindings/userIndex/setDisplayName/UserIndexSetDisplayNameResponse.ts +++ b/tsBindings/userIndex/setDisplayName/UserIndexSetDisplayNameResponse.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type UserIndexSetDisplayNameResponse = "Success" | "Unauthorized" | "UserNotFound" | "DisplayNameInvalid" | { "DisplayNameTooShort": number } | { "DisplayNameTooLong": number }; +export type UserIndexSetDisplayNameResponse = "Success" | "UserNotFound" | "DisplayNameInvalid" | { "DisplayNameTooShort": number } | { "DisplayNameTooLong": number }; From 020622059528e1ea22e9624b86f04add33a70355 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Fri, 22 Nov 2024 16:48:44 +0000 Subject: [PATCH 04/32] Add comment + clarify type names (#6880) --- .../chat_events/src/stable_storage/key.rs | 24 ++++++++-------- .../libraries/stable_memory_map/src/lib.rs | 28 +++++++++++-------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/backend/libraries/chat_events/src/stable_storage/key.rs b/backend/libraries/chat_events/src/stable_storage/key.rs index 3c0e8b9c87..fcfcf4bbca 100644 --- a/backend/libraries/chat_events/src/stable_storage/key.rs +++ b/backend/libraries/chat_events/src/stable_storage/key.rs @@ -84,12 +84,12 @@ impl KeyPrefix { pub fn key_type(&self) -> KeyType { match self { - KeyPrefix::DirectChat(_) => KeyType::DirectChat, - KeyPrefix::GroupChat(_) => KeyType::GroupChat, - KeyPrefix::Channel(_) => KeyType::Channel, - KeyPrefix::DirectChatThread(_) => KeyType::DirectChatThread, - KeyPrefix::GroupChatThread(_) => KeyType::GroupChatThread, - KeyPrefix::ChannelThread(_) => KeyType::ChannelThread, + KeyPrefix::DirectChat(_) => KeyType::DirectChatEvent, + KeyPrefix::GroupChat(_) => KeyType::GroupChatEvent, + KeyPrefix::Channel(_) => KeyType::ChannelEvent, + KeyPrefix::DirectChatThread(_) => KeyType::DirectChatThreadEvent, + KeyPrefix::GroupChatThread(_) => KeyType::GroupChatThreadEvent, + KeyPrefix::ChannelThread(_) => KeyType::ChannelThreadEvent, } } } @@ -140,12 +140,12 @@ impl TryFrom<&[u8]> for KeyPrefix { let bytes = Cow::Borrowed(&bytes[1..]); match key_type { - KeyType::DirectChat => Ok(KeyPrefix::DirectChat(DirectChatKeyPrefix::from_bytes(bytes))), - KeyType::GroupChat => Ok(KeyPrefix::GroupChat(GroupChatKeyPrefix::from_bytes(bytes))), - KeyType::Channel => Ok(KeyPrefix::Channel(ChannelKeyPrefix::from_bytes(bytes))), - KeyType::DirectChatThread => Ok(KeyPrefix::DirectChatThread(DirectChatThreadKeyPrefix::from_bytes(bytes))), - KeyType::GroupChatThread => Ok(KeyPrefix::GroupChatThread(GroupChatThreadKeyPrefix::from_bytes(bytes))), - KeyType::ChannelThread => Ok(KeyPrefix::ChannelThread(ChannelThreadKeyPrefix::from_bytes(bytes))), + KeyType::DirectChatEvent => Ok(KeyPrefix::DirectChat(DirectChatKeyPrefix::from_bytes(bytes))), + KeyType::GroupChatEvent => Ok(KeyPrefix::GroupChat(GroupChatKeyPrefix::from_bytes(bytes))), + KeyType::ChannelEvent => Ok(KeyPrefix::Channel(ChannelKeyPrefix::from_bytes(bytes))), + KeyType::DirectChatThreadEvent => Ok(KeyPrefix::DirectChatThread(DirectChatThreadKeyPrefix::from_bytes(bytes))), + KeyType::GroupChatThreadEvent => Ok(KeyPrefix::GroupChatThread(GroupChatThreadKeyPrefix::from_bytes(bytes))), + KeyType::ChannelThreadEvent => Ok(KeyPrefix::ChannelThread(ChannelThreadKeyPrefix::from_bytes(bytes))), _ => Err(()), } } diff --git a/backend/libraries/stable_memory_map/src/lib.rs b/backend/libraries/stable_memory_map/src/lib.rs index b7f43351df..9e00400249 100644 --- a/backend/libraries/stable_memory_map/src/lib.rs +++ b/backend/libraries/stable_memory_map/src/lib.rs @@ -1,3 +1,7 @@ +//! If you want to store data in this map and be able to iterate over it in order, then the keys +//! must maintain their ordering when represented as bytes, since the keys in the map are ordered +//! by their bytes. + use ic_stable_structures::memory_manager::VirtualMemory; use ic_stable_structures::{DefaultMemoryImpl, StableBTreeMap}; use std::cell::RefCell; @@ -29,12 +33,12 @@ pub fn with_map_mut, Vec, Memory>) -> #[derive(Copy, Clone)] #[repr(u8)] pub enum KeyType { - DirectChat = 1, - GroupChat = 2, - Channel = 3, - DirectChatThread = 4, - GroupChatThread = 5, - ChannelThread = 6, + DirectChatEvent = 1, + GroupChatEvent = 2, + ChannelEvent = 3, + DirectChatThreadEvent = 4, + GroupChatThreadEvent = 5, + ChannelThreadEvent = 6, ChatMember = 7, CommunityMember = 8, } @@ -42,12 +46,12 @@ pub enum KeyType { impl From for KeyType { fn from(value: u8) -> Self { match value { - 1 => KeyType::DirectChat, - 2 => KeyType::GroupChat, - 3 => KeyType::Channel, - 4 => KeyType::DirectChatThread, - 5 => KeyType::GroupChatThread, - 6 => KeyType::ChannelThread, + 1 => KeyType::DirectChatEvent, + 2 => KeyType::GroupChatEvent, + 3 => KeyType::ChannelEvent, + 4 => KeyType::DirectChatThreadEvent, + 5 => KeyType::GroupChatThreadEvent, + 6 => KeyType::ChannelThreadEvent, 7 => KeyType::ChatMember, 8 => KeyType::CommunityMember, _ => unreachable!(), From 6f8ea468bd2c937c4dc80518f64a66215bdbf535 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Sat, 23 Nov 2024 10:29:35 +0000 Subject: [PATCH 05/32] Shout out to RunsOn (#6881) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cbf6e31280..f4a6d90655 100644 --- a/README.md +++ b/README.md @@ -42,3 +42,7 @@ You can build the OpenChat canister wasms by running `./scripts/docker-build-all Copyright 2024 OpenChat Labs LTD Licensed under the AGPLv3: https://www.gnu.org/licenses/agpl-3.0.html + +--- + +*Our tests run fast and cheap via [RunsOn](https://runs-on.com)* From 75c0d893d20dc7566183308c5b144a66f340284d Mon Sep 17 00:00:00 2001 From: Julian Jelfs Date: Sun, 24 Nov 2024 23:37:01 +0000 Subject: [PATCH 06/32] add arabic support (#6882) --- frontend/app/generate.js | 1 + frontend/app/src/i18n/ar.json | 1663 +++++++++++++++++++++++++++++++++ frontend/app/src/i18n/i18n.ts | 6 + frontend/app/src/i18n/it.json | 4 +- 4 files changed, 1672 insertions(+), 2 deletions(-) create mode 100644 frontend/app/src/i18n/ar.json diff --git a/frontend/app/generate.js b/frontend/app/generate.js index b253e44e6c..9fee313377 100644 --- a/frontend/app/generate.js +++ b/frontend/app/generate.js @@ -148,6 +148,7 @@ const languages = [ { lang: "vi", code: "vi" }, { lang: "pl", code: "pl" }, { lang: "fa", code: "fa" }, + { lang: "ar", code: "ar" }, ]; languages.forEach(async ({ lang, code }) => { diff --git a/frontend/app/src/i18n/ar.json b/frontend/app/src/i18n/ar.json new file mode 100644 index 0000000000..ac056b7f8e --- /dev/null +++ b/frontend/app/src/i18n/ar.json @@ -0,0 +1,1663 @@ +{ + "aboutOpenChat": "حول OpenChat", + "access": { + "addGate": "إضافة بوابة", + "addGateInfo": "يمكن دمج عدة بوابات معًا في مجموعات لتحقيق قدر أكبر من المرونة. يمكنك طلب من النجارين تلبية جميع البوابات أو أي منها في المجموعة.", + "amount": "المبلغ (الرموز)", + "amountN": "المبلغ {n} من الرموز", + "and": "تتطلب جميع البوابات", + "approvePaymentTitle": "الموافقة على الدفع", + "bypassWarning": "لن يُطلب من أي مستخدمين تمت دعوتهم صراحةً إلى هذا {level} عبور بوابات الوصول", + "cannotMakePublic": "لا يمكنك جعل {level} خاصًا عامًا لأن ذلك من شأنه تعريض خصوصية الأعضاء الحاليين للخطر.", + "chooseGate": "نوع بوابة الوصول", + "chooseNervousSystem": "اختر الجهاز العصبي", + "chooseOneGate": "اختر البوابة", + "chooseOneGateInfo": "يجب أن تستوفي أحد الشروط التالية للانضمام. اختر الشرط الذي تفضله.", + "chooseToken": "اختر الرمز", + "compositeGate": "بوابة الوصول المشتركة", + "credential": { + "addArgument": "أضف حجة", + "argumentName": "اسم", + "argumentNamePlaceholder": "أدخل اسم الحجة", + "argumentValue": "قيمة", + "argumentValuePlaceholder": "أدخل قيمة الحجة", + "credentialCheckFailed": "عذراً، لم نتمكن من التحقق من بيانات اعتماد \"{credential}\" وبالتالي لن تتمكن من الانضمام إلى {level}.", + "credentialCheckMessage": "للانضمام إلى {level}، يلزمك التحقق من بيانات اعتماد \"{credential}\"", + "credentialGateInfo": "سيتم السماح فقط للمستخدمين الذين يمكنهم تقديم بيانات الاعتماد \"{credential}\" بالانضمام", + "credentialIssuer": "جهة إصدار بيانات الاعتماد", + "credentialName": "اسم الاعتماد", + "credentialNamePlaceholder": "أدخل اسمًا للبيان الاعتمادي", + "credentialParamCredential": "الاعتماد: {credential}", + "credentialParamIssuer": "الجهة المصدرة: {issuer}", + "credentialType": "نوع الاعتماد", + "credentialTypePlaceholder": "أدخل نوع الاعتماد", + "initiateCredentialCheck": "التحقق من بيانات الاعتماد", + "issuerCanisterId": "معرف علبة المصدر", + "issuerCanisterIdPlaceholder": "أدخل معرف علبة المصدر", + "issuerOrigin": "أصل المصدر", + "issuerOriginPlaceholder": "أدخل أصل المصدر", + "label": "تم التحقق من بيانات الاعتماد", + "requiredCredential": "المؤهلات المطلوبة" + }, + "diamondGateInfo": "سيتم السماح فقط للمستخدمين الحاصلين على العضوية الماسية بالانضمام", + "diamondGateInfo2": "لكي تتمكن من الانضمام إلى {level}، يجب أن يكون لديك عضوية Diamond.", + "diamondMember": "العضوية الماسية", + "doYouHaveUniquePersonCredential": "هام! يرجى التأكد من التحقق من شخصيتك الفريدة عبر موقع DecideAI _قبل_ المتابعة.", + "evaluationInterval": "فترة تقييم بوابة الوصول", + "evaluationIntervalInfo": "يجوز لك اختيار فرض إعادة التقييم الدوري لبوابة الوصول الخاصة بك. عندما تنتهي صلاحية وصول أحد الأعضاء، فلن يتمكن من التفاعل مع {level} حتى يتم إعادة تقييمه للتأكد من استيفاء شروط الوصول.", + "evaluationIntervalSummary": "سيتم إعادة تقييم بوابة الوصول هذه كل {interval}", + "gate": "بوابة الوصول", + "gateCheckFailed": "لم يتم الوصول إلى بوابة الدخول", + "gateInvalid": "بوابة الدخول غير صالحة", + "join": "ينضم", + "lapsed": { + "label": "انتهت العضوية", + "rejoin": "إعادة الانضمام إلى {level}", + "user": "أطفال" + }, + "lifetimeDiamondGateInfo": "سيتم السماح فقط للمستخدمين الذين لديهم عضوية Diamond مدى الحياة بالانضمام", + "lifetimeDiamondGateInfo2": "لكي تتمكن من الانضمام إلى {level}، يجب أن يكون لديك عضوية Diamond مدى الحياة.", + "lifetimeDiamondMember": "عضوية الماس مدى الحياة", + "lockedGate": "مغلق", + "lockedGateInfo": "هذا {level} مقفل ولا يمكن الانضمام إليه", + "messagesVisibleToNonMembers": "يمكن معاينة الرسائل من قبل غير الأعضاء", + "minDissolveDelay": "الحد الأدنى لتأخير الذوبان (أيام)", + "minDissolveDelayN": "الحد الأدنى لتأخير الذوبان {n} يوم", + "minimumBalance": "الحد الأدنى لرصيد الرمز", + "minimumBalanceInfo": "لا يمكن للمستخدمين الانضمام إلا إذا كان لديهم حد أدنى من رصيد الرموز", + "minimumBalanceInfo2": "لا يمكن للمستخدمين الانضمام إلا إذا كان لديهم على الأقل {n} {token} رمز في محفظتهم", + "minimumBalanceN": "الحد الأدنى لرصيد {n} من الرموز", + "minimumTokenBalance": "الحد الأدنى للرصيد {token}", + "minStake": "الحد الأدنى للرهان (الرموز)", + "minStakeN": "الحد الأدنى للرهان {n} من الرموز", + "neuronHolder": "حامل الخلايا العصبية", + "neuronHolderInfo": "سيتم السماح فقط لحاملي الخلايا العصبية {token} بالانضمام", + "next": "التالي", + "nftHolder": "حامل NFT (قريبًا)", + "openAccess": "لا يوجد (الوصول المفتوح)", + "openAccessInfo": "لا توجد متطلبات وصول خاصة للانضمام", + "optional": "خياري", + "or": "تتطلب أي بوابة", + "payAndJoin": "الدفع والانضمام", + "payment": "قسط", + "paymentApprovalMessage": "لكي تتمكن من الانضمام إلى {level}، يتعين عليك دفع **{amount}** {token} من الرموز.", + "paymentDistributionMessage": "من هذا، 98% تذهب إلى مالكي {level} و2% إلى خزانة OpenChat SNS.", + "paymentFailed": "فشل الدفع", + "predefinedCredentialIssuer": "جهة إصدار بيانات الاعتماد المحددة مسبقًا", + "private": "خاص", + "public": "عام", + "readonlyTitle": "بوابة الوصول", + "referredByMember": "تمت الإشارة إليه بواسطة العضو", + "referredByMemberInfo": "سيتم السماح فقط للمستخدمين الذين تمت إحالتهم من قبل أحد أعضاء المجتمع بالانضمام", + "subscriptionComingSoon": "نموذج الاشتراك قادم قريبا.", + "title": "تحديث بوابة الوصول", + "tokenBalance": "رصيد الرمز", + "tokenComingSoon": "{token} (قريبا)", + "tokenNeuronHolder": "{token} حامل الخلايا العصبية", + "tokenPayment": "{token} الدفع", + "tokenPaymentInfo": "يمكن للمستخدمين الانضمام فقط إذا قاموا بدفع مبلغ بالرموز {token}.", + "uniquePerson": "شخص فريد من نوعه", + "uniquePersonInfo": "سيتم السماح فقط للمستخدمين الذين قدموا دليلاً فريدًا على هويتهم بالانضمام", + "uniquePersonInfo1": "ماذا لو لم يكن لدي بيانات اعتماد قابلة للتحقق؟", + "uniquePersonInfo2": "إذا لم تقم بذلك، فما عليك سوى التوجه إلى DecideAI، وتسجيل الدخول باستخدام نفس هوية الإنترنت التي استخدمتها للوصول إلى OpenChat واتبع التعليمات لإثبات شخصيتك الفريدة.", + "uniquePersonInfo3": "إن القيام بذلك أمر بسيط، ولا يستغرق سوى بضع دقائق ولا يلزم القيام به إلا مرة واحدة. عند الانتهاء من العملية، ارجع إلى هنا وانقر على \"التحقق\".", + "verify": "يؤكد", + "visibility": "وصول" + }, + "account": "حساب", + "accounts": "الحسابات", + "accountSuspended": "تم تعليق الحساب", + "activity": { + "anon": "مستخدم مجهول", + "crypto": "{username} أرسل لك رسالة مشفرة", + "mention": "{username} ذكرك", + "missingMessage": "محتوى الرسالة غير متاح", + "navLabel": "تغذية النشاط", + "p2pSwapAccepted": "لقد تم قبول عرض المبادلة الخاص بك بواسطة {username}", + "pollVote": "صوت {username} على رسالة استطلاع الرأي الخاصة بك", + "pollVoteN": "صوت {username} و{number} آخرون على رسالة استطلاع الرأي الخاصة بك", + "quoteReply": "{username} اقتباس رد على رسالتك", + "reactionN": "تفاعل {username} و{n} آخرون مع رسالتك", + "reactionOne": "{username} رد على رسالتك", + "reactionTwo": "تفاعل {username} و{other} مع رسالتك", + "tipN": "{username} و{n} آخرون تركوا تلميحًا بشأن رسالتك", + "tipOne": "{username} ترك تلميحًا بشأن رسالتك", + "tipTwo": "ترك {username} و{other} تلميحًا بشأن رسالتك", + "title": "تغذية النشاط" + }, + "addedBy": "أضاف {changedBy} {changed} إلى {level}", + "admin": "مسؤل", + "advanced": "متقدم", + "airdropWarning": "لكي تتأهل لعملية الإنزال الجوي التالية، يجب أن تكون عضوًا في قناة **{channelName}** في مجتمع **{communityName}**. [انقر هنا للانضمام.]({url})", + "alsoCanisterId": "أيضا معرف العلبة الخاص بك", + "andNMore": "و{n} المزيد", + "appearance": "مظهر", + "apply": "يتقدم", + "architecture": "بنيان", + "archiveChat": "أرشيف الدردشة", + "archiveChatFailed": "فشل في أرشفة الدردشة", + "areYouSure": "هل أنت متأكد؟", + "attachFile": "إرفاق صورة/فيديو/ملف", + "avatarTooBig": "الصورة الرمزية المحددة كبيرة جدًا", + "avatarUpdated": "تم تحديث الصورة الرمزية بنجاح", + "avatarUpdateFailed": "فشل تحديث الصورة الرمزية", + "backToResults": "خلف", + "bio": "السيرة الذاتية للمستخدم", + "blocked": "مسدود", + "blockedBy": "{changedBy} تم حظر {changed}", + "blockUser": "حظر المستخدم", + "blockUserFailed": "غير قادر على حظر المستخدم", + "blockUserSucceeded": "لقد تم حظر المستخدم", + "cancel": "يلغي", + "cancelInvite": "إلغاء الدعوة", + "cancelInviteFailed": "خطأ في إلغاء الدعوة", + "cancelInviteSucceeded": "تم إلغاء الدعوة", + "challenge": { + "alreadyRegistered": "يرجى الإغلاق والمحاولة مرة أخرى لاحقًا", + "failed": "فشل التحدي. يرجى المحاولة مرة أخرى.", + "prompt": "اكتب الأحرف التي تراها", + "throttled": "الخدمة مشغولة - يرجى الإغلاق والمحاولة مرة أخرى لاحقًا", + "title": "أثبت أنك لست روبوتًا" + }, + "change": "يتغير", + "changeTheme": "تغيير الثيم", + "channelAlreadyExists": "توجد قناة بهذا الاسم بالفعل", + "chatFrozen": "لقد تم تجميد هذه الدردشة", + "chatFrozenBy": "تم تجميد الدردشة بواسطة {frozenBy}", + "chatMenu": "قائمة الدردشة", + "chatNotFound": "عذرا - لم نتمكن من العثور على الدردشة المحددة", + "chats": "الدردشات", + "chatSummary": { + "mentions": "{count} إشارات غير مقروءة", + "unread": "{count} رسالة غير مقروءة" + }, + "chatUnfrozenBy": "تم إلغاء تجميد الدردشة بواسطة {unfrozenBy}", + "chatWith": "الدردشة مع {username}", + "checkBackLater": "يرجى المحاولة مرة أخرى لاحقًا.", + "chitBalance": "رصيد CHIT", + "chitReferralRewardReason": { + "diamond": "قام المستخدم الذي أحلته بالترقية إلى Diamond", + "lifetime_diamond": "قام المستخدم الذي أحلته بالترقية إلى Lifetime Diamond", + "unique_person": "لقد أثبت المستخدم الذي أشرت إليه شخصيته الفريدة" + }, + "chooseAStorageLevel": "اختر مستوى تخزين يصل إلى الحد الأقصى وهو 1 جيجابايت", + "chooseMembers": "اختيار الأعضاء", + "chooseReaction": "اختر رد فعل", + "chooseTransfer": "يمكنك الدفع مقابل ما يصل إلى 1 جيجابايت من التخزين عبر تحويل ICP.", + "chooseUpgrade": "يمكنك المطالبة بـ 100 ميغابايت من التخزين المجاني عن طريق تلقي رمز التحقق عبر رسالة نصية قصيرة، أو يمكنك الدفع مقابل ما يصل إلى 1 جيجابايت من التخزين عبر تحويل ICP.", + "clearDataCache": "مسح البيانات المخزنة مؤقتًا", + "clearDataCacheInfo": "في بعض الظروف، قد تتلف البيانات التي نخزنها مؤقتًا على جهازك وتسبب مشكلات. وكحل أخير، قد نطلب منك مسح تلك البيانات المخزنة مؤقتًا. يُرجى الاستمرار في الإبلاغ عن أي مشكلات حتى إذا تم حلها عن طريق مسح البيانات المخزنة مؤقتًا.", + "close": "يغلق", + "collapse": "ينهار", + "communities": { + "addToFavourites": "أضف إلى المفضلة", + "adult": "بالغ", + "autoExpand": "توسيع قائمة المجتمعات تلقائيًا", + "back": "خلف", + "browseChannels": "تصفح القنوات", + "channelCount": "القناة(القنوات)", + "channelPlaceholder": "أدخل اسم القناة", + "channels": "القنوات", + "channelsInfo": "يجب عليك إنشاء قناة عامة واحدة أو أكثر. سيتم تسجيل أي مستخدم ينضم إلى مجتمعك تلقائيًا في هذه القنوات. يمكنك دائمًا تغيير ذلك لاحقًا.", + "chooseCommunity": "اختر المجتمع", + "communityLabel": "مجتمع", + "confirmDeleteUserGroup": "هل أنت متأكد أنك تريد حذف هذه المجموعة من المستخدمين؟", + "convert": "تحويل إلى المجتمع", + "convertButton": "يتحول", + "converted": "لقد تم تحويل مجموعتك إلى مجتمع.", + "convertInfo": "يمكننا الاهتمام بتحويل مجموعتك إلى مجتمع.\n\nهذه عملية لا رجعة فيها وستؤدي إلى ما يلي:\n\n* تجميد المجموعة الحالية\n* إنشاء مجتمع جديد بنفس اسم المجموعة\n* إضافة قناة عامة إلى المجتمع بنفس اسم المجموعة\n* إضافة جميع أعضاء المجموعة كأعضاء في المجتمع\n* نسخ سجل الرسائل الكامل من المجموعة إلى القناة\n* حذف المجموعة الحالية\n* إبلاغ جميع أعضاء المجموعة بأنها قد تم تحويلها إلى مجتمع\n\nقد تستغرق هذه العملية بضع دقائق حسب حجم المجموعة لذا يرجى التحلي بالصبر.", + "create": "إنشاء مجتمع", + "createChannel": "قناة جديدة", + "created": "لقد تم إنشاء مجتمعك بنجاح", + "default": "تقصير", + "defaultInfo": "سيتم إضافة المستخدمين المنضمين إلى هذا المجتمع تلقائيًا إلى جميع القنوات العامة", + "delete": "حذف المجتمع", + "deleteMessage": "هل تريد حقًا حذف هذا المجتمع؟", + "description": "وصف", + "descriptionPlaceholder": "أخبرنا ما هو مجتمعك؟", + "details": "تفاصيل", + "directChats": "المحادثات المباشرة", + "edit": "تحرير المجتمع", + "embed": "إضافة قناة مضمنة", + "enterUserGroupName": "أدخل اسم مجموعة المستخدم", + "errors": { + "convertFailed": "نأسف ولكن لم نتمكن من تحويل مجموعاتك إلى مجتمع", + "createGroupFailed": "نأسف لأننا لم نتمكن من إنشاء مجموعة المستخدمين", + "deleteFailed": "نأسف لأننا لم نتمكن من حذف هذا المجتمع", + "deleteUserGroupFailed": "نأسف لأننا لم نتمكن من حذف مجموعة المستخدمين", + "failure": "نأسف لأننا لم نتمكن من إنشاء مجتمعك", + "importFailed": "نأسف لأننا لم نتمكن من استيراد مجموعتك", + "inviteUsers": "نأسف لأننا لم نتمكن من دعوة هؤلاء المستخدمين", + "joinFailed": "نأسف لعدم تمكننا من ضمك إلى هذا المجتمع", + "leaveFailed": "نأسف لأننا لم نتمكن من إزالتك من هذا المجتمع", + "name_taken": "عذرا - يوجد بالفعل مجتمع عام أو مجموعة بهذا الاسم", + "saveFailed": "نأسف لعدم تمكننا من حفظ تحديثاتك", + "updateGroupFailed": "نأسف لعدم تمكننا من تحديث مجموعة المستخدمين", + "userGroupNameTaken": "نأسف لأن اسم مجموعة المستخدم قد تم أخذه بالفعل" + }, + "explore": "استكشاف المجتمعات", + "exploreMobile": "يستكشف", + "favourites": "المفضلة", + "filters": "مرشحات المجتمع", + "flags": "الأعلام", + "general": "عام", + "goto": "انتقل إلى المجتمع", + "groupChats": "محادثات جماعية", + "hideLeftNav": "انهيار قائمة المجتمعات", + "imageLabel": "صورة البانر/الصورة الرمزية", + "import": "استيراد إلى المجتمع", + "importBtn": "يستورد", + "invite": "يدعو", + "join": "ينضم", + "joinCommunity": "انضم إلى المجتمع", + "leave": "مغادرة المجتمع", + "leaveMessage": "هل تريد حقًا مغادرة هذا المجتمع؟", + "loadMore": "تحميل المزيد...", + "mainMenu": "القائمة الرئيسية", + "memberCount": "أعضاء)", + "members": "أعضاء", + "mustHaveOneChannel": "يجب عليك إنشاء قناة عامة واحدة على الأقل", + "mustHaveUniqueChannels": "لا يمكنك الحصول على أسماء قنوات مكررة", + "muteAllChannels": "كتم صوت جميع القنوات", + "name": "اسم", + "namedUserGroupMembers": "**{name}** أعضاء المجموعة", + "namePlaceholder": "أدخل اسم المجتمع", + "next": "التالي", + "noBlankChannels": "يجب أن يكون لجميع القنوات اسم", + "noInvalidChannels": "يجب أن تكون جميع أسماء القنوات بين حرفين {min} و{max}", + "noMatch": "لم يتم العثور على مجتمعات مطابقة", + "noOwned": "أنت لست مالكًا لأي مجتمعات", + "noSpaces": "لا يسمح بالمسافات", + "noUserGroups": "لم يتم العثور على مجموعات المستخدمين", + "offensive": "جارح", + "otherChannels": "القنوات العامة الأخرى", + "pleaseWait": "يرجى الانتظار - قد يستغرق هذا بعض الوقت", + "preview": "معاينة", + "primaryLanguage": "اللغة الأساسية", + "publicChannels": "القنوات العامة", + "refineSearch": "حاول تحسين بحثك", + "removeFromFavourites": "إزالة من المفضلة", + "rules": "قواعد", + "saveChanges": "حفظ التغييرات", + "saved": "لقد تم تحديث مجتمعك بنجاح", + "search": "البحث عن المجتمعات", + "searchGroups": "البحث عن قنوات المجتمع", + "searchUserGroups": "البحث عن مجموعات المستخدمين...", + "tags": { + "all": "الجميع", + "crypto": "العملات المشفرة", + "gaming": "الألعاب", + "metaverse": "ميتافيرس", + "music": "موسيقى", + "photography": "التصوير الفوتوغرافي", + "sport": "رياضة" + }, + "underReview": "تحت المراجعة", + "updateChannelPlaceholder": "تحديث اسم القناة", + "userGroupMembers": "أعضاء مجموعة المستخدمين", + "userGroupName": "اسم مجموعة المستخدم", + "userGroups": "مجموعات المستخدمين", + "usersInvited": "لقد تمت دعوة المستخدمين إلى مجتمعك", + "visibility": "الرؤية" + }, + "communityDisplayNameRules": "فقط ضمن المجتمع المحدد", + "communityFrozen": "المجتمع متجمد", + "communityMembers": "أعضاء المجتمع", + "communityTheme": "موضوع المجتمع", + "communityUnfrozen": "المجتمع غير مجمد", + "confirmLeaveGroup": "هل تريد حقًا مغادرة هذا {level}؟", + "confirmMakeGroupPrivate": "هل تريد بالتأكيد جعل هذا {level} خاصًا؟", + "confirmMakeGroupPublic": "هل تريد بالتأكيد جعل هذا {level} عامًا؟\n\nستظل الرسائل الموجودة خاصة، وستكون جميع الرسائل الجديدة عامة.", + "confirmMembers": "تأكيد الأعضاء", + "congratulations": "مبروك!", + "copiedToClipboard": "تم نسخ عنوان الحساب إلى الحافظة!", + "copy": "ينسخ", + "copyGroupUrl": "نسخ عنوان URL للمجموعة", + "copyInviteCode": "نسخ رمز الدعوة", + "copyMessageUrl": "نسخ عنوان الرسالة", + "copyToClipboard": "نسخ إلى الحافظة", + "createNewGroup": "إنشاء مجموعة جديدة", + "cryptoAccount": { + "auto": "اوتوماتيكي", + "autoInfo": "قم بتنظيم محفظتك تلقائيًا. سيتم عرض أي رمز به رصيد رمزي غير صفري أو بقيمة دولارية أكبر من الحد الأدنى لقيمة الدولار المدخلة أدناه", + "comingSoon": "(قريباً)", + "configureWallet": "قم بتكوين محفظتك", + "configureWalletFailure": "نأسف لأننا لم نتمكن من حفظ تكوين محفظتك", + "hideZeroBalance": "اظهر اقل", + "loadMoreTransactions": "تحميل المزيد", + "manage": "تكوين المحفظة", + "manageHeader": "إدارة حساب {symbol}", + "manual": "يدوي", + "manualInfo": "قم بتنظيم محفظتك يدويًا من خلال تحديد الرموز التي تريد رؤيتها بالضبط", + "minDollarPlaceholder": "الحد الأدنى لرصيد الدولار", + "receive": "يستلم", + "receiveToken": "استقبل {symbol}", + "remove": "إزالة من المحفظة", + "scan": "مسح رمز الاستجابة السريعة", + "search": "البحث عن الرموز", + "send": "يرسل", + "sendFailed": "فشل في إرسال {symbol}", + "sendSucceeded": "لقد تم إرسال {symbol} الخاص بك بنجاح!", + "sendTarget": "أدخل عنوانًا لإرساله إليه", + "sendToken": "أرسل {symbol}", + "shortBalanceLabel": "توازن", + "shortRemainingBalanceLabel": "الرصيد المتبقي", + "showZeroBalance": "اظهر المزيد", + "swap": "تبديل", + "token": "الرمز المميز", + "tokens": "الرموز", + "topUp": "إعادة التعبئة", + "topUpBlurb": "لإعادة التعبئة، قم ببساطة بتحويل مبلغ {token} إلى الحساب أعلاه.", + "total": "المجموع", + "transactionError": "نأسف لأننا لم نتمكن من تحميل معاملاتك", + "transactionHeaders": { + "amount": "كمية", + "from": "من", + "id": "بطاقة تعريف", + "timestamp": "الطابع الزمني", + "to": "ل", + "type": "يكتب" + }, + "transactions": "المعاملات", + "unknownTransactionType": "مجهول" + }, + "currentChitBalance": "الرصيد الحالي لـ CHIT", + "currentLimit": "الحد الأقصى الحالي للتخزين لديك هو {limit} جيجابايت ورصيد حسابك هو {balance} ICP.", + "currentUsage": "استخدام التخزين الحالي", + "dailyChit": { + "alreadyClaimed": "عد غدًا لتمديد سلسلة انتصاراتك!", + "available": "اطلب CHIT الخاص بك ومدد سلسلة انتصاراتك!", + "claim": "اطالب بـ CHIT!", + "comeback": "عد إلى {time}", + "extendStreak": "تمديد السلسلة", + "failedToClaim": "نأسف لعدم تمكننا من المطالبة بـ CHIT اليومي الخاص بك", + "hideWhenClaimed": "إخفاء أيقونة المطالبة CHIT ⚡️ بعد المطالبة كل يوم", + "info": "كلما حافظت على سلسلة نقاطك لفترة أطول، كلما زادت نقاط CHIT اليومية التي يمكنك المطالبة بها!", + "title": "حافظ على سلسلة انتصاراتك", + "utcInfo": "يتم تمثيل خطوط CHIT ونوافذ المطالبة باستخدام UTC، لذا قد تجد أنه من الأسهل عرض التقويم الخاص بك بتوقيت UTC بدلاً من منطقتك الزمنية المحلية.", + "utcMode": "عرض بالتوقيت العالمي المنسق", + "viewStreak": "عرض السلسلة" + }, + "dark": "مظلم", + "days": "أيام", + "deleteFailed": "غير قادر على حذف رسالتك", + "deleteGroup": "حذف {level}", + "deleteGroupFailure": "غير قادر على حذف {level}", + "deleteGroupSuccess": "تم حذف {level} بنجاح", + "deleteMessage": "يمسح", + "deleteMessageAndReport": "حذف والإبلاغ", + "deleteMessageForMe": "حذف بالنسبة لي", + "demoteFailed": "غير قادر على التخفيض إلى {role}", + "demoteTo": "تخفيض إلى {role}", + "directChatCreatedAt": "بدأت الدردشة", + "disableNotificationsMenu": "تعطيل الإشعارات", + "disappearingMessages": { + "disabled": "تم تعطيل الرسائل المختفية", + "disabledBy": "{changedBy} تم تعطيل الرسائل المختفية", + "durationMinutes": "{duration} دقيقة", + "label": "الرسائل المختفية", + "oneMinute": "1 دقيقة", + "summary": "ستختفي الرسائل بعد {duration}", + "timeUpdated": "تم ضبط وقت اختفاء الرسالة على {duration}", + "timeUpdatedBy": "{changedBy} اضبط وقت اختفاء الرسالة على {duration}" + }, + "discard": "ينبذ", + "discoverMoreGroups": "اكتشف المزيد من المجموعات", + "displayName": "اسم العرض", + "displayNameRules": "خياري", + "displayWidth": "عرض العرض", + "doubleClickReply": "انقر نقرًا مزدوجًا فوق الرسالة للرد/التعديل", + "doubleTapReply": "انقر نقرًا مزدوجًا على الرسالة للرد/التعديل", + "downloadFile": "تنزيل {name}", + "dropFile": "قم بإسقاط الملف هنا", + "durationDays": "{duration} يوم", + "durationHours": "{duration} ساعة", + "durationMins": "{duration} دقيقة", + "edited": "تم التعديل", + "editMessage": "يحرر", + "enableNotifications": "هل ترغب في تفعيل الإشعارات؟", + "enableNotificationsMenu": "تمكين الإشعارات", + "enjoyYourStorage": "الآن يمكنك الاستمتاع بنشر الصور والفيديوهات!", + "enterBio": "أخبرنا شيئا عن نفسك", + "enterCaption": "أدخل التسمية التوضيحية", + "enterInviteCode": "أدخل رمز الدعوة الخاص بك", + "enterMessage": "أدخل الرسالة...", + "enterToSend": "مفتاح الإدخال يرسل رسالة", + "errorBlurb": "عذراً، واجهنا خطأ غير متوقع. يرجى محاولة إعادة تحميل OpenChat.", + "errorEditingMessage": "حدث خطأ غير متوقع أثناء تحرير رسالتك", + "errorLoadingChats": "غير قادر على تحميل الدردشات الخاصة بك", + "errorSearchingForUser": "حدث خطأ غير متوقع أثناء البحث عن المستخدمين", + "errorSendingMessage": "حدث خطأ غير متوقع أثناء إرسال رسالتك", + "excessiveLinksNote": "تتم إخفاء معاينات الروابط للرسائل التي تحتوي على أكثر من 5 روابط.", + "expand": "يوسع", + "expandDeletedMessages": "توسيع الرسائل المحذوفة في هذه الدردشة", + "exploreGroups": "استكشاف المجموعات", + "externalContent": { + "disclaimer": "هذا المحتوى خارجي لـ OpenChat. يجب الإبلاغ عن أي مشكلات تتعلق بهذا المحتوى إلى مالك المجتمع", + "error": "فشل تحميل المحتوى الخارجي", + "frozen": "تم تجميد المحتوى الخارجي بواسطة مشرف المنصة", + "initialising": "تهيئة المحتوى الخارجي", + "label": "المحتوى الخارجي", + "name": "رابط المحتوى الخارجي" + }, + "failedToCopyLinkToClipboard": "غير قادر على نسخ الرابط إلى الحافظة", + "failedToCopyToClipboard": "غير قادر على النسخ إلى الحافظة. {account}", + "failedToCopyUrlToClipboard": "غير قادر على نسخ عنوان URL ({url}) إلى الحافظة", + "failedToFreezeCommunity": "فشل في تجميد المجتمع", + "failedToFreezeGroup": "فشل تجميد المجموعة", + "failedToLeaveGroup": "حدث خطأ أثناء محاولة إزالتك من {level}", + "failedToShareLink": "فشل في مشاركة الرابط", + "failedToShareMessage": "غير قادر على مشاركة هذه الرسالة", + "failedToSuspendUser": "فشل تعليق المستخدم", + "failedToUnfreezeCommunity": "فشل في إلغاء تجميد المجتمع", + "failedToUnfreezeGroup": "فشل في إلغاء تجميد المجموعة", + "failedToUnsuspendUser": "فشل إلغاء تعليق المستخدم", + "faq": { + "airdrop_a": "في أبريل، تم إرسال مليون رمز CHAT للمستخدمين المؤهلين من فئة Diamond. راجع اقتراح الحركة هذا للحصول على التفاصيل.

في وقت لاحق من هذا العام، سيقدم فريق تطوير OpenChat اقتراحًا لتنفيذ مخطط مكافآت للمستخدمين. سيتمكن مستخدمو فئة Diamond من كسب رموز CHAT بناءً على درجة سمعتهم داخل OpenChat. لم يتم تحديد كيفية حساب السمعة بالضبط ولكن سيتم بذل جهود كبيرة لضمان معاقبة مرسلي البريد العشوائي. نريد تشجيع الدردشة الحقيقية وعدم توفير فرصة للروبوتات أو المستخدمين عديمي الضمير \"لجمع\" الرموز.", + "airdrop_q": "هل سيكون هناك انزال جوي؟", + "android_app_a": "أما بالنسبة لنظام iOS (انظر أعلاه)، فإن تطبيق الويب OpenChat يعمل بالفعل على نظام Android ويمكنك \"إضافة إلى الشاشة الرئيسية\" من قائمة المتصفح. وهذا يمنحك أيقونة قياسية لفتح التطبيق تبدو وكأنها تطبيق أصلي بدون شريط عنوان URL. كما أن دعم تطبيق الويب التقدمي (PWA) جيد جدًا على نظام Android. ومثله كمثل نظام iOS، فهو يدعم إشعارات الويب الفورية ولكنه يدعم أيضًا قراءة جهات الاتصال من الهاتف (إذا منحت الإذن في كل حالة). وبعيدًا عن ذلك، فإن دعم WebRTC أفضل بكثير. وبالتالي فإن حالة إنتاج تطبيق أصلي لنظام Android أقل إقناعًا وستأتي بعد تطبيق iOS الأصلي. وتنطبق نفس الاعتبارات على بناء تطبيق Android باعتباره iOS من حيث استهداف غلاف أصلي رقيق حول تطبيق ويب أساسي، وفيما يتعلق بالشهادة على متجر تطبيقات Android.", + "android_app_q": "متى سيكون هناك تطبيق أندرويد؟", + "buychat_a": "يمكنك شراء CHAT باستخدام USDC على Helix Markets. بخلاف ذلك، يجب عليك أولاً شراء ICP والذي يمكنك بعد ذلك تبديله بـ CHAT على البورصات اللامركزية المختلفة (DEXes).DFINITY تضيف الدعم لـ واجهة برمجة تطبيقات Rosetta لرموز ICRC1 (التي تتضمن CHAT) مما يجعلها أسهل بكثير في التكامل مع البورصات المركزية (CEXes). بمجرد حدوث ذلك، من المرجح أن يصبح CHAT متاحًا للتداول على CEXes مقابل ICP أو USDT.", + "buychat_q": "كيف يمكنني شراء رموز CHAT؟", + "content_a": "للحصول على التفاصيل الكاملة حول محتوى موقعنا وإرشادات التعديل انظر هنا.", + "content_q": "ما هو نوع المحتوى المسموح به؟", + "diamond_a": "يمكنك الترقية إلى العضوية الماسية للوصول إلى ميزات إضافية أو محسنة داخل OpenChat. انظر هنا لمقارنة الميزات والترقية.", + "diamond_q": "ما هي العضوية الماسية؟", + "header": "الأسئلة الشائعة", + "info_a": "انتقل إلى صفحتنا الرئيسية وابحث عن روابط لـ features وroadmap وwhitepaper وarchitecture وblog في OpenChat", + "info_q": "أين يمكنني معرفة المزيد عن OpenChat؟", + "ios_app_a": "من الجدير بالذكر أن تطبيق الويب OpenChat يعمل بالفعل على نظام التشغيل iOS ويمكنك \"إضافة إلى الشاشة الرئيسية\" من قائمة المتصفح. يمنحك هذا أيقونة قياسية لفتح التطبيق تبدو وكأنها تطبيق أصلي بدون شريط عنوان URL. كما أضافت Apple مؤخرًا دعمًا لإشعارات الدفع عبر الويب وهو أمر بالغ الأهمية لتطبيق المراسلة إذا كنت غير متصل بالإنترنت أو لا تستخدم التطبيق. بشكل عام، يتخلف دعم Apple لما يسمى بتطبيقات الويب التقدمية (PWAs) عن Android لأن تطبيقات الويب التقدمية تهدد هيمنة متجر التطبيقات. يفتقر إلى الدعم (مقارنة بتطبيقات الويب التقدمية لنظام التشغيل Android) لقراءة جهات اتصال الهاتف وتنفيذ WebRTC مليء بالأخطاء (مطلوب لمكالمات الصوت والفيديو من نظير إلى نظير). وبسبب هذه القيود، فإن إنتاج تطبيق iOS أصلي يعد على رأس جدول أعمالنا نسبيًا. على الرغم من أنه في ظاهره \"تطبيق دردشة فقط\"، إلا أنه في الواقع معقد إلى حد ما مع قاعدة بيانات كبيرة. الحل المثالي بالنسبة لنا هو تغليف تطبيق OpenChat على الويب في تطبيق أصلي رقيق يتواصل مع واجهات برمجة التطبيقات الأصلية للهاتف بطريقة تجعل الجزء الأكبر من قاعدة التعليمات البرمجية مشتركًا دون المساس بتجربة التطبيق الأصلي. نحن نبحث في هذا المجال ولكن لم يتضح بعد ما إذا كان هذا ممكنًا أو مدى صعوبة ذلك. هناك عقبة أخرى محتملة تتمثل في عملية اعتماد متجر التطبيقات. من خلال جعل التطبيق الأساسي تطبيق ويب، يمكننا إجراء تغييرات على تطبيق OpenChat iOS دون المرور بعملية الموافقة التي قد لا تكون مقبولة. ومع ذلك، إذا تم حل هذه المشكلات، فمن الممكن أن يكون تطبيق iOS جاهزًا في غضون بضعة أشهر. وإلا، إذا احتجنا إلى إعادة كتابة (ثم صيانة) تطبيق OpenChat iOS الأصلي من الصفر، فسوف يستغرق هذا وقتًا أطول بكثير...", + "ios_app_q": "متى سيكون هناك تطبيق iOS؟", + "menu": "التعليمات", + "referral_rewards_a": "يمكنك كسب مكافآت CHIT عن كل مستخدم تحيله ويستمر في التحقق من شخصيته الفريدة أو يصبح عضوًا في Diamond. إذا استمر المستخدم الذي تحيله في الحصول على عضوية Diamond، فستكسب 5000 CHIT، وإذا أثبت شخصيته الفريدة، فستكون 10000 CHIT وإذا حصل على Diamond مدى الحياة، فستكون 15000 CHIT. إذا قاموا لاحقًا بإجراء \"تحقق\" بقيمة أعلى، فستحصل على الفرق في CHIT. يمكنك العثور على رابط الإحالة الخاص بك في قسم \"دعوة الأصدقاء / العائلة\" في \"إعدادات الملف الشخصي\" من القائمة الرئيسية.

كيف يمكنك القيام بذلك؟ هناك الكثير من الطرق. يمكنك إخبار الأصدقاء والعائلة والتغريد حول OpenChat وإنشاء مقطع فيديو حول OpenChat يصف ما تحبه فيه ومشاركته على TikTok أو YouTube. يمكنك البث على Twitch أو كتابة منشور مدونة أو إنشاء بودكاست أو استضافة مساحة Twitter أو حتى عقد لقاء حقيقي. نحن نتطلع إلى رؤية جميع الأفكار الإبداعية التي توصلتم إليها لنشر الكلمة!", + "referral_rewards_q": "ما هي مكافآت الإحالة؟", + "security_a": "باختصار، يوفر جهاز الكمبيوتر المتصل بالإنترنت ضمانات أمان قوية للغاية. ومع ذلك، هناك منطقة ضعف معروفة جيدًا تعمل شركة Dfinity على معالجتها. فمع بذل بعض الجهد، يمكن لمزود عقدة مارق تثبيت نسخة مخترقة من برنامج العقدة مما يسمح له باعتراض وقراءة الرسائل الواردة وقراءة الذاكرة مباشرة. ومع ذلك، بمجرد توفر SEV-SNP على أجهزة العقدة، يمكن للمستخدمين أن يكونوا على ثقة تامة من أنه باستثناء متلقي رسائلهم، لن تكون بياناتهم متاحة لأي شخص سوى أنفسهم. وفي وقت لاحق، سننفذ تشفيرًا من طرف إلى طرف بحيث يتم تخزين البيانات فعليًا في شكل مشفر في ذاكرة علبة وبالتالي لن تكون متاحة لمشغلي العقدة المارقين بغض النظر عن وجود SEV-SNP. من المحتمل أن يتضمن هذا بعض القيود، مثل عدم القدرة على البحث في سجل رسائلك، لذا يمكنك اختيار الاشتراك في الأمان الشامل للدردشات المحددة.", + "security_q": "هل رسائلي آمنة؟", + "send_tokens_a": "عند إرسال الرموز كرسالة دردشة، يتم إجراء التحويل بواسطة دفتر الأستاذ للرمز المحدد والذي يتحمل رسومًا إلزامية. يتم حرق هذه الرسوم، مما يقلل قليلاً من المعروض من الرموز. الرسوم الخاصة بالرموز المختلفة التي ندعمها هي كما يلي:
  • CHAT 0.001
  • ICP 0.0001
  • ckBTC 0.0000001
  • SNS1 0.00001
", + "send_tokens_q": "لماذا يتم فرض رسوم معاملة عليّ عند إرسال الرموز؟", + "shortcuts_a": "هناك العديد من أوامر الاختصار التي يمكنك استخدامها من مربع إدخال إدخال الرسالة.

`/poll` - لإنشاء استطلاع رأي جديد
`/search xyz` - للبحث في الدردشة الحالية عن المصطلح \"xyz\"
`/gif kittens` - للبحث عن صور متحركة للقطط
`/icp 0.1` - لإرسال 0.1 ICP إلى شخص ما
`/faq question_id*` - لإنشاء ونشر رابط منسق لسؤال أسئلة شائعة محدد
`/diamond` - لإنشاء ونشر رابط منسق بتفاصيل عضوية Diamond

يمكن أيضًا استخدام الأوامر التي تقبل وسيطة بدون الوسيطة.

* معرفات أسئلة الأسئلة الشائعة المتاحة هي: airdrop، التصويت، المحفظة، buychat، send_tokens، diamond، ios_app، android_app، find_groups، style_messages، storage، security، roadmap، Shortcuts", + "shortcuts_q": "ما هي أوامر الاختصار المتاحة؟", + "storage_a": "الرسائل النصية لا تشغل مساحة كبيرة وبالتالي تكلفنا القليل نسبيًا. الصور تشغل مساحة أكبر بكثير وتتراكم التكاليف. الآن بعض المعلومات الأساسية عن نظام OpenChat. كل مستخدم لديه علبة خاصة به تحتوي على جميع الرسائل الخاصة بمحادثاته المباشرة. كل مجموعة لديها أيضًا علبة خاصة بها تحتوي على جميع رسائل المجموعة. ومع ذلك، نقوم بتخزين أي بيانات ملف مرتبطة بالرسائل، مثل الصور والفيديو، في OpenStorage. كل مستخدم لديه بدل متجدد للبيانات وعندما يتم إرسال رسالة تخرج أي بيانات ملف من بدل المرسل. ينطبق هذا أيضًا على الرسائل المرسلة إلى المجموعات، لذلك لا يوجد بدل بيانات جماعي فقط بدل فردي.

سيحصل كل مستخدم OpenChat على 0.1 جيجابايت من مساحة التخزين المجانية. يمكن للمستخدم بعد ذلك الترقية اختياريًا إلى Diamond لتلقي 1 جيجابايت من مساحة التخزين. عندما يصل المستخدم إلى حد التخزين الخاص به، تنتهي صلاحية أقدم ملفاته تلقائيًا ويتم استردادها. وهذا يعني أنه لا ينبغي للمستخدم أبدًا أن ينفد التخزين لديه. لتقليل تكاليفنا، سيتم أيضًا استرداد ملفات مستخدمي المستوى المجاني بعد تسعين يومًا بغض النظر عن استخدامهم للتخزين. لاحظ أن الرسالة نفسها، بما في ذلك أي تعليق أو صورة مصغرة، لن يتم حذفها أبدًا وبالتالي سيتم الاحتفاظ بسجل الرسائل.", + "storage_q": "كيف يعمل التخزين؟", + "style_messages_a": "يمكنك إضافة فواصل الأسطر باستخدام shift-enter كما أننا ندعم أيضًا معظم جوانب Markdown. اتبع هذا الرابط للحصول على ملخص جيد لقواعد لغة Markdown.", + "style_messages_q": "هل يمكنني تصميم الرسائل؟", + "translation_a": "يتم توفير ترجماتنا بواسطة Google translate افتراضيًا وفي بعض الأحيان لا يتوفر سياق كافٍ لـ Google للقيام بعمل جيد. غالبًا ما يتطلب الأمر متحدثًا أصليًا للغة لتوفير ترجمة أفضل. إذا قمت بتحديد إحدى اللغات غير الإنجليزية المدعومة في ملفك الشخصي، فستلاحظ وجود تبديل أيضًا في ملفك الشخصي لتنشيط وضع تصحيح الترجمة. مع تمكين هذا الوضع، سترى أيقونة ترجمة صغيرة بجوار كل تسمية داخل واجهة المستخدم التي تمت ترجمتها. إذا قمت بالنقر فوق هذا الرمز، فستتمكن بعد ذلك من إرسال تصحيح مقترح. حاول التوصل إلى اقتراح دقيق ويناسب واجهة المستخدم جيدًا. يمكنك التحقق من شكل اقتراحك في مكانه وتكرار الإجراء عدة مرات كما تريد. سيراجع الفريق اقتراحك وسيتم اعتماده إذا تمت الموافقة عليه. سيتم مكافأتك في الدردشة لكل تصحيح معتمد تقدمه. نحن نقدر حقًا أنك خصصت الوقت لتحسين ترجماتنا - لم نكن لنتمكن من القيام بذلك بدونك.", + "translation_q": "كيف يمكنني تصحيح خطأ الترجمة؟", + "voting_a": "انضم إلى مجموعة مقترحات OpenChat واقرأ تدوينة المدونة هذه والتي تقدم نظرة عامة حول حوكمة OpenChat وتحتوي على دليل خطوة بخطوة حول التصويت.", + "voting_q": "كيف أقوم بالتصويت على المقترحات؟", + "wallet_a": "عندما يتم إنشاء مستخدم OpenChat، فإنه يمتلك تلقائيًا حسابًا لمجموعة من الرموز (ICP وCHAT وckBTC وSNS1). سيكون هذا الحساب فارغًا في البداية ولكن يمكنك شحن الحساب عن طريق تحويل الرموز إلى عنوانه. بمجرد حصولك على بعض الرموز في محفظة OpenChat الخاصة بك، ستتمكن من إرسالها مباشرة إلى حساب أي مستخدم OpenChat آخر عبر نوع خاص من الرسائل. يمكنك أيضًا استخدام ICP (قريبًا CHAT) في محفظتك للترقية إلى عضوية Diamond. إذا كنت ترغب في إرسال الرموز إلى حساب آخر، فيمكنك القيام بذلك عن طريق فتح محفظتك من القائمة الرئيسية والنقر فوق رابط \"إرسال\". تمنحك هذه الشاشة خيار إرسال رموزك إلى أي عنوان آخر تختاره.", + "wallet_q": "كيف تعمل محفظتي؟" + }, + "faqUrlCopiedToClipboard": "تم نسخ عنوان URL للأسئلة الشائعة إلى الحافظة!", + "features": "سمات", + "filterMembers": "أعضاء الفلتر", + "followThread": "اتبع هذا الموضوع", + "followThreadFailed": "فشل متابعة الموضوع", + "fontSize": "حجم الخط", + "forward": "إلى الأمام", + "freezeCommunity": "تجميد المجتمع", + "freezeGroup": "مجموعة التجميد", + "giphyMessage": "رسالة جيف", + "goHome": "بيت", + "goToFirstMention": "انتقل إلى أول ذكر غير مقروء", + "goToLatestMessage": "انتقل إلى الرسالة الأخيرة", + "group": { + "addGroupPhoto": "أضف صورة {level}", + "addMembers": "إضافة أعضاء القناة", + "addMembersFailed": "حدث خطأ أثناء إضافة الأعضاء", + "addMembersPartialSuccess": "لم يتمكن من إضافة بعض الأعضاء", + "addMembersTab": "إضافة الأعضاء", + "addOrInviteUsers": "إضافة أو دعوة الأعضاء", + "advanced": "متقدم", + "back": "خلف", + "create": "إنشاء {level}", + "createTitle": "إنشاء {level} جديد", + "description": "{level} الوصف", + "details": "تفاصيل", + "edit": "تعديل {level}", + "editGroupPhoto": "تعديل الصورة {level}", + "externalUrl": "رابط خارجي", + "externalUrlPlaceholder": "عنوان URL الخارجي للمحتوى المراد تضمينه", + "externalUrlWarning": "يجب أن يكون من الممكن استضافة الموقع الخارجي في إطار مضمّن. وهذا أمر لا يمكن لـ OpenChat التحكم فيه.", + "getRulesFailed": "فشل في الحصول على قواعد {level}", + "groupInfo": "{level} معلومات", + "historyPrivateMessage": "الرسائل القديمة غير مرئية للأعضاء الجدد", + "image": "صورة {level}", + "inviteUsers": "دعوة أعضاء {level}", + "inviteUsersFailed": "حدث خطأ أثناء دعوة المستخدمين", + "inviteUsersTab": "دعوة المستخدمين", + "members": "أعضاء", + "name": "{level} الاسم", + "next": "التالي", + "privateGroup": "خاص {level}", + "privateGroupInfo": "لا يمكن للمستخدمين الانضمام إلى {level} بحرية. يجب أن تتم إضافتهم أو دعوتهم من قبل عضو مخول.", + "publicGroup": "عامة {level}", + "searchForCommunityMember": "البحث عن عضو المجتمع", + "searchForUser": "البحث عن مستخدم", + "shareTab": "يشارك", + "tooManyInvites": "يمكنك الحصول على 100 دعوة معلقة فقط", + "update": "تحديث {level}", + "usersInvited": "تمت إرسال رسالة الدعوة!", + "welcome": "مرحباً بكم في {groupName}" + }, + "groupAlreadyExists": "يوجد بالفعل {level} عام بهذا الاسم", + "groupAvatar": "{level} الصورة الرمزية", + "groupChangedBy": "{changedBy} غيّر {changed}", + "groupCreatedBy": "أنشأ {username} {level}", + "groupCreationFailed": "نأسف لأننا لم نتمكن من إنشاء مجموعتك", + "groupDesc": "{level} الوصف", + "groupDescTooLong": "وصف {level} طويل جدًا", + "groupDetails": "{level} التفاصيل", + "groupInviteChangedBy": "{changedBy} {changed} رابط دعوة المجموعة", + "groupName": "{level} الاسم", + "groupNameInvalid": "اسم {level} غير صالح", + "groupNameReserved": "تم حجز اسم {level}", + "groupNameTooLong": "اسم {level} طويل جدًا", + "groupNameTooShort": "اسم {level} قصير جدًا", + "groupRules": "قواعد المجموعة", + "groupRulesDisabled": "{changedBy} قام بتعطيل قواعد المجموعة", + "groupRulesEnabled": "{changedBy} قام بتفعيل قواعد المجموعة", + "groupRulesTooLong": "قواعد المجموعة طويلة جدًا", + "groupRulesTooShort": "لا يجب أن تكون قواعد المجموعة فارغة", + "groupUpdateFailed": "نأسف لعدم تمكننا من تحديث مجموعتك", + "groupVisibilityChangedBy": "أجرى {changedBy} تغييرات على إمكانية رؤية {level}", + "groupWithN": "{number} أعضاء", + "guestUser": "مستخدم ضيف", + "halloffame": { + "allTime": "كل الوقت", + "diamonds": "الماس", + "gameover": "انتهت اللعبة!", + "menu": "كبار المرجعين", + "start": "يبدأ", + "thisMonth": "هذا الشهر", + "username": "اسم المستخدم", + "users": "المستخدمون", + "value": "قيمة" + }, + "here": "هنا", + "hideAuthProviders": "إخفاء الخيارات", + "hideBlocked": "إخفاء الرسائل من المستخدمين الذين قمت بحظرهم", + "historyOffInfo": "لن يرى الأعضاء الجدد الرسائل التي تصل إليهم إلا بعد انضمامهم. 🤫", + "historyOnInfo": "سيكون سجل الدردشة الكامل مرئيًا للمنضمين الجدد. كن حذرًا الآن! 🤐", + "historyVisible": "سجل الدردشة المرئي", + "home": "بيت", + "homepage": "الصفحة الرئيسية", + "hotGroups": "المجموعات الساخنة", + "hours": "ساعات", + "howToBuyToken": "أحتاج إلى مساعدة في شراء {token}", + "human": { + "already": "مبروك! لقد تأكدت بالفعل من أنك شخص حقيقي وفريد من نوعه!", + "failed": "نأسف لعدم تمكننا من تأكيد امتلاكك لبيانات الاعتماد القابلة للتحقق من \"الشخص الفريد\". إذا لم تكن لديك هذه البيانات بعد، فاتبع الإرشادات أدناه وحاول مرة أخرى.", + "instruction": "لتتمكن من التأكيد مع OpenChat على أنك شخص حقيقي وفريد، سوف تحتاج إلى تقديم بيانات اعتماد \"الشخص الفريد\" القابلة للتحقق.", + "notVerified": "لم تقم بعد بالتحقق من كونك شخصًا حقيقيًا وفريدًا. قد ترغب في التحقق كإشارة إلى مدى مصداقيتك للمستخدمين الآخرين وكوسيلة للتأهل لعمليات الإنزال الجوي المستقبلية.", + "verification": "تَحَقّق", + "verified": "شخص فريد تم التحقق منه", + "verify": "يؤكد" + }, + "identity": { + "back": "خلف", + "credentialWarning": "يجب أن يكون لديك هوية إنترنت مرتبطة بحساب OpenChat الخاص بك لتأكيد امتلاكك لبيانات الاعتماد {name}. يجب أن يكون مصدر بيانات الاعتماد مرتبطًا بنفس هوية الإنترنت. إذا قمت بالتسجيل باستخدام مزود آخر، فستحتاج إلى إنشاء هوية إنترنت وربطها بحساب OpenChat الخاص بك قبل المتابعة.", + "failure": { + "alreadyLinked": "عذرًا ولكن يبدو أن هذا المدير مرتبط بالفعل بحساب OpenChat", + "link": "نأسف ولكن لم نتمكن من ربط هذا المدير بحساب OpenChat الخاص بك", + "login_approver": "نأسف لأننا لم نتمكن من تسجيل الدخول إلى موفر مصادقة OpenChat الحالي الخاص بك", + "login_initiator": "نأسف لعدم تمكننا من تسجيل الدخول إلى موفر المصادقة الجديد", + "loginApprover": "نأسف لأننا لم نتمكن من تسجيل الدخول إلى موفر مصادقة OpenChat الحالي الخاص بك", + "loginInitiator": "نأسف لعدم تمكننا من تسجيل الدخول إلى مزود الخدمة الذي اخترته", + "pollingError": "حدث خطأ غير متوقع أثناء انتظار تسجيل الدخول بالبريد الإلكتروني", + "principalMismatch": "يبدو أنك لم تقم بإعادة المصادقة مع نفس الموفر الذي قمت بتسجيل الدخول إليه حاليًا" + }, + "humanityWarning": "يجب أن يكون لديك هوية إنترنت مرتبطة بحساب OpenChat الخاص بك لتأكيد شخصيتك الفريدة. إذا قمت بالتسجيل باستخدام مزود آخر، فسوف تحتاج إلى إنشاء هوية إنترنت وربطها بحساب OpenChat الخاص بك قبل المتابعة.", + "link": "وصلة", + "linkedAccounts": { + "currentAccount": "تم تسجيل الدخول حاليًا باستخدام هذا المدير", + "linkAdvice": "لإضافة موفر تسجيل دخول جديد، قم أولاً بتسجيل الدخول باستخدام الموفر الجديد الذي تريد إضافته، ثم قم بتسجيل الدخول باستخدام الموفر الحالي وأخيرًا قم بربط الاثنين معًا.", + "linkAnother": "أضف آخر", + "section": "مقدمو خدمة تسجيل الدخول", + "start": "البدء" + }, + "linkIdentity": "هوية الرابط", + "linkTwoIdentities": "نحن الآن على استعداد لربط الهويتين معًا.", + "mismatch": "لا يتطابق هذا مع الحساب الرئيسي الذي قمت بتسجيل الدخول به حاليًا. هل أنت متأكد من أنك استخدمت نفس مزود المصادقة؟", + "proceed": "يتابع", + "signIn_approver": "قم بتسجيل الدخول باستخدام موفر الهوية الذي تستخدمه حاليًا", + "signIn_initiator": "قم بتسجيل الدخول باستخدام موفر الهوية الذي ترغب في ربطه بحساب OpenChat الخاص بك", + "signInCurrent": "قم بتسجيل الدخول باستخدام موفر الهوية الذي تستخدمه حاليًا", + "signInNext": "قم بإنشاء هوية الإنترنت التي ترغب في ربطها بحساب OpenChat الخاص بك أو قم بتسجيل الدخول بها", + "tryAgain": "حاول ثانية" + }, + "identityMigrationMessage": "يرجى الانتظار حتى تتم ترقية حسابك إلى نظامنا الجديد. سيسمح لنا هذا بدعم العديد من خيارات تسجيل الدخول الأخرى في المستقبل، بما في ذلك استرداد البريد الإلكتروني الاختياري في حالة فقدان الوصول إلى جهازك.", + "inheritSystem": "وراثة موضوع النظام", + "install": { + "dontShow": "لا تظهر لي هذا مرة أخرى", + "message": "لتلقي الإشعارات والحصول على أفضل تجربة للمستخدم، نوصيك بإضافة OpenChat إلى الشاشة الرئيسية.", + "title": "أضف إلى الشاشة الرئيسية" + }, + "insufficientFunds": "يرجى التأكد من أن الحساب أعلاه يحتوي على رصيد لا يقل عن {amount} ICP لتغطية مساحة التخزين المطلوبة، ثم اضغط على زر \"تحديث\".", + "insufficientStorage": "مساحة تخزين غير كافية", + "invite": { + "confirmReset": "هل أنت متأكد من أنك تريد إعادة تعيين رابط {level}؟ لن يتمكن الأشخاص بعد الآن من الانضمام إلى {level} باستخدام الرابط الحالي.", + "disabled": "عاجز", + "enabled": "تم التمكين", + "enableLink": "تمكين الرابط", + "errorDisablingLink": "غير قادر على تعطيل الرابط", + "errorEnablingLink": "غير قادر على تمكين الرابط", + "errorGettingLink": "غير قادر على الحصول على الرابط", + "errorResettingLink": "غير قادر على إعادة تعيين الرابط", + "invite": "يدعو", + "inviteWithLink": "شارك {level} عبر الرابط", + "referredUsers": "المستخدمون المشار إليهم", + "reset": "إعادة ضبط", + "resetLink": "إعادة تعيين الرابط", + "shareMessage": "يمكن لأي شخص لديه هذا الرابط معاينة {xyz والانضمام إليه}. بصفتك عضوًا في الفئة الماسية، يمكنك أيضًا كسب [مكافآت الإحالة](?faq=referral_rewards).", + "shareMessageTrust": "شاركها مع الأشخاص الذين تثق بهم." + }, + "inviteCodeCopied": "تم نسخ رمز الدعوة إلى الحافظة الخاصة بك", + "invited": "مدعو", + "invitedBy": "{changedBy} دعا {changed} إلى {level}", + "invitedUsers": "المستخدمون المدعوون", + "irreversible": "هل تريد بالتأكيد حذف هذا {level}؟ لا يمكن التراجع عن هذا!", + "join": "ينضم", + "joined": "انضمت", + "joinGroup": "انضم إلى {level}", + "joinGroupFailed": "غير قادر على الانضمام إلى {level}", + "lastOnline": "آخر ظهور على الإنترنت منذ {duration}", + "learnToEarn": { + "accepted_swap_offer": "قبول عرض المبادلة", + "changed_theme": "تغيير الثيم", + "deleted_message": "حذف رسالة", + "done": "منتهي", + "edited_message": "تعديل الرسالة", + "external": "للقيام به (خارجي)", + "externalInfo": "يتم تحديد الإنجازات الخارجية من قبل أطراف ثالثة. ما عليك سوى النقر فوق الرابط واتباع تعليماتهم للحصول على مكافأة CHIT.", + "favourited_chat": "وضع علامة على الدردشة كمفضلة", + "followed_thread": "اتبع الموضوع", + "forwarded_message": "إعادة توجيه الرسالة", + "had_message_tipped": "تلقي رسالة إرشادية", + "joined_call": "انضم إلى مكالمة", + "joined_community": "انضم إلى المجتمع", + "joined_gated_group_or_community": "انضم إلى مجموعة/قناة مغلقة", + "joined_group": "انضم إلى مجموعة", + "nothingDone": "مازال أمامك الكثير من العمل للقيام به!", + "nothingLeftToDo": "استرخي! لم يعد لديك ما تفعله", + "percentageComplete": "{perc}% مكتمل", + "pinned_chat": "تثبيت الدردشة في أعلى القائمة", + "proved_unique_personhood": "أثبت إنسانيتك الفريدة", + "quote_replied": "اقتباس الرد على الرسالة", + "reacted_to_message": "الرد على الرسالة", + "received_crypto": "تلقي رسالة مشفرة", + "received_direct_message": "تلقي رسالة مباشرة", + "received_reaction": "تلقي رد فعل على رسالة", + "referred_10th_user": "أحل المستخدم العاشر الذي تم التحقق منه", + "referred_1st_user": "أحل المستخدم الأول الذي تم التحقق منه", + "referred_20th_user": "أحل المستخدم العشرين الذي تم التحقق منه", + "referred_3rd_user": "أحل المستخدم الثالث الذي تم التحقق منه", + "referred_50th_user": "أحل المستخدم رقم 50 الذي تم التحقق منه", + "replied_in_thread": "ابدأ الرد في موضوع", + "sent_audio": "إرسال رسالة صوتية", + "sent_crypto": "إرسال رسالة مشفرة", + "sent_direct_message": "إرسال رسالة مباشرة", + "sent_file": "إرسال ملف", + "sent_giphy": "أرسل صورة متحركة", + "sent_image": "أرسل صورة", + "sent_meme": "أرسل رسالة ميم", + "sent_poll": "إرسال استطلاع رأي", + "sent_prize": "إرسال رسالة الجائزة", + "sent_reminder": "إرسال تذكير", + "sent_swap_offer": "إرسال عرض تبادل P2P", + "sent_text": "إرسال رسالة نصية", + "sent_video": "أرسل مقطع فيديو", + "set_avatar": "قم بتحميل الصورة الرمزية الخاصة بك", + "set_bio": "تحديث السيرة الذاتية الخاصة بك", + "set_community_display_name": "تعيين اسم عرض المجتمع", + "set_display_name": "تحديث اسم العرض الخاص بك", + "set_pin": "تعيين رقم التعريف الشخصي على محفظتك", + "showChitPopup": "أعلمني بإنجازاتي", + "started_call": "ابدأ مكالمة فيديو", + "streak_100": "تحقيق سلسلة 100 يوم", + "streak_14": "تحقيق سلسلة من 14 يومًا", + "streak_3": "تحقيق سلسلة من 3 أيام", + "streak_30": "تحقيق سلسلة من 30 يومًا", + "streak_365": "تحقيق سلسلة 365 يومًا", + "streak_7": "تحقيق سلسلة من 7 أيام", + "swapped_from_wallet": "تبديل رمز من المحفظة", + "tipped_message": "إرسال رسالة", + "title": "الإنجازات", + "todo": "للقيام بذلك", + "upgrade_to_gold_diamond": "الترقية إلى عضوية مدى الحياة الماسية", + "upgraded_to_diamond": "الترقية إلى العضوية الماسية", + "voted_on_poll": "التصويت على استطلاع" + }, + "leave": "يترك", + "leaveGroup": "اترك {level}", + "leftGroup": "لقد غادرت المجموعة بنجاح", + "level": { + "channel": "قناة", + "community": "مجتمع", + "group": "مجموعة" + }, + "light": "ضوء", + "linkCopiedToClipboard": "تم نسخ الرابط إلى الحافظة", + "loadGif": "عرض الصورة المتحركة", + "loadImage": "إظهار الصورة", + "loadingTweetPreview": "جاري تحميل معاينة التغريدة...", + "loadMeme": "عرض الميم", + "login": "تسجيل الدخول", + "loginDialog": { + "back": "خلف", + "checkEmail": "انقر على الرابط الذي أرسلناه لك وأدخل الكود التالي", + "codeCopiedToClipboard": "تم نسخ الكود إلى الحافظة", + "failedToCopyCodeToClipboard": "فشل في نسخ الكود إلى الحافظة", + "failedToSendEmail": "لم نتمكن من إرسال البريد الإلكتروني في هذا الوقت. يرجى المحاولة مرة أخرى لاحقًا.", + "generatingLink": "جاري إنشاء رابط تسجيل دخول آمن وكود...", + "haveAccount": "هل لديك حساب بالفعل؟", + "invalidEmail": "عنوان البريد الإلكتروني غير صالح.", + "noAccount": "ليس لديك حساب؟", + "refreshTitle": "إرسال رمز جديد", + "showMore": "إظهار المزيد من خيارات تسجيل الدخول", + "signin": "تسجيل الدخول", + "signinEmailPlaceholder": "تسجيل الدخول باستخدام البريد الإلكتروني...", + "signinWith": "تسجيل الدخول باستخدام {provider}", + "signup": "اشتراك", + "signupEmailPlaceholder": "سجل باستخدام البريد الإلكتروني...", + "signupTitle": "سجل في OpenChat", + "signupWith": "سجل باستخدام {provider}", + "strapline": "أين يتواصل Web3", + "title": "تسجيل الدخول إلى OpenChat", + "unexpectedError": "خطأ غير متوقع. يرجى المحاولة مرة أخرى لاحقًا." + }, + "logout": "تسجيل الخروج", + "lowBandwidth": "نطاق ترددي منخفض", + "magicLink": { + "closeMessage": "يرجى إغلاق هذه الصفحة ومحاولة تسجيل الدخول مرة أخرى", + "code_invalid": "الكود غير صالح", + "continueMessage": "يرجى إغلاق هذه الصفحة والاستمرار في الصفحة الأصلية", + "enterCode": "أدخل رمز التحقق", + "link_expired": "الرابط صالح لمدة 5 دقائق فقط وقد انتهى الآن", + "link_invalid": "الرابط غير صالح", + "success": "تم بنجاح! لقد قمت بتسجيل الدخول الآن", + "title": "تسجيل الدخول باستخدام رابط البريد الإلكتروني" + }, + "makeGroupPrivate": "جعل المجموعة خاصة", + "makeGroupPrivateFailed": "فشل في جعل {level} خاصًا", + "makeGroupPublicFailed": "فشل في جعل {level} عامًا", + "markAllRead": "وضع علامة على الكل كمقروء", + "maxAudioSize": "تم تقليص مقطع الصوت إلى الحد الأقصى 1 ميجا بايت", + "maxFileSize": "تم تجاوز الحد الأقصى لحجم الملف وهو 1 ميجا بايت", + "maxGroupsCreated": "لقد قمت بالفعل بإنشاء الحد الأقصى لعدد {level}", + "maxImageSize": "تم تجاوز الحد الأقصى لحجم الصورة وهو 1 ميجا بايت", + "maxStorage": "يمكنك الترقية إلى الحد الأقصى 1 جيجابايت من مساحة التخزين", + "maxVideoSize": "تم تجاوز الحد الأقصى لحجم الفيديو وهو 5 ميجا بايت", + "member": "عضو", + "memberCount": "أعضاء {count}", + "memberIsTyping": "{username} يكتب", + "members": "أعضاء", + "membersAreTyping": "أعضاء {number} يكتبون", + "membersHeader": "أعضاء {level}", + "messageBlocked": "الرسالة مخفية", + "messageDeleted": "تم حذف الرسالة بواسطة {username} في {timestamp}", + "messageDeletedByOpenChatBot": "تم الإبلاغ عن هذه الرسالة أولاً بواسطة مستخدم OpenChat، ثم تم إرسالها إلى [Modclub]({modclub}) حيث تم تحديد أن الرسالة انتهكت [قاعدة النظام الأساسي]({rules})، وأخيرًا تم حذفها بواسطة {username} على {timestamp}", + "messages": "رسائل", + "messageUrlCopiedToClipboard": "تم نسخ عنوان الرسالة إلى الحافظة!", + "minimumAmount": "الحد الأدنى: {amount} {symbol}", + "minutes": "دقائق", + "moderator": "المشرف", + "months": "شهور", + "muted": "مكتوم", + "muteNotifications": "كتم صوت الإشعارات", + "myAccount": "حسابي", + "new": "جديد", + "newChat": "دردشة جديدة", + "newGroup": "مجموعة جديدة", + "newGroupDesc": "ما هذا {level}؟", + "newGroupName": "أدخل اسم {level}", + "newLimit": "{limit} / 1 جيجا بايت", + "next": "التالي", + "nMessagesDeleted": "تم حذف الرسائل {number}", + "no": "لا", + "noAccess": "لا يوجد وصول", + "noAccessText": "عذراً، ولكن ليس لديك إمكانية الوصول إلى المجتمع أو المجموعة أو القناة التي تحاول الوصول إليها.", + "noAudio": "متصفحك لا يدعم الصوت المضمن", + "noChangeToStorage": "اختر حد تخزين جديد باستخدام شريط التمرير الموجود بالأعلى حتى نتمكن من حساب سعر الترقية لك.", + "noChannelSelected": "لم يتم تحديد أي قناة حاليًا", + "noChatsAvailable": "لا توجد محادثات متاحة!", + "noChatSelected": "لم يتم تحديد أي دردشة حاليًا", + "noGroupsFound": "لم يتم العثور على أي مجموعات 😭", + "noThanks": "ًلا شكرا", + "notificationsDisabled": "تم تعطيل الإشعارات", + "notificationsEnabled": "تم تمكين الإشعارات", + "notInterested": "غير مهتم", + "noUserSelected": "لم يتم تحديد أي مستخدم حاليًا", + "noVideo": "متصفحك لا يدعم الفيديو المضمن", + "nUsersJoined": "انضم مستخدمو {number} إلى {level}", + "offline": "غير متصل", + "offlineError": "عذرا - يبدو أنك غير متصل بالإنترنت في الوقت الحالي", + "offlineMessage": "غير متصل", + "oneDay": "يوم واحد", + "oneHour": "1 ساعة", + "oneMessageDeleted": "تم حذف 1 رسالة", + "onlineNow": "متصل الآن", + "openChat": "الدردشة المفتوحة", + "original": "إبداعي", + "otherUsers": "مستخدمين آخرين", + "ownedBy": "مملوكة لـ @{username}", + "owner": "مالك", + "ownerCantLeave": "يجب عليك إضافة مالك آخر قبل أن تتمكن من مغادرة {level}", + "p2pSwap": { + "accept": "يقبل", + "accepted": "مقبول", + "acceptedBy": "لقد تم قبول هذا العرض من قبل {user} وهو قيد الانتظار للاستكمال.", + "already_accepted": "لقد تم قبول عرض المبادلة هذا بالفعل", + "builderTitle": "تقديم عرض المبادلة", + "cancel": "يلغي", + "cancelled": "تم الإلغاء", + "completed": "لقد تم قبول هذا العرض من قبل {user}.", + "confirmAccept": "عند قبولك لهذا العرض، سيتم تحويل {amount} {token} (يشمل رسوم معاملة) من محفظتك إلى حساب الضمان الخاص بـ OpenChat. وستتلقى {amountOther} {tokenOther} في المقابل. هل ترغب في المتابعة؟", + "confirmCancel": "عند إلغاء هذا العرض، سيتم تحويل {amount} من حساب الضمان الخاص بـ OpenChat إلى محفظتك مرة أخرى. هل ترغب في المتابعة؟", + "confirmSend": "عند إنشاء هذا العرض، سيتم تحويل {amount} (يتضمن رسوم معاملة) من محفظتك إلى صندوق الضمان الخاص بـ OpenChat. وسيبقى هناك حتى انتهاء صلاحية العرض أو إلغائه أو قبوله. هل ترغب في المتابعة؟", + "creatingYourMessage": "يرجى الانتظار أثناء إنشاء عرض المبادلة", + "expired": "منتهي الصلاحية", + "expiryTime": "وقت انتهاء الصلاحية", + "failedToCreateMessage": "فشل في إنشاء عرض المبادلة", + "insufficient_funds": "لم يتبق لديك أموال كافية لقبول عرض المبادلة هذا", + "insufficientBalance": "رصيد غير كاف", + "insufficientBalanceMessage": "يجب أن يكون لديك على الأقل {amount} {token} (بما في ذلك رسوم المعاملة 2) لقبول هذا المبادلة.", + "progress": { + "accepted": "[نقل {amount1} {token1}]({token1TxnIn}) من {user1} إلى الضمان", + "accepting": "تم قبوله بواسطة {user1}", + "cancelled": "تم الإلغاء", + "cancelled_pending": "تم الإلغاء في انتظار استرداد المبلغ", + "completed": "مكتمل", + "expired": "منتهي الصلاحية", + "expired_pending": "انتهت صلاحيتها في انتظار استرداد المبلغ", + "open": "[نقل {amount0} {token0}]({token0TxnIn}) من {user0} إلى الضمان", + "refunded": "[استرداد مبلغ {amount0} {token0}]({token0TxnOut}) إلى {user0}", + "reserved": "تم قبوله بواسطة {user1} في انتظار التحويل", + "swapped": "[نقل {amount1} {token1}]({token1TxnOut}) إلى {user0} [و{amount0} {token0}]({token0TxnOut}) إلى {user1}", + "swapping": "تبادل", + "waiting": "في انتظار القبول" + }, + "reserved": "محجوز", + "reservedBy": "تم حجز هذا العرض بواسطة {user} وهو قيد الانتظار للاستكمال.", + "summary": "هذا عرض لمبادلة {fromAmount} {fromToken} بـ {toAmount} {toToken}.", + "swap_cancelled": "تم إلغاء عرض المبادلة هذا", + "swap_expired": "لقد انتهى عرض المبادلة هذا", + "swap_not_found": "لم يتم العثور على عرض المبادلة هذا", + "swapTokenTo": "عرض مبادلة {amount0} {token0} بـ {amount1} {token1}", + "tokenBalance": "{token} الرصيد", + "unknown_accept_error": "خطأ في قبول عرض المبادلة", + "unknown_cancel_error": "حدث خطأ أثناء إلغاء عرض المبادلة", + "youAccepted": "لقد قبلت هذا العرض وهو قيد الإكمال.", + "youCompleted": "لقد قبلت هذا العرض.", + "youReserved": "لقد قمت بحجز هذا العرض وهو قيد الإكمال." + }, + "participant": "عضو", + "percLeft": "{perc}% متبقية", + "performingUpgrade": "تنفيذ الترقية", + "permissions": { + "addMembers": "إضافة أعضاء", + "allMembers": "يمكن لجميع الأعضاء", + "changeRoles": "تغيير أدوار الأعضاء", + "createPolls": "إنشاء استطلاعات الرأي", + "createPrivateChannel": "إنشاء قنوات خاصة", + "createPublicChannel": "إنشاء قنوات عامة", + "currentRole": "{سسسس} {سسسس}", + "deleteMessages": "حذف الرسائل", + "general": "عام", + "inviteUsers": "دعوة المستخدمين", + "manageUserGroups": "إدارة مجموعات المستخدمين", + "mentionAllMembers": "اذكر جميع الأعضاء ({mention})", + "message": "رسائل", + "messagePermissions": { + "audio": "إرسال رسائل صوتية", + "crypto": "إرسال رسائل مشفرة", + "default": "إرسال الرسائل افتراضيا", + "file": "إرسال رسائل الملفات", + "giphy": "إرسال رسائل GIF", + "image": "إرسال رسائل الصور", + "memeFighter": "إرسال رسائل Meme Fighter", + "p2pSwap": "إرسال رسائل تبادل P2P", + "poll": "إنشاء رسائل استطلاع الرأي", + "prize": "إرسال رسائل الجائزة", + "text": "إرسال رسائل نصية", + "video": "إرسال رسائل الفيديو" + }, + "nobody": "لا أحد يستطيع", + "notPermitted": "لا يسمح لك بـ {permission}", + "overrideChatMessages": "تجاوز أذونات رسائل الدردشة", + "ownerAndAdmins": "يمكن للمالكين والمسؤولين", + "ownerAndAdminsAndModerators": "يمكن للمالكين والمسؤولين والمشرفين", + "ownerOnly": "يمكن للمالكين", + "permissions": "الأذونات", + "pinMessages": "رسائل الدبوس", + "reactToMessages": "الرد على الرسائل", + "removeMembers": "إزالة/حظر الأعضاء", + "replyInThread": "الرد في الموضوع", + "sendMessages": "إرسال الرسائل", + "startVideoCall": "بدء مكالمات الفيديو", + "thread": "الخيوط", + "threadPermissions": { + "audio": "إرسال رسائل صوتية في المواضيع", + "crypto": "إرسال رسائل مشفرة في المواضيع", + "default": "إرسال الرسائل في المواضيع بشكل افتراضي", + "file": "إرسال رسائل الملفات في المواضيع", + "giphy": "إرسال رسائل GIF في المواضيع", + "image": "إرسال رسائل الصور في المواضيع", + "memeFighter": "إرسال رسائل Meme Fighter في المواضيع", + "p2pSwap": "إرسال رسائل تبادل P2P في المواضيع", + "poll": "إنشاء رسائل استطلاع في المواضيع", + "prize": "إرسال رسائل الجائزة في المواضيع", + "text": "إرسال رسائل نصية في المواضيع", + "video": "إرسال رسائل الفيديو في المواضيع" + }, + "updateDetails": "تعديل المعلومات الأساسية", + "updateGroup": "تعديل معلومات المجموعة", + "whoCan": "من يستطيع" + }, + "permissionsChangedBy": "قام {changedBy} بتغيير أذونات {level}", + "pickEmoji": "اختر الرمز التعبيري", + "pinChat": { + "failed": "فشل تثبيت الدردشة - يرجى المحاولة لاحقًا", + "limitExceeded": "يمكنك فقط التثبيت على الدردشات {limit}", + "menuItem": "دردشة الدبوس", + "unpinFailed": "فشل إلغاء تثبيت الدردشة - يرجى المحاولة لاحقًا", + "unpinMenuItem": "إلغاء تثبيت الدردشة" + }, + "pinMessage": "دبوس", + "pinMessageFailed": "غير قادر على تثبيت الرسالة", + "pinnedMessages": "الرسائل المثبتة", + "pinNumber": { + "changePin": "تغيير الدبوس", + "changePinSuccess": "تم تغيير رقم الدبوس", + "changePinTitle": "تغيير رقم الدبوس", + "clearPin": "دبوس واضح", + "clearPinMessage": "أدخل رقمك السري لمسحه", + "clearPinSuccess": "تم مسح رقم التعريف الشخصي", + "clearPinTitle": "مسح رقم الدبوس", + "currentPin": "أدخل رقمك الشخصي الحالي", + "enterPin": "أدخل رقمك الشخصي", + "enterPinInfo": "يجب عليك إدخال رقم التعريف الشخصي الخاص بك لتحويل الرموز من محفظتك.", + "forgotLabel": "لقد نسيت رقمي السري", + "forgotPin": "تسجيل الدخول", + "forgotPinMessage": "لإعادة تعيين رقم التعريف الشخصي الخاص بك، ستحتاج إلى تسجيل الدخول باستخدام موفر المصادقة الحالي الخاص بك لإثبات هويتك.", + "forgotPinTitle": "دبوس منسي", + "invalid": "يجب أن يكون رقمك الشخصي رقمًا", + "newPin": "أدخل رقمك الجديد", + "pinIncorrect": "الدبوس غير صحيح - يرجى المحاولة مرة أخرى", + "pinIncorrectTryLater": "الدبوس غير صحيح - يرجى المحاولة مرة أخرى في {duration}", + "pinRequired": "مطلوب رقم التعريف الشخصي - يرجى المحاولة مرة أخرى", + "setPin": "مجموعة دبوس", + "setPinMessage": "قم بتعيين رقم التعريف الشخصي لإضافة طبقة إضافية من الأمان إلى محفظة OpenChat الخاصة بك. قبل إجراء أي تحويلات رمزية من محفظتك، سيُطلب منك إدخال رقم التعريف الشخصي الخاص بك.", + "setPinSuccess": "مجموعة أرقام الدبوس", + "setPinTitle": "تعيين رقم الدبوس", + "title": "رقم الدبوس", + "tooManyFailedAttempts": "محاولات فاشلة كثيرة - يرجى المحاولة مرة أخرى في {duration}" + }, + "placeholderContent": "يرجى الانتظار، سيتم تحميل رسالتك قريبًا", + "pleaseDeposit": "يرجى الضغط على زر \"تأكيد\" إذا كنت سعيدًا بدفع {amount} ICP من الحساب أعلاه لترقية مساحة التخزين الخاصة بك إلى {limit}", + "pleaseWait": "الرجاء الانتظار لبضع ثوان", + "poll": { + "addAnotherAnswer": "أضف إجابة أخرى", + "addAnswer": "أضف إجابتين على الأقل", + "allowChangeVotes": "السماح للمستخدمين بتغيير التصويت", + "allowMultipleVotes": "السماح بالتصويتات المتعددة", + "anonymous": "مجهول", + "answersLabel": "الأجوبة", + "answerText": "أدخل نص الإجابة...", + "atLeastTwo": "الحد الأدنى 2", + "create": "إنشاء استطلاع رأي", + "finished": "تم الانتهاء من الاستطلاع", + "invalidAnswer": "ممم، هل لديك إجابة مكررة؟", + "limitedDuration": "مدة محدودة", + "maxReached": "تم الوصول إلى الحد الأقصى 10 إجابات", + "numVotes": "{votes} الأصوات", + "numVotesBy": "{votes} الأصوات بواسطة", + "numVotesIncludingYours": "{votes} الأصوات بما في ذلك صوتك", + "oneDay": "يوم واحد", + "oneHour": "ساعة واحدة", + "oneVote": "1 صوت", + "oneVoteBy": "1 صوت من قبل", + "oneWeek": "1 اسبوع", + "optionalQuestion": "اختياريا أدخل سؤالك هنا", + "poll": "استطلاع رأي", + "pollDuration": "مدة الاستطلاع", + "pollEnds": "إنتهى الإستطلاع {end}", + "questionLabel": "سؤال", + "settings": "إعدادات", + "showBeforeEnd": "إظهار الأصوات قبل انتهاء الاستطلاع", + "start": "يبدأ", + "totalVotes": "مجموع الأصوات: {total}", + "voteFailed": "فشل التصويت على الاستطلاع", + "votersPrivate": "سيتم عدم الكشف عن هوية الناخبين", + "votersPublic": "الناخبون سيكونون علنيين" + }, + "preferredLanguage": "اللغة المفضلة", + "preview": "معاينة", + "previewing": "مجموعة المعاينة", + "privateGroupInfo": "سيتم تقييد هذا {level} على الأشخاص الذين تقوم بدعوتهم.", + "privateGroupWithN": "{level} خاص مع {number} أعضاء", + "prizeMessage": "رسالة الجائزة", + "prizes": { + "allClaimed": "تم المطالبة بها بالكامل", + "anyone": "أي مستخدم", + "claim": "مطالبة", + "claimed": "نجاح!", + "claimFailed": "نأسف لأن طلبك لم ينجح! أتمنى لك حظًا أوفر في المرة القادمة.", + "click": "انقر على الزر أدناه للحصول على فرصة للفوز بجائزة.", + "creatingYourPrizeMessage": "يرجى الانتظار بينما نقوم بإنشاء رسالة الجائزة الخاصة بك", + "distribution": "كيف يتم توزيع الجوائز؟", + "duration": "إلى متى ستكون الجائزة فعالة؟", + "equalDistribution": "التوزيع المتساوي", + "finished": "انتهى", + "live": "رسالة الجائزة مباشرة! يمكن للآخرين النقر للمطالبة بها 🎁", + "maxPrize": "ما هو الحد الأقصى للجائزة؟", + "minPrize": "ما هو الحد الأدنى للجائزة؟", + "numberOfWinners": "عدد الفائزين", + "onlyDiamond": "فقط للمستخدمين الماسيين", + "prizeFinished": "لقد انتهت رسالة الجائزة الخاصة بك الآن", + "randomDistribution": "التوزيع العشوائي", + "title": "جائزة", + "totalAmount": "المبلغ الإجمالي للجائزة", + "whoCanWin": "من يمكنه المطالبة بالحصة؟", + "winner": "**{recipient}** فاز **{amount}** {token}" + }, + "profile": { + "block": "حاجز", + "chat": "محادثة", + "communities": "المجتمعات", + "earnMore": "اكسب المزيد", + "global": "عالمي", + "label": "حساب تعريفي", + "ringtone": "اختر نغمة الرنين الخاصة بك", + "selectCommunity": "حدد المجتمع", + "settings": "إعدادات", + "title": "إعدادات الملف الشخصي", + "unblock": "إلغاء الحظر", + "videoCameraOn": "ابدأ بتشغيل الكاميرا", + "videoMicOn": "ابدأ بالميكروفون", + "videoSettings": "إعدادات مكالمات الفيديو", + "videoSpeakerView": "ابدأ باستخدام عرض المتحدث النشط" + }, + "promoteFailed": "غير قادر على الترقية إلى {role}", + "promoteTo": "ترقية إلى {role}", + "proposal": { + "action": "فعل", + "additionalContent": "محتوى إضافي", + "adopt": "يتبنى", + "alreadyVoted": "لقد قمت بالتصويت بالفعل - حاول إعادة تحميل الصفحة", + "builtInAction": "الإجراءات المضمنة", + "collapse": "انهيار الرسالة", + "cyclesDispenserAction": "إجراءات موزع الدورات", + "details": "تفاصيل", + "disableAll": "تعطيل الكل", + "enableAll": "تمكين الكل", + "filter": "تصفية المقترحات", + "groupIndexAction": "إجراءات مؤشر المجموعة", + "makeProposal": "قدم اقتراحا", + "maker": { + "achievementChatCost": "مطلوب دفع {cost} رمز {chat} لتوفير CHIT المطلوبة. سيتم استرداد أي CHAT غير منفقة عند انتهاء صلاحية الإنجاز.", + "achievementExpiry": "انتهاء الصلاحية", + "achievementLogo": "شعار الإنجاز", + "achievementName": "اسم الإنجاز", + "achievementUrl": "رابط الإنجاز", + "addTokenChatCost": "مطلوب دفع مبلغ {cost} من رموز {chat} لإدراج رمز جديد على OpenChat.", + "amount": "كمية", + "amountRules": "على الأقل 1 رمز {token}", + "approvalError": "فشل في الموافقة على نقل الدردشة", + "awardingAchievementCanisterId": "معرف العلبة", + "awardingAchievementCanisterIdRules": "فقط هذه العلبة يمكنها أن تمنح الإنجاز", + "chitReward": "مكافأة شيت", + "chitRewardRules": "على الأقل {value} CHIT", + "enterAchievementName": "أدخل اسمًا للإنجاز", + "enterAmount": "أدخل كمية من رموز {token}", + "enterChitReward": "أدخل مكافأة CHIT", + "enterMaxAwards": "أدخل الحد الأقصى للمستخدمين للحصول على الجائزة", + "enterRecipientOwner": "أدخل رأس مال حساب المستلم", + "enterRecipientSubaccount": "أدخل حساب فرعي اختياري للمستلم", + "enterSummary": "أدخل ملخصًا", + "enterTitle": "أدخل عنوانًا", + "enterUrl": "أدخل عنوان URL اختياري", + "header": "قدم اقتراحا", + "howToBuyUrl": "كيفية شراء رابط", + "ledgerCanisterId": "معرف علبة دفتر الأستاذ", + "maxAwards": "تم منح الحد الأقصى من المستخدمين", + "maxAwardsRules": "على الأقل {value}", + "message": "مطلوب إيداع مبلغ {cost} من رموز {token} (يتضمن مجموعتين من رسوم التحويل) لتقديم اقتراح يتم استرداده إذا تم اعتماد الاقتراح.", + "recipientOwner": "رأس مال حساب المستلم", + "recipientSubaccount": "الحساب الفرعي للمستلم", + "recipientSubaccountRules": "يجب أن تكون سلسلة سداسية", + "summary": "ملخص", + "summaryRules": "يدعم ترميز Markdown", + "title": "عنوان", + "tokenInfoUrl": "عنوان URL لمعلومات الرمز", + "tokenLogo": "الشعار", + "transactionUrlFormat": "تنسيق عنوان URL للمعاملة", + "treasury": "الرمز المراد نقله من الخزانة", + "type": "نوع الاقتراح", + "unexpectedError": "خطأ غير متوقع أثناء إرسال الاقتراح", + "url": "الرابط", + "urlRules": "رابط للتفاصيل التكميلية" + }, + "neuronControllerAction": "إجراءات وحدة التحكم العصبية", + "noEligibleNeurons": "لم يتم العثور على الخلايا العصبية المؤهلة", + "noEligibleNeuronsMessage": "لم يتم العثور على أي خلايا عصبية مؤهلة أو مرتبطة. للتصويت على المقترحات من داخل OpenChat، يجب عليك أولاً إضافة معرف مستخدم OpenChat الخاص بك كمفتاح سريع لأي خلايا عصبية ترغب في التصويت بها.

معرف مستخدم OpenChat الخاص بك هو: {userId}.

لمزيد من المعلومات حول التصويت والحوكمة، اقرأ منشور المدونة هذا.", + "notificationsAction": "فهرس الإشعارات الإجراءات", + "proposalNotAceptingVotes": "هذا الاقتراح لا يقبل الأصوات - حاول إعادة تحميل الصفحة", + "proposalsBotAction": "اقتراحات إجراءات الروبوت", + "proposedBy": "مقترح من قبل", + "readless": "اقرأ أقل", + "readmore": "اقرأ المزيد", + "registryAction": "إجراءات التسجيل", + "reject": "يرفض", + "storageIndexAction": "إجراءات مؤشر التخزين", + "topic": "عنوان", + "unknownActionCategory": "إجراءات أخرى", + "userIndexAction": "إجراءات مؤشر المستخدم", + "voteFailed": "فشل في التصويت - يرجى المحاولة لاحقًا", + "votingPeriodEnded": "انتهت فترة التصويت", + "votingPeriodRemaining": "الفترة المتبقية للتصويت", + "youVotedAdopt": "لقد صوتت للتبني!", + "youVotedReject": "لقد صوتت بالرفض!" + }, + "publicChannelInfo": "يتم إضافة أعضاء المجتمع تلقائيًا إلى القنوات العامة (ويمكنهم المغادرة بعد ذلك).", + "publicGroupInfo": "سيتمكن أي شخص من اكتشاف هذا {level} والانضمام إليه.", + "publicGroups": "المجموعات العامة", + "publicGroupUnique": "يجب أن تكون الأسماء العامة {level} فريدة.", + "publicGroupWithN": "{level} عامة مع {number} أعضاء", + "quoteReply": "اقتباس الرد", + "reactions": { + "andYou": "، وأنت", + "greaterThan99People": "99+ شخص", + "reactedWith": "تفاعل مع", + "youClickToRemove": "أنت (انقر للإزالة)" + }, + "readOnlyChat": "هذه الدردشة للقراءة فقط", + "readOnlyThread": "هذا الموضوع للقراءة فقط", + "reason": "سبب", + "reasonForSuspension": "سبب الإيقاف", + "recordAudioMessage": "تسجيل رسالة صوتية", + "referralHeader": "دعوة الأصدقاء/العائلة", + "refresh": "ينعش", + "register": { + "agree": "يوافق", + "bioTooLong": "سيرتك الذاتية طويلة جدًا", + "choosePath": "كيف ترغب بالتسجيل؟", + "closed": "نظرًا للطلب المرتفع للغاية، تم إغلاق تسجيلات المستخدمين الجدد مؤقتًا. سنقوم قريبًا بالتوسع عبر شبكات فرعية متعددة وسنتمكن بعد ذلك من النمو إلى أجل غير مسمى!", + "closedTitle": "تم إيقاف التسجيل", + "confirm": "يتأكد", + "confirmCyclesTransferText": "يرجى التأكيد أدناه عند قيامك بتحويل {amount} دورة من محفظة الدورات الخاصة بك إلى محفظة OpenChat أدناه", + "confirmICPTransferText": "يرجى التأكيد أدناه عند قيامك بتحويل {amount} ICP إلى الحساب الموضح أدناه", + "confirmTransfer": "تأكيد التحويل", + "createUser": "إنشاء المستخدم", + "cyclesTransferred": "تم تأكيد رسوم دورات {fee}", + "disclaimer": "من خلال التسجيل في OpenChat فإنك توافق على الالتزام بشروطنا وأحكامنا", + "displayNameInvalid": "اسم العرض غير صالح، يرجى المحاولة مرة أخرى", + "displayNameTooLong": "اسم العرض هذا طويل جدًا", + "displayNameTooShort": "اسم العرض هذا قصير جدًا", + "doYouWantToProceed": "لا يزال بإمكانك متابعة التسجيل، ولكن رمز الإحالة المقدم لن يكون له أي تأثير.", + "enjoy": "استمتع بالدردشة على الإنترنت الكمبيوتر", + "enterDisplayName": "اختر اسم العرض", + "enterUsername": "اختيار اسم المستخدم", + "failedToGetFee": "فشل في توليد رسوم التسجيل", + "findReferrer": "هل أشار إليك أحد؟", + "goBack": "عُد", + "icpTransferred": "تم تأكيد رسوم {fee} ICP", + "invalidCode": "رمز غير صالح", + "letsGo": "دعنا نذهب!", + "preparingUser": "إعداد المستخدم ...", + "proceed": "يتابع", + "referralCodeInvalid": "نأسف، رمز الإحالة الذي قدمته غير صالح أو منتهي الصلاحية", + "referredBy": "تمت الإشارة إليه بواسطة", + "registerAs": "للتسجيل كمستخدم", + "registerUser": "تسجيل المستخدم", + "registrationComplete": "تم التسجيل بالكامل ...", + "regOptions": "هل تفضل التحقق عبر رمز SMS (موصى به) أو عن طريق إجراء تحويل ICP صغير؟", + "searchForReferrer": "ابحث عن المستخدم الذي أشار إليك", + "title": "سجل باستخدام OpenChat", + "transfer": "تحويل", + "unableToConfirmFee": "لم نتمكن من تأكيد رسوم التسجيل الخاصة بك", + "userLimitReached": "تم الوصول إلى الحد الأقصى للمستخدم", + "usernameInvalid": "اسم المستخدم غير صالح، يرجى المحاولة مرة أخرى", + "usernameRules": "اختر اسم مستخدم باستخدام الأحرف الأبجدية الرقمية أو الشرطة السفلية فقط", + "usernameTaken": "هذا اسم المستخدم مأخوذ بالفعل", + "usernameTooLong": "اسم المستخدم هذا طويل جدًا", + "usernameTooShort": "اسم المستخدم هذا قصير جدًا", + "userNotFound": "لم يتم العثور على المستخدم", + "welcome": "مرحباً بكم في OpenChat!" + }, + "reminders": { + "cancel": "إلغاء التذكير", + "cancelFailure": "نأسف لعدم تمكننا من إلغاء التذكير الخاص بك", + "cancelSuccess": "لقد تم إلغاء التذكير الخاص بك بنجاح", + "create": "يخلق", + "failure": "نأسف لأننا لم نتمكن من ضبط التذكير الخاص بك", + "menu": "ذكّرني", + "nextWeek": "الأسبوع المقبل", + "note": "أضف ملاحظة", + "notePlaceholder": "ذكّرني ب...", + "oneHour": "في ساعة واحدة", + "optional": "خياري", + "remindAt": "سأذكرك بهذه الرسالة في {datetime}", + "success": "لقد تم ضبط التذكير الخاص بك بنجاح", + "threeHours": "في 3 ساعات", + "title": "تعيين تذكير", + "tomorrow": "غداً", + "twentyMinutes": "في 20 دقيقة", + "youAsked": "لقد طلبت مني أن أذكرك بهذه الرسالة." + }, + "remove": "يزيل", + "removeChat": "إزالة الدردشة", + "removedBy": "قام {changedBy} بإزالة {changed} من {level}", + "removeMemberFailed": "غير قادر على إزالة العضو", + "removePreviewQuestion": "إزالة المعاينة؟", + "renderPreviews": "معاينات الروابط التي يتم عرضها تلقائيًا", + "reply": "رد", + "replyPrivately": "الرد بشكل خاص", + "report": { + "advice": "إذا كنت تعتقد أن هذه الرسالة تنتهك قواعد هذه المجموعة، يرجى الاتصال بالمالكين والمسؤولين في المجموعة في المقام الأول. لا تبلغ عن رسالة لتعديل المنصة إلا إذا شعرت أنها تنتهك [قواعد المنصة]({rules}) وإلا فإنها تضيع وقت المشرفين. قد يؤدي الإبلاغ الخاطئ المستمر إلى تعليق حسابك.", + "child": "تحتوي الرسالة على محتوى جنسي للأطفال", + "deleteMessage": "حذف الرسالة المبلغ عنها", + "failure": "نأسف لعدم تمكننا من إبلاغ مشرفي المنصة بهذه الرسالة", + "menu": "تقرير", + "messageReport": "تم الإبلاغ عنه بواسطة @{username} على {timestamp} للسبب التالي: **{reason}**", + "nonConsensual": "تحتوي الرسالة على محتوى جنسي غير متفق عليه", + "note": "أضف ملاحظة", + "notePlaceholder": "أخبرنا لماذا تقوم بالإبلاغ عن هذا المحتوى", + "optional": "خياري", + "other": "سبب آخر", + "pleaseSelect": "الرجاء اختيار السبب", + "reason": "سبب التقرير", + "scam": "الرسالة تروج لعملية احتيال مالية", + "selfHarm": "تحتوي الرسالة على أو تشجع على إيذاء النفس", + "showing": "عرض أحدث {count} من {total} تقرير(ات)", + "success": "تم الإبلاغ عن الرسالة إلى مشرفي المنصة", + "threat": "الرسالة تهديدية", + "title": "رسالة التقرير", + "violence": "الرسالة تحتوي على عنف مفرط" + }, + "reportBug": "إذا كنت تعتقد أن هذا خطأ، انقر هنا للإبلاغ عن التفاصيل الكاملة", + "reset": "إعادة ضبط", + "restricted": { + "generic": "من أجل الامتثال للقوانين المحلية، لا يُسمح بهذه الميزة في ولايتك القضائية الحالية", + "swap": "من أجل الامتثال للقوانين المحلية، لا يُسمح بمبادلات الرموز في ولايتك القضائية الحالية.", + "title": "ميزة مقيدة" + }, + "restrictedContent": "المحتوى المحظور", + "restrictedContentInfo": "اختر ما إذا كنت ترغب في رؤية المجموعات والمجتمعات التي تم تطبيق علامات التعديل التالية عليها.", + "retryMessage": "إعادة محاولة الرسالة", + "revealDeletedMessage": "كشف الرسالة", + "roadmap": "خريطة الطريق", + "role": { + "admin": "المسؤولون", + "default": "تقصير", + "member": "أعضاء", + "moderator": "المشرفون", + "none": "لا أحد", + "owner": "المالكين" + }, + "roleChanged": "قام {changedBy} بتغيير دور {changed} إلى {newRole}", + "rules": { + "accept": "يقبل", + "acceptTitle": "قواعد الدردشة", + "channelRulesExplanation": "سيتم إضافة هذه القواعد إلى أي قواعد مجتمعية", + "communityRulesExplanation": "سيتم إضافة بادئة إلى أي قواعد قناة لهذه القواعد", + "enable": "تمكين القواعد", + "instructions": "إذا تم تمكين ذلك، فيجب على الأعضاء الجدد الموافقة على القواعد قبل أن يتمكنوا من إرسال الرسائل.", + "levelRules": "قواعد {level}", + "placeholder": "ما هي قواعد {level}؟", + "promptExistingUsers": "مطالبة الأعضاء الحاليين", + "promptExistingUsersInstructions": "يجب على الأعضاء الحاليين الموافقة على القواعد الجديدة قبل أن يتمكنوا من إرسال الرسائل.", + "reject": "يرفض", + "rules": "قواعد" + }, + "save": "يحفظ", + "search": "يبحث...", + "searchChannelsPlaceholder": "البحث عن القنوات...", + "searchChat": "يبحث", + "searchFavouritesPlaceholder": "ابحث عن المفضلة لديك...", + "searchForUsername": "ابحث عن اسم المستخدم", + "searchGroupsPlaceholder": "البحث عن المجموعات...", + "searchPlaceholder": "البحث عن المستخدمين والمجموعات...", + "searchUsersPlaceholder": "البحث عن المستخدمين...", + "selectAChannel": "حدد قناة من القائمة أو ابحث عن المزيد من القنوات في هذا المجتمع.", + "selectAChat": "ابحث عن صديق، أو قم بإنشاء مجموعة جديدة، أو حدد دردشة.", + "selectAFavourite": "يمكنك إضافة الدردشات المباشرة أو الدردشات الجماعية أو قنوات المجتمع إلى المفضلة لديك وستراها هنا.", + "selectAGroup": "حدد مجموعة لمعاينتها أو الانضمام إليها.", + "selectAGroupChat": "حدد مجموعة من القائمة أو ابحث عن مجموعة عالمية للانضمام إليها.", + "selectAUser": "حدد مستخدمًا من القائمة أو ابحث عن مستخدم للدردشة معه.", + "selectCommunityTheme": "حدد موضوع المجتمع", + "send": "يرسل", + "sendGif": "إرسال صورة متحركة", + "sendMessage": "أرسل رسالة", + "sendMessageDisabledAnon": "الرجاء تسجيل الدخول لإرسال رسالة", + "sendTextDisabled": "تم تعطيل الرسائل النصية", + "sendTo": "إرسال إلى...", + "sessionExpired": "انتهت الجلسة", + "sessionExpiredBlurb": "الرجاء تسجيل الدخول مرة أخرى", + "share": "يشارك", + "showFilters": "إظهار المرشحات", + "showPinned": "إظهار المثبتة", + "showPreview": "عرض المعاينة", + "showTweet": "عرض التغريدة", + "showVideo": "عرض الفيديو", + "skip": "يتخطى", + "startNewChat": "ابدأ محادثة جديدة", + "stats": { + "audioMessages": "رسالة صوتية", + "cryptoTransfers": "رسالة مشفرة", + "deletedMessages": "الرسائل المحذوفة", + "fileMessages": "رسالة الملف", + "giphyMessages": "رسالة جيف", + "groupStats": "إحصائيات {level}", + "icpTransfers": "رسالة ICP", + "imageMessages": "رسالة الصورة", + "pollMessages": "رسالة الاستطلاع", + "pollVotes": "تصويتات الاستطلاع", + "reactions": "ردود الفعل", + "replies": "الردود", + "reportedMessages": "الرسائل المبلغ عنها", + "reportedMessagesInfo": "الرسائل التي أرسلتها وتم حذفها أو الإبلاغ عنها من قبل الآخرين. قد يُستخدم هذا كإشارة إلى أنك لا تساهم بشكل إيجابي في المجتمع.", + "textMessages": "رسالة نصية", + "userStats": "إحصائيات المستخدم", + "videoMessages": "رسالة فيديو" + }, + "stopRecording": "إيقاف التسجيل", + "storage": "تخزين", + "storageUsed": "{used}GB المستخدمة من {limit}GB", + "submit": "يُقدِّم", + "submitNewGroup": "إنشاء مجموعة", + "supportsMarkdown": "يدعم ترميز Markdown", + "suspend": "تعليق", + "suspendedUser": "المستخدم الموقوف", + "suspendUser": "تعليق المستخدم", + "switchDomain": { + "blurb": "أنت تستخدم حاليًا نطاق [{currentDomain}]({currentDomain}).

لن تتلقى إشعارات من هذا النطاق القديم وسيتم إيقافه قريبًا تمامًا.

*للاستمرار في استخدام OpenChat والاستفادة الكاملة من تحسينات الأداء المستمرة، يرجى التبديل إلى نطاقنا الجديد [https://oc.app](https://oc.app)*

إذا قمت بحفظ الموقع على الشاشة الرئيسية، فستحتاج إلى حذفه/إلغاء تثبيته وحفظه على الشاشة الرئيسية مرة أخرى من النطاق الجديد.

", + "continue": "يكمل", + "title": "يرجى التبديل إلى نطاقنا الجديد" + }, + "tapForReferralLink": "انقر هنا لنسخ رابط الإحالة الشخصي الخاص بك", + "tapToLogin": "وضع الضيف - انقر هنا لتسجيل الدخول", + "tellMeMore": "مزيد من المعلومات", + "theInternetComputer": "كمبيوتر الانترنت", + "theme": { + "dark": "مظلم", + "light": "ضوء", + "preferredDarkTheme": "المظهر الداكن المفضل", + "preferredLightTheme": "موضوع الضوء المفضل", + "system": "نظام", + "title": "سمة" + }, + "thisIsPrivateGroupWithN": "هذه صفحة خاصة بـ {level} مع أعضاء {number}", + "thisIsPublicGroupWithN": "هذا هو {level} العام مع {number} أعضاء", + "thread": { + "lastMessage": "(الرسالة الأخيرة {date})", + "menu": "الرد في الموضوع", + "moreMessages": "- {number} رسالة أخرى -", + "nreplies": "{number} {replies} {message}", + "open": "يفتح", + "openThread": "موضوع مفتوح", + "previewFailure": "غير قادر على تحميل معاينات الموضوع", + "previewTitle": "الخيوط", + "replies": "الردود", + "reply": "رد", + "title": "خيط", + "unread": "{count} موضوع(ات) تحتوي على رسائل غير مقروءة" + }, + "throttleMessage": "تم تقليص الرسائل - سيتم استئنافها في {time}s", + "tip": { + "advice": "انقر عدة مرات حسب رغبتك لترك إكرامية", + "failure": "نأسف لعدم تمكننا من إضافة نصيحتك", + "menu": "نصيحة", + "noExchangeRate": "ليس لدينا سعر صرف محدد لـ {token}", + "plusFee": "+ {fee} {token} رسوم", + "showCustom": "مبلغ الإكرامية المخصص", + "title": "نصيحة" + }, + "today": "اليوم", + "toggleBlockMarkdown": "تبديل مستوى الكتلة", + "toggleMuteNotificationsFailed": "لا يمكن إرسال الإشعارات {operation}", + "toggleMuteNotificationsSucceeded": "تم الإشعار بنجاح {operation}", + "toggleTranslationEditMode": "تبديل تصحيحات الترجمة", + "tokenSwap": { + "back": "خلف", + "bestQuote": "أفضل سعر صرف مذكور هو {rate} {tokenOut} لكل {tokenIn} من {dex}.", + "confirmUnderstanding": "حدد هذا المربع لتأكيد فهمك", + "findingAvailableTokens": "العثور على الرموز المتاحة للتبادل...", + "getTokenSwapsError": "حدث خطأ أثناء الحصول على المبادلات لـ {tokenIn}", + "noQuotes": "لم يقدم أي DEXes عرض أسعار للمبادلة {tokenIn}", + "proceedWithSwap": "لن يتم إجراء عملية المبادلة إلا إذا كان مبلغ {tokenOut} المراد استلامه يعادل 98% على الأقل من المبلغ المذكور. هل ترغب في المضي قدمًا ومحاولة إجراء عملية المبادلة؟", + "progress": { + "deposit": "نقل {amountIn} {tokenIn} إلى {dex}", + "done": "تم المبادلة بالكامل", + "error": "خطأ في المبادلة - لم يتم فقدان أي أموال", + "failed": "فشل المبادلة - تغير سعر الصرف", + "get": "احصل على حساب الوديعة", + "insufficientFunds": "فشل المبادلة - الأموال غير كافية", + "notify": "إخطار {dex} بالإيداع", + "refund": "استرداد {amountIn} {tokenIn} من {dex}", + "swap": "أطلب من {dex} إجراء عملية التبديل", + "withdraw": "سحب {amountOut} {tokenOut} من {dex}" + }, + "quote": "يقتبس", + "quoteError": "حدث خطأ أثناء الحصول على عروض الأسعار للتبادل {tokenIn}", + "quoteTooLow": "أفضل سعر معروض هو {rate} {tokenOut} لكل {tokenIn} من {dex}. وهذا يعطي {amountOut} {tokenOut} مقابل {amountIn} {tokenIn}. لا يمكنك التبديل إلا إذا تجاوز المبلغ المعروض {minAmountOut}.", + "requote": "إعادة الاقتباس", + "swap": "تبديل", + "swapNotAvailable": "حاليًا، ليس من الممكن تبديل {tokenIn} إلى أي رمز آخر", + "swapToken": "تبديل {tokenIn}", + "swapTokenTo": "تبديل {tokenIn} إلى {tokenOut}", + "warningValueDropped": "بسبب انخفاض السيولة، فإن قيمة {tokenOut} التي ستحصل عليها هي < 90% من قيمة {tokenIn} التي ستقوم بمبادلتها (بناءً على القيمة الدولارية المحددة لهذه الرموز).", + "warningValueUnknown": "السعر الحالي بالدولار لواحد أو أكثر من هذه الرموز غير متاح، وبالتالي ليس من الممكن تحديد ما إذا كانت هذه المقايضة تمثل قيمة جيدة.", + "youWillReceive": "سوف تتلقى **{amountOut}** {tokenOut} (${usdOut}) مقابل **{amountIn}** {tokenIn} (${usdIn})." + }, + "tokenTransfer": { + "accountNameTaken": "لديك بالفعل حساب بهذا الاسم", + "amount": "كمية", + "chooseAddress": "اختر أحد عناوينك المحفوظة", + "chooseReceiver": "أدخل اسم المستخدم للمستلم", + "confirm": "يتأكد", + "confirmedSent": "أرسل {sender} {amount} {token} إلى {receiver}", + "done": "تحديث ومواصلة", + "enterAccountName": "أدخل اسمًا لهذا الحساب", + "failedToSaveAccount": "عذرا، لم نتمكن من حفظ حسابك", + "fee": "سيتم إضافة رسوم {fee} {token}", + "makeDeposit": "يرجى إيداع مبلغ في الحساب أعلاه وتحديث رصيدك.", + "max": "الأعلى", + "message": "رسالة", + "messagePlaceholder": "تضمين رسالة اختيارية", + "pendingSent": "{sender} يرسل {amount} {token} إلى {receiver}", + "pendingSentByYou": "أنت ترسل {amount} {token} إلى {receiver}", + "receiver": "متلقي", + "saveAccount": "يحفظ", + "saveAccountMessage": "هل ترغب في حفظ هذا الحساب لسهولة الوصول إليه في المرة القادمة؟", + "send": "يرسل", + "title": "أرسل {token}", + "transfer": "{token} نقل", + "unexpected": "تم استلام نوع تحويل تشفير غير متوقع", + "viewTransaction": "[المعاملة]({url})", + "warning": "تحذير - تقع على عاتقك مسؤولية التأكد من أنك تثق في هوية ودوافع المتلقي. لا يمكن لـ OpenChat عكس أي تحويلات {token}.", + "yourAccount": "عنوانك {token}", + "zeroBalance": "ليس لديك أي {token}." + }, + "totalChitEarned": "مجموع CHIT المكتسب", + "transferOwnership": "نقل الملكية إلى", + "transferOwnershipFailed": "فشل نقل الملكية", + "transferOwnershipSucceeded": "تم نقل الملكية بنجاح", + "translateMessage": "يترجم", + "typeGroupName": "يرجى كتابة **{name}** للتأكيد.", + "unableToLoadEmojiPicker": "عذراً! لم نتمكن من تحميل أداة اختيار الرموز التعبيرية. يُرجى إعادة التحميل والمحاولة مرة أخرى.", + "unableToLoadSMSUpgrade": "قبل ترقية مساحة التخزين الخاصة بك، نحتاج إلى تحديث OpenChat. يُرجى إعادة التحميل والمحاولة مرة أخرى.", + "unableToRefreshAccountBalance": "نأسف، لم نتمكن من تحديث رصيد حسابك {token}", + "unableToSaveUserProfile": "غير قادر على حفظ ملف تعريف المستخدم", + "unableToTranslate": "غير قادر على الترجمة", + "unarchiveChat": "إلغاء أرشفة الدردشة", + "unarchiveChatFailed": "فشل في إلغاء أرشفة الدردشة", + "unauthorizedToCreatePublicGroup": "ليس لديك الحق في إنشاء مجموعات عامة", + "unblockedBy": "تم إلغاء حظر {changedBy}", + "unblockUser": "إلغاء حظر المستخدم", + "unblockUserFailed": "غير قادر على إلغاء حظر المستخدم", + "unblockUserSucceeded": "لقد تم رفع الحظر عن المستخدم", + "unblockUserSucceededAddFailed": "تم إلغاء حظر المستخدم ولكن لا يمكن إضافته مرة أخرى إلى المجموعة", + "undeleteMessage": "إلغاء الحذف", + "undeleteMessageFailed": "فشل في إلغاء حذف الرسالة", + "undeletingMessage": "إلغاء حذف الرسالة بواسطة {username} على {timestamp}", + "unexpectedError": "خطأ غير متوقع", + "unfollowThread": "إلغاء متابعة هذا الموضوع", + "unfollowThreadFailed": "فشل إلغاء متابعة الموضوع", + "unfreezeCommunity": "إزالة تجميد المجتمع", + "unfreezeGroup": "إزالة التجميد عن المجموعة", + "unknown": "مجهول", + "unknownUser": "مستخدم غير معروف", + "unmuted": "غير مكتوم", + "unmuteNotifications": "إلغاء كتم صوت الإشعارات", + "unpinMessage": "إزالة التثبيت", + "unpinMessageFailed": "غير قادر على إلغاء تثبيت الرسالة", + "unresolvedReply": "محتوى الرسالة غير متاح", + "unsavedGroupChanges": "لقد قمت بإجراء تغييرات على المجموعة والتي لم يتم حفظها بعد", + "unsuspendedUser": "المستخدم غير الموقوف", + "unsuspendUser": "إلغاء تعليق المستخدم", + "untranslateMessage": "عدم الترجمة", + "update": "تحديث", + "updateNow": "أعد التحميل الآن", + "updateRequired": "إصدار جديد من OpenChat متاح - إعادة التحميل في {countdown}s", + "upgrade": { + "airdrops": "الإنزال الجوي والمكافآت", + "airdropsInfo": "سيكون مستخدمو الماس مؤهلين تلقائيًا للحصول على أي عمليات إنزال جوي أو هدايا مجانية!", + "allSupportedTokens": "جميع الرموز المدعومة", + "autorenew": "التجديد التلقائي", + "benefits": "فوائد الماس", + "button": "يرقي", + "cannotExtend": "لا يمكنك تمديد عضويتك إلا خلال ثلاثة أشهر من انتهاء صلاحيتها", + "chatAndIcp": "ICP & CHAT", + "comingSoon": "قريباً", + "confirm": "يتأكد", + "congratulations": "يمكنك الآن الاستمتاع بمميزات الماس لدينا!", + "createCommunities": "إنشاء المجتمعات", + "crypto": "إرسال العملات المشفرة", + "customThemes": "المواضيع المخصصة", + "diamond": "الماس", + "diamondBadge": "شارة الماس", + "diamondMediaMessages": "5 ميجا بايت / 50 ميجا بايت", + "diamondNotifications": "iOS وAndroid وسطح المكتب", + "diamondPrivateGroups": "25", + "diamondPublicGroups": "10", + "diamondStorage": "1 جيجا بايت متجددة", + "diamondStorageLimit": "1 جيجابايت من مساحة التخزين المجانية وبعدها سيتم حذف الملفات الأقدم تلقائيًا. البيانات ضمن حدود التخزين لا تنتهي صلاحيتها أبدًا.", + "diamondTextMessages": "حتى 4000", + "directChats": "المحادثات المباشرة", + "displayNames": "أسماء العرض", + "eligible": "صالح", + "expiryMessage": "تنتهي عضوية الماس الخاصة بك {relative}", + "extend": "تمديد الماس", + "extendShort": "يمتد", + "extendTo": "تمتد إلى", + "feature": "ميزة", + "features": "سمات", + "featuresTitle": "الترقية إلى الماس", + "forDisplayName": "الترقية لتعيين اسم العرض", + "free": "حر", + "freeMediaMessages": "1 ميجابايت / 5 ميجابايت", + "freeNotifications": "iOS وAndroid وسطح المكتب", + "freePrivateGroups": "5", + "freeStorage": "100 ميجا بايت متجددة", + "freeStorageLimit": "100 ميجابايت من مساحة التخزين المجانية وبعدها سيتم حذف الملفات الأقدم تلقائيًا. تنتهي صلاحية الملفات بعد 90 يومًا.", + "freeTextMessages": "حتى 1000", + "gatedGroups": "المجموعات المسورة", + "gatingMsg": "لإنشاء مجموعات ذات وصول محمي، يجب أن تكون مستخدمًا من الفئة Diamond", + "giphys": "صور متحركة", + "groupMsg": "لإنشاء ما يصل إلى 10 مجموعات عامة والتحكم في الوصول إلى المجموعة، يجب أن تكون مستخدمًا من فئة Diamond", + "insufficientFunds": "يرجى التأكد من أن حسابك {token} يحتوي على {amount} على الأقل", + "lifetime": "حياة", + "lifetimeMessage": "لديك عضوية ماسية مدى الحياة.", + "maxMessageLength": "الحد الأقصى لطول الرسالة هو {number} حرفًا", + "mediaLimits": "الصور والملفات والمقاطع الصوتية محدودة بـ {image}، والفيديوهات محدودة بـ {video}", + "mediaMessages": "رسائل إعلامية", + "membership": "عضوية", + "message": "أخبرني عن العضوية الماسية", + "nftProfile": "صورة الملف الشخصي NFT", + "notifications": "إشعارات", + "oneMonth": "شهر واحد", + "oneYear": "سنة واحدة", + "p2pSwap": "إجراء/قبول عمليات تبادل p2p", + "paymentFailed": "فشل الدفع لعضوية الماس", + "paymentSmallprint": "إذا اخترت تجديد الدفع تلقائيًا، فيجب عليك التأكد من وجود أموال كافية في حساب OpenChat ICP الخاص بك. سيرسل لك روبوت OpenChat تذكيرًا وديًا. إذا اخترت عدم التجديد التلقائي أو لم نتمكن من تحصيل الدفع المناسب، فستعود إلى الخطة المجانية على الفور وقد تفقد الملفات أو الوسائط التي نشرتها.", + "paymentTitle": "قسط", + "polls": "إنشاء استطلاعات الرأي", + "privateGroups": "المجموعات الخاصة", + "publicGroups": "المجموعات العامة", + "reactions": "ردود الفعل", + "reminders": "تذكيرات", + "storage": "تخزين", + "textMessages": "الرسائل النصية", + "threeMonths": "ثلاثة أشهر", + "translations": "الترجمات", + "upgradeSuccess": "لقد تم الدفع بنجاح", + "videoCalls": "مكالمات الفيديو" + }, + "upgradeByTransfer": "اي سي بي", + "upgradeStorage": "ترقية التخزين", + "urlCopiedToClipboard": "تم نسخ عنوان URL للمجموعة إلى الحافظة!", + "useAtOwnRisk": "استخدم على مسؤوليتك الخاصة", + "userId": "معرف المستخدم", + "userIdCopiedToClipboard": "تم نسخ معرف المستخدم إلى الحافظة", + "userInfoHeader": "عام", + "userIsBlocked": "إلغاء حظر المستخدم من إرسال الرسائل", + "userJoined": "انضم {username} إلى {level}", + "username": "اسم المستخدم", + "usernameRules": "أحرف أبجدية رقمية وشرطات سفلية فقط", + "userReferralMessage": "شارك هذا الرابط على OpenChat مع الأصدقاء والعائلة. ستربح مكافآت CHIT عن كل مستخدم تحيله ويستمر في التحقق من شخصيته الفريدة أو يصبح عضوًا في Diamond. إذا استمر مستخدم تحيله في الحصول على عضوية Diamond، فستربح 5000 CHIT، وإذا أثبت شخصيته الفريدة، فستربح 10000 CHIT وإذا حصل على Diamond مدى الحياة، فستربح 15000 CHIT. إذا قام لاحقًا بإجراء \"تحقق\" بقيمة أعلى، فستحصل على الفرق في CHIT.", + "users": "المستخدمون", + "userSearchFailed": "حدث خطأ أثناء البحث عن المستخدمين", + "version": "إصدار", + "videoCall": { + "accessRequest": "{username} يريد التحدث", + "askToSpeak": "اطلب التحدث", + "broadcastCallInProgress": "هناك مكالمة بث جارية في هذه الدردشة", + "broadcastStartedBy": "تم بدء مكالمة البث بواسطة {username}", + "callFailed": "عذرا، لم نتمكن من بدء مكالمة الفيديو", + "chat": "محادثة", + "clickToJoin": "انقر للانضمام إلى المكالمة", + "demoteToHidden": "جعل المشاهد", + "denied": "لقد رفض المضيف طلبك للتحدث", + "duration": "بعد {duration}", + "ended": "انتهت المكالمة", + "endedAt": "انتهت المكالمة عند {time}", + "hostEnded": "أنهى المضيف المكالمة", + "ignore": "يتجاهل", + "incoming": "مكالمة فيديو واردة", + "join": "انضم إلى المكالمة", + "joinVideo": "انضم إلى مكالمة الفيديو", + "leave": "ترك المكالمة", + "leaveVideo": "ترك مكالمة الفيديو", + "minimise": "تقليل", + "missedCall": "مكالمة فيديو فائتة من {username}", + "noMeetingToJoin": "عذرا ولكن الاجتماع الذي تحاول الانضمام إليه قد انتهى", + "participants": "مشاركون", + "presenters": "المقدمون ({count})", + "remoteStart": "تم بدء مكالمة فيديو بواسطة {name}", + "showParticipants": "عرض المشاركين", + "startBroadcast": "بدء البث", + "startedBy": "تم بدء مكالمة فيديو بواسطة {username}", + "startVideo": "بدء مكالمة فيديو", + "switchCall": "أنت الآن في مكالمة فيديو. هل ترغب في التبديل إلى هذه المكالمة؟", + "toggleCam": "قم بتبديل الكاميرا الخاصة بك", + "toggleMic": "قم بتبديل الميكروفون الخاص بك", + "toggleShare": "تبديل مشاركة سطح المكتب", + "viewers": "المشاهدون ({count})" + }, + "viewer": "مشاهد", + "wallet": "محفظة", + "walletSettings": "إعدادات المحفظة", + "websiteVersion": "من الموقع", + "weeks": "اسابيع", + "whatsHot": "ما هو الساخن؟", + "whitepaper": "الورقة البيضاء", + "yes": "نعم", + "yesPlease": "نعم من فضلك", + "yesterday": "أمس", + "you": "أنت", + "yourAccountIsSuspended": "تم تعليق حسابك", + "yourChats": "دردشاتك", + "youreBlocked": "عذرا - لقد تم حظرك من هذه المجموعة", + "yourRoleChanged": "{changedBy} غيّر دورك إلى {newRole}" +} \ No newline at end of file diff --git a/frontend/app/src/i18n/i18n.ts b/frontend/app/src/i18n/i18n.ts index 92b3444e34..9ec825499f 100644 --- a/frontend/app/src/i18n/i18n.ts +++ b/frontend/app/src/i18n/i18n.ts @@ -23,6 +23,7 @@ export const translationCodes: Record = { vi: "vi", pl: "pl", fa: "fa", + ar: "ar", }; export const supportedLanguages = [ @@ -82,6 +83,10 @@ export const supportedLanguages = [ name: "فارسی", code: "fa", }, + { + name: "عربي", + code: "ar", + }, ]; export const supportedLanguagesByCode = supportedLanguages.reduce( @@ -107,6 +112,7 @@ register("uk", () => import("./uk.json")); register("vi", () => import("./vi.json")); register("pl", () => import("./pl.json")); register("fa", () => import("./fa.json")); +register("ar", () => import("./ar.json")); export function getStoredLocale(): string { const fromStorage = localStorage.getItem(configKeys.locale); diff --git a/frontend/app/src/i18n/it.json b/frontend/app/src/i18n/it.json index 6fcff94c1a..14ec47f37c 100644 --- a/frontend/app/src/i18n/it.json +++ b/frontend/app/src/i18n/it.json @@ -345,7 +345,7 @@ "showZeroBalance": "mostra di più", "swap": "Scambio", "token": "gettone", - "tokens": "Gettoni", + "tokens": "Token", "topUp": "Riempire", "topUpBlurb": "Per ricaricare, trasferisci semplicemente un po' di {token} sul conto sopra.", "total": "Totale", @@ -1660,4 +1660,4 @@ "yourChats": "Le tue chat", "youreBlocked": "Siamo spiacenti, sei stato bloccato da questo gruppo", "yourRoleChanged": "{changedBy} ha cambiato il tuo ruolo in {newRole}" -} \ No newline at end of file +} From 4356722a1d94ec5bd5747855a1fece9d3b43d33f Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Mon, 25 Nov 2024 10:26:26 +0000 Subject: [PATCH 07/32] Update canisters post release (#6871) --- backend/canisters/airdrop_bot/CHANGELOG.md | 2 ++ .../airdrop_bot/impl/src/lifecycle/post_upgrade.rs | 9 +++------ backend/canisters/group_index/CHANGELOG.md | 2 ++ .../group_index/impl/src/lifecycle/post_upgrade.rs | 7 +++---- backend/canisters/local_group_index/CHANGELOG.md | 2 ++ backend/canisters/local_group_index/impl/src/lib.rs | 1 - .../impl/src/lifecycle/post_upgrade.rs | 11 +---------- backend/canisters/local_user_index/CHANGELOG.md | 2 ++ backend/canisters/neuron_controller/CHANGELOG.md | 2 ++ backend/canisters/user_index/CHANGELOG.md | 2 ++ 10 files changed, 19 insertions(+), 21 deletions(-) diff --git a/backend/canisters/airdrop_bot/CHANGELOG.md b/backend/canisters/airdrop_bot/CHANGELOG.md index 071f527388..af967b22c5 100644 --- a/backend/canisters/airdrop_bot/CHANGELOG.md +++ b/backend/canisters/airdrop_bot/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +## [[2.0.1468](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1468-airdrop_bot)] - 2024-11-24 + ### Added - Add an error log with http endpoint ([#6608](https://github.com/open-chat-labs/open-chat/pull/6608)) diff --git a/backend/canisters/airdrop_bot/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/airdrop_bot/impl/src/lifecycle/post_upgrade.rs index ee66a3abf9..f1065dfbff 100644 --- a/backend/canisters/airdrop_bot/impl/src/lifecycle/post_upgrade.rs +++ b/backend/canisters/airdrop_bot/impl/src/lifecycle/post_upgrade.rs @@ -14,13 +14,10 @@ fn post_upgrade(args: Args) { let memory = get_upgrades_memory(); let reader = get_reader(&memory); - let (mut data, logs, traces): (Data, Vec, Vec) = msgpack::deserialize(reader).unwrap(); + let (data, errors, logs, traces): (Data, Vec, Vec, Vec) = + msgpack::deserialize(reader).unwrap(); - data.pending_actions_queue.set_max_concurrency(20); - - // TODO: After release change this to - // let (data, errors, logs, traces): (Data, Vec, Vec, Vec) = msgpack::deserialize(reader).unwrap(); - canister_logger::init_with_logs(data.test_mode, Vec::new(), logs, traces); + canister_logger::init_with_logs(data.test_mode, errors, logs, traces); let env = init_env(data.rng_seed); init_state(env, data, args.wasm_version); diff --git a/backend/canisters/group_index/CHANGELOG.md b/backend/canisters/group_index/CHANGELOG.md index 873f5139f7..d09ace3a7d 100644 --- a/backend/canisters/group_index/CHANGELOG.md +++ b/backend/canisters/group_index/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +## [[2.0.1465](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1465-group_index)] - 2024-11-21 + ### Added - Add an error log with http endpoint ([#6608](https://github.com/open-chat-labs/open-chat/pull/6608)) diff --git a/backend/canisters/group_index/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/group_index/impl/src/lifecycle/post_upgrade.rs index 030a4c61fc..b8eb669225 100644 --- a/backend/canisters/group_index/impl/src/lifecycle/post_upgrade.rs +++ b/backend/canisters/group_index/impl/src/lifecycle/post_upgrade.rs @@ -15,11 +15,10 @@ fn post_upgrade(args: Args) { let memory = get_upgrades_memory(); let reader = get_reader(&memory); - let (data, logs, traces): (Data, Vec, Vec) = msgpack::deserialize(reader).unwrap(); + let (data, errors, logs, traces): (Data, Vec, Vec, Vec) = + msgpack::deserialize(reader).unwrap(); - // TODO: After release change this to - // let (data, errors, logs, traces): (Data, Vec, Vec, Vec) = msgpack::deserialize(reader).unwrap(); - canister_logger::init_with_logs(data.test_mode, Vec::new(), logs, traces); + canister_logger::init_with_logs(data.test_mode, errors, logs, traces); let env = init_env(data.rng_seed); init_cycles_dispenser_client(data.cycles_dispenser_canister_id, data.test_mode); diff --git a/backend/canisters/local_group_index/CHANGELOG.md b/backend/canisters/local_group_index/CHANGELOG.md index 61444c501c..e85be7c54a 100644 --- a/backend/canisters/local_group_index/CHANGELOG.md +++ b/backend/canisters/local_group_index/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +## [[2.0.1462](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1462-local_group_index)] - 2024-11-21 + ### Changed - Pass in `bot_api_gateway` when creating groups and communities ([#6842](https://github.com/open-chat-labs/open-chat/pull/6842)) diff --git a/backend/canisters/local_group_index/impl/src/lib.rs b/backend/canisters/local_group_index/impl/src/lib.rs index 8f9f3ac49f..5e2df216f5 100644 --- a/backend/canisters/local_group_index/impl/src/lib.rs +++ b/backend/canisters/local_group_index/impl/src/lib.rs @@ -140,7 +140,6 @@ struct Data { pub local_user_index_canister_id: CanisterId, pub group_index_canister_id: CanisterId, pub notifications_canister_id: CanisterId, - #[serde(default = "CanisterId::anonymous")] pub bot_api_gateway_canister_id: CanisterId, pub groups_requiring_upgrade: CanistersRequiringUpgrade, pub communities_requiring_upgrade: CanistersRequiringUpgrade, diff --git a/backend/canisters/local_group_index/impl/src/lifecycle/post_upgrade.rs b/backend/canisters/local_group_index/impl/src/lifecycle/post_upgrade.rs index 30dce02a90..45e0149e2c 100644 --- a/backend/canisters/local_group_index/impl/src/lifecycle/post_upgrade.rs +++ b/backend/canisters/local_group_index/impl/src/lifecycle/post_upgrade.rs @@ -7,7 +7,6 @@ use ic_cdk::post_upgrade; use local_group_index_canister::post_upgrade::Args; use stable_memory::get_reader; use tracing::info; -use types::CanisterId; use utils::cycles::init_cycles_dispenser_client; #[post_upgrade] @@ -16,17 +15,9 @@ fn post_upgrade(args: Args) { let memory = get_upgrades_memory(); let reader = get_reader(&memory); - let (mut data, errors, logs, traces): (Data, Vec, Vec, Vec) = + let (data, errors, logs, traces): (Data, Vec, Vec, Vec) = msgpack::deserialize(reader).unwrap(); - if data.local_user_index_canister_id == CanisterId::from_text("nq4qv-wqaaa-aaaaf-bhdgq-cai").unwrap() { - data.bot_api_gateway_canister_id = CanisterId::from_text("xdh4a-myaaa-aaaaf-bscya-cai").unwrap() - } else if data.local_user_index_canister_id == CanisterId::from_text("aboy3-giaaa-aaaar-aaaaq-cai").unwrap() { - data.bot_api_gateway_canister_id = CanisterId::from_text("lvpeh-caaaa-aaaar-boaha-cai").unwrap() - } else if data.local_user_index_canister_id == CanisterId::from_text("pecvb-tqaaa-aaaaf-bhdiq-cai").unwrap() { - data.bot_api_gateway_canister_id = CanisterId::from_text("xeg2u-baaaa-aaaaf-bscyq-cai").unwrap() - } - canister_logger::init_with_logs(data.test_mode, errors, logs, traces); let env = init_env(data.rng_seed); diff --git a/backend/canisters/local_user_index/CHANGELOG.md b/backend/canisters/local_user_index/CHANGELOG.md index f1fa9f21f8..a3b7c3f27e 100644 --- a/backend/canisters/local_user_index/CHANGELOG.md +++ b/backend/canisters/local_user_index/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +## [[2.0.1463](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1463-local_user_index)] - 2024-11-21 + ### Changed - Avoid pushing events to bot users ([#6846](https://github.com/open-chat-labs/open-chat/pull/6846)) diff --git a/backend/canisters/neuron_controller/CHANGELOG.md b/backend/canisters/neuron_controller/CHANGELOG.md index f0eadd3c9c..47bc926b80 100644 --- a/backend/canisters/neuron_controller/CHANGELOG.md +++ b/backend/canisters/neuron_controller/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +## [[2.0.1467](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1467-neuron_controller)] - 2024-11-24 + ### Changed - Increase CyclesDispenser's minimum balance to 10k ICP ([#6870](https://github.com/open-chat-labs/open-chat/pull/6870)) diff --git a/backend/canisters/user_index/CHANGELOG.md b/backend/canisters/user_index/CHANGELOG.md index 048d94682d..34b2fba30d 100644 --- a/backend/canisters/user_index/CHANGELOG.md +++ b/backend/canisters/user_index/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +## [[2.0.1464](https://github.com/open-chat-labs/open-chat/releases/tag/v2.0.1464-user_index)] - 2024-11-21 + ### Changed - Pass in `BotApiCanister` when installing a new LocalUserIndex ([#6828](https://github.com/open-chat-labs/open-chat/pull/6828)) From 24ba40f1fe51363a4dcfedc7c897a807eab972d8 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Mon, 25 Nov 2024 10:41:14 +0000 Subject: [PATCH 08/32] Reduce size of community members when serialized (#6883) --- backend/canisters/community/CHANGELOG.md | 1 + .../community/impl/src/model/members.rs | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/canisters/community/CHANGELOG.md b/backend/canisters/community/CHANGELOG.md index 146970a7f5..31503fa99c 100644 --- a/backend/canisters/community/CHANGELOG.md +++ b/backend/canisters/community/CHANGELOG.md @@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Return `FailedToDeserialize` and log error if unable to read event ([#6873](https://github.com/open-chat-labs/open-chat/pull/6873)) - Extract stable memory map so it can store additional datasets ([#6876](https://github.com/open-chat-labs/open-chat/pull/6876)) - Avoid iterating all users when determining who to notify of new message ([#6877](https://github.com/open-chat-labs/open-chat/pull/6877)) +- Reduce size of community members when serialized ([#6883](https://github.com/open-chat-labs/open-chat/pull/6883)) ### Removed diff --git a/backend/canisters/community/impl/src/model/members.rs b/backend/canisters/community/impl/src/model/members.rs index 45f7d98a4e..e42f936eea 100644 --- a/backend/canisters/community/impl/src/model/members.rs +++ b/backend/canisters/community/impl/src/model/members.rs @@ -7,7 +7,8 @@ use serde_repr::{Deserialize_repr, Serialize_repr}; use std::collections::hash_map::Entry::Vacant; use std::collections::{BTreeSet, HashMap, HashSet}; use types::{ - ChannelId, CommunityMember, CommunityPermissions, CommunityRole, TimestampMillis, Timestamped, UserId, UserType, Version, + is_default, ChannelId, CommunityMember, CommunityPermissions, CommunityRole, TimestampMillis, Timestamped, UserId, + UserType, Version, }; const MAX_MEMBERS_PER_COMMUNITY: u32 = 100_000; @@ -415,18 +416,29 @@ impl Members for CommunityMembers { #[derive(Serialize, Deserialize, Clone)] pub struct CommunityMemberInternal { + #[serde(rename = "u", alias = "user_id")] pub user_id: UserId, + #[serde(rename = "d", alias = "date_added")] pub date_added: TimestampMillis, + #[serde(rename = "r", alias = "role", default, skip_serializing_if = "is_default")] pub role: CommunityRole, + #[serde(rename = "s", alias = "suspended", default, skip_serializing_if = "is_default")] pub suspended: Timestamped, + #[serde(rename = "c", alias = "channels")] pub channels: HashSet, + #[serde(rename = "cr", alias = "channel_removed", default, skip_serializing_if = "Vec::is_empty")] pub channels_removed: Vec>, + #[serde(rename = "ra", alias = "rules_accepted", skip_serializing_if = "Option::is_none")] pub rules_accepted: Option>, + #[serde(rename = "ut", alias = "user_type", default, skip_serializing_if = "is_default")] pub user_type: UserType, + #[serde(rename = "dn", alias = "display_name", default, skip_serializing_if = "is_default")] display_name: Timestamped>, + #[serde(rename = "rb", alias = "referred_by", skip_serializing_if = "Option::is_none")] pub referred_by: Option, + #[serde(rename = "rf", alias = "referrals", default, skip_serializing_if = "HashSet::is_empty")] pub referrals: HashSet, - #[serde(default)] + #[serde(rename = "l", alias = "lapsed", default, skip_serializing_if = "is_default")] pub lapsed: Timestamped, } From c68a4dff46c17aa4edaa9b2cf5733531b287b424 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Mon, 25 Nov 2024 11:18:50 +0000 Subject: [PATCH 09/32] Avoid many cases where we were iterating over all community members (#6884) --- Cargo.lock | 2 + backend/canisters/community/CHANGELOG.md | 3 +- backend/canisters/community/impl/Cargo.toml | 4 + backend/canisters/community/impl/src/lib.rs | 28 +- .../community/impl/src/model/members.rs | 351 ++++++++++++++---- .../impl/src/model/members/proptests.rs | 181 +++++++++ .../community/impl/src/queries/invite_code.rs | 2 +- .../impl/src/queries/selected_initial.rs | 22 +- .../impl/src/queries/summary_updates.rs | 4 +- .../impl/src/updates/accept_p2p_swap.rs | 4 +- .../src/updates/add_members_to_channel.rs | 4 +- .../impl/src/updates/add_reaction.rs | 4 +- .../impl/src/updates/c2c_delete_community.rs | 8 +- .../impl/src/updates/c2c_freeze_community.rs | 2 +- .../impl/src/updates/c2c_invite_users.rs | 4 +- .../updates/c2c_invite_users_to_channel.rs | 2 +- .../impl/src/updates/c2c_join_channel.rs | 8 +- .../impl/src/updates/c2c_join_community.rs | 6 +- .../impl/src/updates/c2c_leave_community.rs | 4 +- .../src/updates/c2c_set_user_suspended.rs | 14 +- .../impl/src/updates/c2c_tip_message.rs | 4 +- .../impl/src/updates/cancel_invites.rs | 6 +- .../impl/src/updates/change_channel_role.rs | 4 +- .../community/impl/src/updates/change_role.rs | 9 +- .../community/impl/src/updates/claim_prize.rs | 4 +- .../impl/src/updates/create_channel.rs | 8 +- .../impl/src/updates/create_user_group.rs | 6 +- .../impl/src/updates/delete_channel.rs | 4 +- .../impl/src/updates/delete_messages.rs | 4 +- .../impl/src/updates/delete_user_groups.rs | 6 +- .../impl/src/updates/disable_invite_code.rs | 6 +- .../impl/src/updates/edit_message.rs | 4 +- .../impl/src/updates/enable_invite_code.rs | 6 +- .../impl/src/updates/follow_thread.rs | 4 +- .../impl/src/updates/import_group.rs | 2 +- .../impl/src/updates/leave_channel.rs | 2 +- .../community/impl/src/updates/pin_message.rs | 4 +- .../impl/src/updates/register_poll_vote.rs | 4 +- .../src/updates/register_proposal_vote.rs | 4 +- .../src/updates/register_proposal_vote_v2.rs | 2 +- .../impl/src/updates/remove_member.rs | 8 +- .../src/updates/remove_member_from_channel.rs | 4 +- .../impl/src/updates/remove_reaction.rs | 4 +- .../impl/src/updates/report_message.rs | 4 +- .../impl/src/updates/send_message.rs | 4 +- .../src/updates/set_member_display_name.rs | 4 +- .../src/updates/set_video_call_presence.rs | 4 +- .../impl/src/updates/start_video_call.rs | 2 +- .../src/updates/toggle_mute_notifications.rs | 4 +- .../impl/src/updates/unblock_user.rs | 6 +- .../impl/src/updates/undelete_messages.rs | 4 +- .../impl/src/updates/unfollow_thread.rs | 4 +- .../impl/src/updates/update_community.rs | 10 +- .../impl/src/updates/update_user_group.rs | 6 +- .../libraries/group_chat_core/src/members.rs | 8 +- 55 files changed, 621 insertions(+), 205 deletions(-) create mode 100644 backend/canisters/community/impl/src/model/members/proptests.rs diff --git a/Cargo.lock b/Cargo.lock index 0202ffa6c0..8287cbfd50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1621,6 +1621,7 @@ dependencies = [ "msgpack", "notifications_canister", "notifications_canister_c2c_client", + "proptest", "rand 0.8.5", "regex-lite", "search", @@ -1630,6 +1631,7 @@ dependencies = [ "stable_memory", "stable_memory_map", "storage_bucket_client", + "test-strategy", "timer_job_queues", "tracing", "types", diff --git a/backend/canisters/community/CHANGELOG.md b/backend/canisters/community/CHANGELOG.md index 31503fa99c..22cc9b7411 100644 --- a/backend/canisters/community/CHANGELOG.md +++ b/backend/canisters/community/CHANGELOG.md @@ -31,8 +31,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Reduce the number of events stored on the heap in the `HybridMap` ([#6867](https://github.com/open-chat-labs/open-chat/pull/6867)) - Return `FailedToDeserialize` and log error if unable to read event ([#6873](https://github.com/open-chat-labs/open-chat/pull/6873)) - Extract stable memory map so it can store additional datasets ([#6876](https://github.com/open-chat-labs/open-chat/pull/6876)) -- Avoid iterating all users when determining who to notify of new message ([#6877](https://github.com/open-chat-labs/open-chat/pull/6877)) +- Avoid iterating all channel members when determining who to notify of new message ([#6877](https://github.com/open-chat-labs/open-chat/pull/6877)) - Reduce size of community members when serialized ([#6883](https://github.com/open-chat-labs/open-chat/pull/6883)) +- Avoid many cases where we were iterating over all community members ([#6884](https://github.com/open-chat-labs/open-chat/pull/6884)) ### Removed diff --git a/backend/canisters/community/impl/Cargo.toml b/backend/canisters/community/impl/Cargo.toml index 9f6c59044f..21e6d1bcb2 100644 --- a/backend/canisters/community/impl/Cargo.toml +++ b/backend/canisters/community/impl/Cargo.toml @@ -68,3 +68,7 @@ user_canister_c2c_client = { path = "../../user/c2c_client" } user_index_canister = { path = "../../user_index/api" } user_index_canister_c2c_client = { path = "../../user_index/c2c_client" } utils = { path = "../../../libraries/utils" } + +[dev-dependencies] +proptest = { workspace = true } +test-strategy = { workspace = true } diff --git a/backend/canisters/community/impl/src/lib.rs b/backend/canisters/community/impl/src/lib.rs index 3baead4e8f..41a0416e29 100644 --- a/backend/canisters/community/impl/src/lib.rs +++ b/backend/canisters/community/impl/src/lib.rs @@ -33,8 +33,8 @@ use std::time::Duration; use timer_job_queues::GroupedTimerJobQueue; use types::{ AccessGate, AccessGateConfigInternal, Achievement, BuildVersion, CanisterId, ChannelId, ChatMetrics, - CommunityCanisterCommunitySummary, CommunityMembership, CommunityPermissions, CommunityRole, Cryptocurrency, Cycles, - Document, Empty, FrozenGroupInfo, Milliseconds, Notification, Rules, TimestampMillis, Timestamped, UserId, UserType, + CommunityCanisterCommunitySummary, CommunityMembership, CommunityPermissions, Cryptocurrency, Cycles, Document, Empty, + FrozenGroupInfo, Milliseconds, Notification, Rules, TimestampMillis, Timestamped, UserId, UserType, }; use types::{CommunityId, SNS_FEE_SHARE_PERCENT}; use user_canister::CommunityCanisterEvent; @@ -120,13 +120,7 @@ impl RuntimeState { pub fn queue_access_gate_payments(&mut self, payment: GatePayment) { // Queue a payment to each owner less the fee - let owners: Vec = self - .data - .members - .iter() - .filter(|m| matches!(m.role, CommunityRole::Owner)) - .map(|m| m.user_id) - .collect(); + let owners = self.data.members.owners(); let owner_count = owners.len() as u128; let owner_share = (payment.amount * (100 - SNS_FEE_SHARE_PERCENT) / 100) / owner_count; @@ -136,7 +130,7 @@ impl RuntimeState { amount: owner_share, fee: payment.fee, ledger_canister: payment.ledger_canister_id, - recipient: PaymentRecipient::Member(owner), + recipient: PaymentRecipient::Member(*owner), reason: PendingPaymentReason::AccessGate, }); } @@ -168,13 +162,13 @@ impl RuntimeState { let (channels, membership) = if let Some(m) = member { let membership = CommunityMembership { joined: m.date_added, - role: m.role, + role: m.role(), rules_accepted: m .rules_accepted .as_ref() .map_or(false, |version| version.value >= self.data.rules.text.version), display_name: m.display_name().value.clone(), - lapsed: m.lapsed.value, + lapsed: m.lapsed().value, }; // Return all the channels that the user is a member of @@ -294,8 +288,8 @@ impl RuntimeState { public: self.data.is_public, date_created: self.data.date_created, members: self.data.members.len(), - admins: self.data.members.admin_count(), - owners: self.data.members.owner_count(), + admins: self.data.members.admins().len() as u32, + owners: self.data.members.owners().len() as u32, blocked: self.data.members.blocked().len() as u32, invited: self.data.invited_users.len() as u32, frozen: self.data.is_frozen(), @@ -597,7 +591,7 @@ impl Data { channel.chat.members.update_lapsed(user_id, lapsed, now); } } else { - self.members.updated_lapsed(user_id, lapsed, now); + self.members.update_lapsed(user_id, lapsed, now); } } @@ -674,9 +668,9 @@ impl Data { if hidden_for_non_members { if let Some(member) = member { - if member.suspended.value { + if member.suspended().value { return Err(EventsResponse::UserSuspended); - } else if member.lapsed.value { + } else if member.lapsed().value { return Err(EventsResponse::UserLapsed); } } else { diff --git a/backend/canisters/community/impl/src/model/members.rs b/backend/canisters/community/impl/src/model/members.rs index e42f936eea..4463cc44fc 100644 --- a/backend/canisters/community/impl/src/model/members.rs +++ b/backend/canisters/community/impl/src/model/members.rs @@ -4,27 +4,105 @@ use group_community_common::{Member, Members}; use rand::RngCore; use serde::{Deserialize, Serialize}; use serde_repr::{Deserialize_repr, Serialize_repr}; -use std::collections::hash_map::Entry::Vacant; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::btree_map::Entry::Vacant; +use std::collections::{BTreeMap, BTreeSet}; use types::{ is_default, ChannelId, CommunityMember, CommunityPermissions, CommunityRole, TimestampMillis, Timestamped, UserId, UserType, Version, }; +#[cfg(test)] +mod proptests; + const MAX_MEMBERS_PER_COMMUNITY: u32 = 100_000; #[derive(Serialize, Deserialize)] +#[serde(from = "CommunityMembersPrevious")] pub struct CommunityMembers { - members: HashMap, + members: BTreeMap, user_groups: UserGroups, // This includes the userIds of community members and also users invited to the community - principal_to_user_id_map: HashMap, - blocked: HashSet, - admin_count: u32, - owner_count: u32, + principal_to_user_id_map: BTreeMap, + member_ids: BTreeSet, + owners: BTreeSet, + admins: BTreeSet, + bots: BTreeMap, + blocked: BTreeSet, + lapsed: BTreeSet, + suspended: BTreeSet, + members_with_display_names: BTreeSet, + members_with_referrals: BTreeSet, + updates: BTreeSet<(TimestampMillis, UserId, MemberUpdate)>, +} + +#[derive(Serialize, Deserialize)] +pub struct CommunityMembersPrevious { + members: BTreeMap, + user_groups: UserGroups, + principal_to_user_id_map: BTreeMap, + blocked: BTreeSet, updates: BTreeSet<(TimestampMillis, UserId, MemberUpdate)>, } +impl From for CommunityMembers { + fn from(value: CommunityMembersPrevious) -> Self { + let mut member_ids = BTreeSet::new(); + let mut owners = BTreeSet::new(); + let mut admins = BTreeSet::new(); + let mut bots = BTreeMap::new(); + let mut lapsed = BTreeSet::new(); + let mut suspended = BTreeSet::new(); + let mut members_with_display_names = BTreeSet::new(); + let mut members_with_referrals = BTreeSet::new(); + + for member in value.members.values() { + member_ids.insert(member.user_id); + + match member.role { + CommunityRole::Owner => owners.insert(member.user_id), + CommunityRole::Admin => admins.insert(member.user_id), + CommunityRole::Member => false, + }; + + if member.lapsed.value { + lapsed.insert(member.user_id); + } + + if member.user_type.is_bot() { + bots.insert(member.user_id, member.user_type); + } + + if member.suspended.value { + suspended.insert(member.user_id); + } + + if member.display_name.is_some() { + members_with_display_names.insert(member.user_id); + } + + if !member.referrals.is_empty() { + members_with_referrals.insert(member.user_id); + } + } + + CommunityMembers { + members: value.members, + user_groups: value.user_groups, + principal_to_user_id_map: value.principal_to_user_id_map, + member_ids, + owners, + admins, + bots, + blocked: value.blocked, + lapsed, + suspended, + members_with_display_names, + members_with_referrals, + updates: value.updates, + } + } +} + impl CommunityMembers { pub fn new( creator_principal: Principal, @@ -44,7 +122,7 @@ impl CommunityMembers { user_type: creator_user_type, display_name: Timestamped::default(), referred_by: None, - referrals: HashSet::new(), + referrals: BTreeSet::new(), lapsed: Timestamped::default(), }; @@ -52,9 +130,19 @@ impl CommunityMembers { members: vec![(creator_user_id, member)].into_iter().collect(), user_groups: UserGroups::default(), principal_to_user_id_map: vec![(creator_principal, creator_user_id)].into_iter().collect(), - blocked: HashSet::new(), - admin_count: 0, - owner_count: 1, + member_ids: [creator_user_id].into_iter().collect(), + owners: [creator_user_id].into_iter().collect(), + admins: BTreeSet::new(), + bots: if creator_user_type.is_bot() { + [(creator_user_id, creator_user_type)].into_iter().collect() + } else { + BTreeMap::new() + }, + blocked: BTreeSet::new(), + lapsed: BTreeSet::new(), + suspended: BTreeSet::new(), + members_with_display_names: BTreeSet::new(), + members_with_referrals: BTreeSet::new(), updates: BTreeSet::new(), } } @@ -81,13 +169,13 @@ impl CommunityMembers { date_added: now, role: CommunityRole::Member, suspended: Timestamped::default(), - channels: HashSet::new(), + channels: BTreeSet::new(), channels_removed: Vec::new(), rules_accepted: None, user_type, display_name: Timestamped::default(), referred_by, - referrals: HashSet::new(), + referrals: BTreeSet::new(), lapsed: Timestamped::default(), }; e.insert(member.clone()); @@ -95,7 +183,10 @@ impl CommunityMembers { if let Some(referrer) = referred_by.and_then(|ref_id| self.get_by_user_id_mut(&ref_id)) { referrer.referrals.insert(user_id); + let referrer_user_id = referrer.user_id; + self.members_with_referrals.insert(referrer_user_id); } + self.member_ids.insert(user_id); AddResult::Success(member) } @@ -117,16 +208,34 @@ impl CommunityMembers { if let Some(user_id) = self.principal_to_user_id_map.remove(principal) { if let Some(member) = self.members.remove(&user_id) { match member.role { - CommunityRole::Owner => self.owner_count -= 1, - CommunityRole::Admin => self.admin_count -= 1, - _ => (), + CommunityRole::Owner => self.owners.remove(&user_id), + CommunityRole::Admin => self.admins.remove(&user_id), + _ => false, + }; + if member.user_type.is_bot() { + self.bots.remove(&user_id); + } + if member.lapsed.value { + self.lapsed.remove(&user_id); + } + if member.suspended.value { + self.suspended.remove(&user_id); + } + if member.display_name.is_some() { + self.members_with_display_names.remove(&user_id); + } + if !member.referrals.is_empty() { + self.members_with_referrals.remove(&user_id); } - - self.user_groups.remove_user_from_all(&member.user_id, now); - if let Some(referrer) = member.referred_by.and_then(|uid| self.get_by_user_id_mut(&uid)) { referrer.referrals.remove(&user_id); + if referrer.referrals.is_empty() { + let referrer_user_id = referrer.user_id; + self.members_with_referrals.remove(&referrer_user_id); + } } + self.user_groups.remove_user_from_all(&member.user_id, now); + self.member_ids.remove(&user_id); return Some(member); } @@ -135,6 +244,7 @@ impl CommunityMembers { None } + #[allow(clippy::too_many_arguments)] pub fn change_role( &mut self, user_id: UserId, @@ -143,6 +253,7 @@ impl CommunityMembers { permissions: &CommunityPermissions, is_caller_platform_moderator: bool, is_user_platform_moderator: bool, + now: TimestampMillis, ) -> ChangeRoleResult { // Is the caller authorized to change the user to this role match self.get_by_user_id(&user_id) { @@ -158,10 +269,7 @@ impl CommunityMembers { None => return ChangeRoleResult::UserNotInCommunity, } - let mut owner_count = self.owner_count; - let mut admin_count = self.admin_count; - - let member = match self.get_by_user_id_mut(&target_user_id) { + let member = match self.members.get_mut(&target_user_id) { Some(p) => p, None => return ChangeRoleResult::TargetUserNotInCommunity, }; @@ -172,7 +280,7 @@ impl CommunityMembers { } // It is not possible to change the role of the last owner - if member.role.is_owner() && owner_count <= 1 { + if member.role.is_owner() && self.owners.len() <= 1 { return ChangeRoleResult::Invalid; } // It is not currently possible to make a bot an owner @@ -186,22 +294,24 @@ impl CommunityMembers { return ChangeRoleResult::Unchanged; } - match member.role { - CommunityRole::Owner => owner_count -= 1, - CommunityRole::Admin => admin_count -= 1, - _ => (), - } + match prev_role { + CommunityRole::Owner => self.owners.remove(&target_user_id), + CommunityRole::Admin => self.admins.remove(&target_user_id), + _ => false, + }; member.role = new_role; - match member.role { - CommunityRole::Owner => owner_count += 1, - CommunityRole::Admin => admin_count += 1, - _ => (), - } - - self.owner_count = owner_count; - self.admin_count = admin_count; + match new_role { + CommunityRole::Owner => { + if member.lapsed.value { + self.update_lapsed(target_user_id, false, now); + } + self.owners.insert(target_user_id) + } + CommunityRole::Admin => self.admins.insert(target_user_id), + _ => false, + }; ChangeRoleResult::Success(ChangeRoleSuccessResult { caller_id: user_id, @@ -209,6 +319,22 @@ impl CommunityMembers { }) } + pub fn set_suspended(&mut self, user_id: UserId, suspended: bool, now: TimestampMillis) -> Option { + let member = self.members.get_mut(&user_id)?; + + if member.suspended.value != suspended { + member.suspended = Timestamped::new(suspended, now); + if suspended { + self.suspended.insert(user_id); + } else { + self.suspended.remove(&user_id); + } + Some(true) + } else { + Some(false) + } + } + pub fn create_user_group( &mut self, name: String, @@ -297,10 +423,6 @@ impl CommunityMembers { self.blocked.iter().copied().collect() } - pub fn iter(&self) -> impl Iterator { - self.members.values() - } - pub fn iter_mut(&mut self) -> impl Iterator { self.members.values_mut() } @@ -348,37 +470,79 @@ impl CommunityMembers { self.members.len() as u32 } - pub fn owner_count(&self) -> u32 { - self.owner_count + pub fn member_ids(&self) -> &BTreeSet { + &self.member_ids + } + + pub fn owners(&self) -> &BTreeSet { + &self.owners + } + + pub fn admins(&self) -> &BTreeSet { + &self.admins + } + + pub fn lapsed(&self) -> &BTreeSet { + &self.lapsed + } + + pub fn suspended(&self) -> &BTreeSet { + &self.suspended + } + + pub fn members_with_display_names(&self) -> &BTreeSet { + &self.members_with_display_names } - pub fn admin_count(&self) -> u32 { - self.admin_count + pub fn members_with_referrals(&self) -> &BTreeSet { + &self.members_with_referrals } pub fn set_display_name(&mut self, user_id: UserId, display_name: Option, now: TimestampMillis) { if let Some(member) = self.members.get_mut(&user_id) { + if display_name.is_some() { + self.members_with_display_names.insert(user_id); + } else { + self.members_with_display_names.remove(&user_id); + } member.display_name = Timestamped::new(display_name, now); self.updates.insert((now, user_id, MemberUpdate::DisplayNameChanged)); } } - pub fn updated_lapsed(&mut self, user_id: UserId, lapsed: bool, now: TimestampMillis) { - if let Some(member) = self.members.get_mut(&user_id) { - if member.set_lapsed(lapsed, now) { - self.updates.insert(( - now, - user_id, - if lapsed { MemberUpdate::Lapsed } else { MemberUpdate::Unlapsed }, - )); + pub fn update_lapsed(&mut self, user_id: UserId, lapsed: bool, now: TimestampMillis) { + let Some(member) = self.members.get_mut(&user_id) else { + return; + }; + + let updated = if lapsed { + // Owners can't lapse + !member.is_owner() && member.set_lapsed(true, now) + } else { + member.set_lapsed(false, now) + }; + + if updated { + if lapsed { + self.lapsed.insert(user_id); + } else { + self.lapsed.remove(&user_id); } + + self.updates.insert(( + now, + user_id, + if lapsed { MemberUpdate::Lapsed } else { MemberUpdate::Unlapsed }, + )); } } pub fn unlapse_all(&mut self, now: TimestampMillis) { - for member in self.members.values_mut() { - if member.set_lapsed(false, now) { - self.updates.insert((now, member.user_id, MemberUpdate::Unlapsed)); + for user_id in std::mem::take(&mut self.lapsed) { + if let Some(member) = self.members.get_mut(&user_id) { + if member.set_lapsed(false, now) { + self.updates.insert((now, member.user_id, MemberUpdate::Unlapsed)); + } } } } @@ -400,6 +564,51 @@ impl CommunityMembers { .max() .unwrap() } + + #[cfg(test)] + fn check_invariants(&self) { + let mut member_ids = BTreeSet::new(); + let mut owners = BTreeSet::new(); + let mut admins = BTreeSet::new(); + let mut lapsed = BTreeSet::new(); + let mut suspended = BTreeSet::new(); + let mut members_with_display_names = BTreeSet::new(); + let mut members_with_referrals = BTreeSet::new(); + + for member in self.members.values() { + member_ids.insert(member.user_id); + + match member.role { + CommunityRole::Owner => owners.insert(member.user_id), + CommunityRole::Admin => admins.insert(member.user_id), + CommunityRole::Member => false, + }; + + if member.lapsed.value { + lapsed.insert(member.user_id); + } + + if member.suspended.value { + suspended.insert(member.user_id); + } + + if member.display_name.is_some() { + members_with_display_names.insert(member.user_id); + } + + if !member.referrals.is_empty() { + members_with_referrals.insert(member.user_id); + } + } + + assert_eq!(member_ids, self.member_ids); + assert_eq!(owners, self.owners); + assert_eq!(admins, self.admins); + assert_eq!(lapsed, self.lapsed); + assert_eq!(suspended, self.suspended); + assert_eq!(members_with_display_names, self.members_with_display_names); + assert_eq!(members_with_referrals, self.members_with_referrals); + } } impl Members for CommunityMembers { @@ -421,11 +630,9 @@ pub struct CommunityMemberInternal { #[serde(rename = "d", alias = "date_added")] pub date_added: TimestampMillis, #[serde(rename = "r", alias = "role", default, skip_serializing_if = "is_default")] - pub role: CommunityRole, - #[serde(rename = "s", alias = "suspended", default, skip_serializing_if = "is_default")] - pub suspended: Timestamped, + role: CommunityRole, #[serde(rename = "c", alias = "channels")] - pub channels: HashSet, + pub channels: BTreeSet, #[serde(rename = "cr", alias = "channel_removed", default, skip_serializing_if = "Vec::is_empty")] pub channels_removed: Vec>, #[serde(rename = "ra", alias = "rules_accepted", skip_serializing_if = "Option::is_none")] @@ -436,10 +643,12 @@ pub struct CommunityMemberInternal { display_name: Timestamped>, #[serde(rename = "rb", alias = "referred_by", skip_serializing_if = "Option::is_none")] pub referred_by: Option, - #[serde(rename = "rf", alias = "referrals", default, skip_serializing_if = "HashSet::is_empty")] - pub referrals: HashSet, + #[serde(rename = "rf", alias = "referrals", default, skip_serializing_if = "BTreeSet::is_empty")] + referrals: BTreeSet, #[serde(rename = "l", alias = "lapsed", default, skip_serializing_if = "is_default")] - pub lapsed: Timestamped, + lapsed: Timestamped, + #[serde(rename = "s", alias = "suspended", default, skip_serializing_if = "is_default")] + suspended: Timestamped, } impl CommunityMemberInternal { @@ -484,9 +693,25 @@ impl CommunityMemberInternal { .unwrap() } + pub fn role(&self) -> CommunityRole { + self.role + } + pub fn display_name(&self) -> &Timestamped> { &self.display_name } + + pub fn referrals(&self) -> &BTreeSet { + &self.referrals + } + + pub fn lapsed(&self) -> &Timestamped { + &self.lapsed + } + + pub fn suspended(&self) -> &Timestamped { + &self.suspended + } } impl Member for CommunityMemberInternal { diff --git a/backend/canisters/community/impl/src/model/members/proptests.rs b/backend/canisters/community/impl/src/model/members/proptests.rs new file mode 100644 index 0000000000..b214cc0ae4 --- /dev/null +++ b/backend/canisters/community/impl/src/model/members/proptests.rs @@ -0,0 +1,181 @@ +use crate::model::members::CommunityMembers; +use candid::Principal; +use proptest::collection::vec as pvec; +use proptest::prelude::*; +use proptest::prop_oneof; +use std::collections::BTreeSet; +use test_strategy::proptest; +use types::{CommunityPermissions, CommunityRole, TimestampMillis, UserId, UserType}; + +#[derive(Debug, Clone)] +enum Operation { + Add { + user_id: UserId, + referred_by_index: Option, + }, + ChangeRole { + owner_index: usize, + user_index: usize, + role: CommunityRole, + }, + Remove { + user_index: usize, + }, + SetDisplayName { + user_index: usize, + value: Option, + }, + Block { + user_index: usize, + }, + Unblock { + user_index: usize, + }, + Lapse { + user_index: usize, + }, + Unlapse { + user_index: usize, + }, + UnlapseAll, + SetSuspended { + user_index: usize, + suspended: bool, + }, +} + +fn operation_strategy() -> impl Strategy { + prop_oneof![ + 50 => (any::(), any::(), any::()) + .prop_map(|(user_index, set_referrer, referrer_index)| Operation::Add { user_id: user_id(user_index), referred_by_index: set_referrer.then_some(referrer_index) }), + 20 => (any::(), any::(), any::()) + .prop_map(|(owner_index, user_index, role_index)| Operation::ChangeRole { owner_index, user_index, role: role(role_index) }), + 10 => (any::(), any::()).prop_map(|(user_index, value)| Operation::SetDisplayName { user_index, value: Some(value.to_string()) } ), + 5 => (any::()).prop_map(|user_index| Operation::SetDisplayName { user_index, value: None }), + 10 => any::().prop_map(|user_index| Operation::Remove { user_index}), + 5 => any::().prop_map(|user_index| Operation::Block { user_index}), + 3 => any::().prop_map(|user_index| Operation::Unblock { user_index}), + 5 => any::().prop_map(|user_index| Operation::Lapse { user_index}), + 3 => any::().prop_map(|user_index| Operation::Unlapse { user_index}), + 1 => Just(Operation::UnlapseAll), + 2 => any::().prop_map(|user_index| Operation::SetSuspended { user_index, suspended: true }), + 1 => any::().prop_map(|user_index| Operation::SetSuspended { user_index, suspended: false }), + ] +} + +#[proptest(cases = 10)] +fn comprehensive(#[strategy(pvec(operation_strategy(), 100..5_000))] ops: Vec) { + let mut members = CommunityMembers::new(principal(0), user_id(0), UserType::User, vec![1], 0); + + let mut timestamp = 1000; + for op in ops.into_iter() { + execute_operation(&mut members, op, timestamp); + timestamp += 1000; + } + + members.check_invariants(); +} + +fn execute_operation(members: &mut CommunityMembers, op: Operation, timestamp: TimestampMillis) { + match op { + Operation::Add { + user_id, + referred_by_index, + } => { + let referred_by = referred_by_index.and_then(|i| { + if members.member_ids.is_empty() { + None + } else { + Some(get(&members.member_ids, i)) + } + }); + members.add(user_id, user_id.into(), UserType::User, referred_by, timestamp); + } + Operation::ChangeRole { + owner_index, + user_index, + role, + } => { + let owner = get(&members.owners, owner_index); + let user_id = get(&members.member_ids, user_index); + members.change_role( + owner, + user_id, + role, + &CommunityPermissions::default(), + false, + false, + timestamp, + ); + } + Operation::SetDisplayName { user_index, value } => { + let user_id = get(&members.member_ids, user_index); + members.set_display_name(user_id, value, timestamp); + } + Operation::Remove { user_index } => { + let user_id = get(&members.member_ids, user_index); + if members.owners.len() != 1 || members.owners.first() != Some(&user_id) { + members.remove(&user_id, timestamp); + } + } + Operation::Block { user_index } => { + let user_id = get(&members.member_ids, user_index); + if members.owners.len() != 1 || members.owners.first() != Some(&user_id) { + members.remove(&user_id, timestamp); + members.block(user_id); + } + } + Operation::Unblock { user_index } => { + if !members.blocked.is_empty() { + let user_id = get(&members.blocked, user_index); + members.unblock(&user_id); + } + } + Operation::Lapse { user_index } => { + let user_id = get(&members.member_ids, user_index); + members.update_lapsed(user_id, true, timestamp); + } + Operation::Unlapse { user_index } => { + if !members.lapsed.is_empty() { + let user_id = get(&members.lapsed, user_index); + members.update_lapsed(user_id, false, timestamp); + } + } + Operation::UnlapseAll => { + members.unlapse_all(timestamp); + } + Operation::SetSuspended { user_index, suspended } => { + if suspended { + let user_id = get(&members.member_ids, user_index); + members.set_suspended(user_id, true, timestamp); + } else if !members.suspended.is_empty() { + let user_id = get(&members.suspended, user_index); + members.set_suspended(user_id, false, timestamp); + } + } + }; +} + +fn get(set: &BTreeSet, index: usize) -> UserId { + let index = index % set.len(); + *set.iter().nth(index).unwrap() +} + +fn principal(index: usize) -> Principal { + Principal::from_slice(&index.to_be_bytes()) +} + +fn user_id(index: usize) -> UserId { + principal(index).into() +} + +fn role(value: usize) -> CommunityRole { + let index = value % 3; + + match index { + 0 => CommunityRole::Owner, + 1 => CommunityRole::Admin, + 2 => CommunityRole::Member, + _ => unreachable!(), + } +} diff --git a/backend/canisters/community/impl/src/queries/invite_code.rs b/backend/canisters/community/impl/src/queries/invite_code.rs index 7f14d9f3de..b28450d1a0 100644 --- a/backend/canisters/community/impl/src/queries/invite_code.rs +++ b/backend/canisters/community/impl/src/queries/invite_code.rs @@ -12,7 +12,7 @@ fn invite_code_impl(state: &RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.role.can_invite_users(&state.data.permissions) { + if member.role().can_invite_users(&state.data.permissions) { Success(SuccessResult { code: if state.data.invite_code_enabled { state.data.invite_code } else { None }, }) diff --git a/backend/canisters/community/impl/src/queries/selected_initial.rs b/backend/canisters/community/impl/src/queries/selected_initial.rs index ac55cb4a81..09fde3f456 100644 --- a/backend/canisters/community/impl/src/queries/selected_initial.rs +++ b/backend/canisters/community/impl/src/queries/selected_initial.rs @@ -1,7 +1,7 @@ use crate::{read_state, RuntimeState}; use canister_api_macros::query; use community_canister::selected_initial::{Response::*, *}; -use types::{CommunityMember, CommunityRole}; +use std::collections::HashSet; #[query(candid = true, msgpack = true)] fn selected_initial(args: Args) -> Response { @@ -23,15 +23,25 @@ fn selected_initial_impl(args: Args, state: &RuntimeState) -> Response { let referrals = data .members .get(caller) - .map_or(Vec::new(), |m| m.referrals.iter().copied().collect()); + .map_or(Vec::new(), |m| m.referrals().iter().copied().collect()); + + let mut non_basic_members = HashSet::new(); + non_basic_members.extend(data.members.owners().iter().copied()); + non_basic_members.extend(data.members.admins().iter().copied()); + non_basic_members.extend(data.members.lapsed().iter().copied()); + non_basic_members.extend(data.members.suspended().iter().copied()); + non_basic_members.extend(data.members.members_with_display_names().iter().copied()); + non_basic_members.extend(data.members.members_with_referrals().iter().copied()); let mut members = Vec::new(); let mut basic_members = Vec::new(); - for member in state.data.members.iter().map(CommunityMember::from) { - if matches!(member.role, CommunityRole::Member) && member.display_name.is_none() && !member.lapsed { - basic_members.push(member.user_id); + for user_id in data.members.member_ids().iter() { + if non_basic_members.contains(user_id) { + if let Some(member) = data.members.get_by_user_id(user_id) { + members.push(member.into()); + } } else { - members.push(member); + basic_members.push(*user_id); } } diff --git a/backend/canisters/community/impl/src/queries/summary_updates.rs b/backend/canisters/community/impl/src/queries/summary_updates.rs index 06fb996157..b1bccf2dfd 100644 --- a/backend/canisters/community/impl/src/queries/summary_updates.rs +++ b/backend/canisters/community/impl/src/queries/summary_updates.rs @@ -100,7 +100,7 @@ fn summary_updates_impl( } let membership = member.map(|m| CommunityMembershipUpdates { - role: updates_from_events.role_changed.then_some(m.role), + role: updates_from_events.role_changed.then_some(m.role()), rules_accepted: m .rules_accepted .as_ref() @@ -113,7 +113,7 @@ fn summary_updates_impl( Some(display_name) => OptionUpdate::SetToSome(display_name.clone()), None => OptionUpdate::SetToNone, }), - lapsed: m.lapsed.if_set_after(updates_since).copied(), + lapsed: m.lapsed().if_set_after(updates_since).copied(), }); let last_updated = [community_last_updated] diff --git a/backend/canisters/community/impl/src/updates/accept_p2p_swap.rs b/backend/canisters/community/impl/src/updates/accept_p2p_swap.rs index 13914a784c..cf5a83615f 100644 --- a/backend/canisters/community/impl/src/updates/accept_p2p_swap.rs +++ b/backend/canisters/community/impl/src/updates/accept_p2p_swap.rs @@ -108,9 +108,9 @@ fn reserve_p2p_swap(args: Args, state: &mut RuntimeState) -> Result Result let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); - } else if member.lapsed.value { + } else if member.lapsed().value { return Err(UserLapsed); } diff --git a/backend/canisters/community/impl/src/updates/add_reaction.rs b/backend/canisters/community/impl/src/updates/add_reaction.rs index c50a993b1c..6edb463bc0 100644 --- a/backend/canisters/community/impl/src/updates/add_reaction.rs +++ b/backend/canisters/community/impl/src/updates/add_reaction.rs @@ -22,9 +22,9 @@ fn add_reaction_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } let user_id = member.user_id; diff --git a/backend/canisters/community/impl/src/updates/c2c_delete_community.rs b/backend/canisters/community/impl/src/updates/c2c_delete_community.rs index 9467ee9449..fb97627830 100644 --- a/backend/canisters/community/impl/src/updates/c2c_delete_community.rs +++ b/backend/canisters/community/impl/src/updates/c2c_delete_community.rs @@ -51,11 +51,11 @@ fn prepare(state: &RuntimeState) -> Result { let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { Err(UserSuspended) - } else if member.lapsed.value { + } else if member.lapsed().value { Err(UserLapsed) - } else if !member.role.can_delete_community() { + } else if !member.role().can_delete_community() { Err(NotAuthorized) } else { Ok(PrepareResult { @@ -63,7 +63,7 @@ fn prepare(state: &RuntimeState) -> Result { community_id: state.env.canister_id().into(), deleted_by: member.user_id, communtiy_name: state.data.name.clone(), - members: state.data.members.iter().map(|m| m.user_id).collect(), + members: state.data.members.member_ids().iter().copied().collect(), }) } } else { diff --git a/backend/canisters/community/impl/src/updates/c2c_freeze_community.rs b/backend/canisters/community/impl/src/updates/c2c_freeze_community.rs index 4a96a2c448..fe5fed3320 100644 --- a/backend/canisters/community/impl/src/updates/c2c_freeze_community.rs +++ b/backend/canisters/community/impl/src/updates/c2c_freeze_community.rs @@ -50,7 +50,7 @@ fn c2c_freeze_community_impl(args: Args, state: &mut RuntimeState) -> Response { handle_activity_notification(state); if args.return_members { - SuccessWithMembers(event, state.data.members.iter().map(|p| p.user_id).collect()) + SuccessWithMembers(event, state.data.members.member_ids().iter().copied().collect()) } else { Success(event) } diff --git a/backend/canisters/community/impl/src/updates/c2c_invite_users.rs b/backend/canisters/community/impl/src/updates/c2c_invite_users.rs index 392769d41b..808f48a773 100644 --- a/backend/canisters/community/impl/src/updates/c2c_invite_users.rs +++ b/backend/canisters/community/impl/src/updates/c2c_invite_users.rs @@ -27,12 +27,12 @@ pub(crate) fn invite_users_to_community_impl(args: Args, state: &mut RuntimeStat let now = state.env.now(); if let Some(member) = state.data.members.get_by_user_id(&args.caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } // The original caller must be authorized to invite other users - if !member.role.can_invite_users(&state.data.permissions) { + if !member.role().can_invite_users(&state.data.permissions) { return NotAuthorized; } diff --git a/backend/canisters/community/impl/src/updates/c2c_invite_users_to_channel.rs b/backend/canisters/community/impl/src/updates/c2c_invite_users_to_channel.rs index 8a31317f4e..657e052723 100644 --- a/backend/canisters/community/impl/src/updates/c2c_invite_users_to_channel.rs +++ b/backend/canisters/community/impl/src/updates/c2c_invite_users_to_channel.rs @@ -21,7 +21,7 @@ fn c2c_invite_users_to_channel_impl(args: Args, state: &mut RuntimeState) -> Res } if let Some(member) = state.data.members.get_by_user_id(&args.caller).cloned() { - if member.suspended.value { + if member.suspended().value { return UserSuspended; } diff --git a/backend/canisters/community/impl/src/updates/c2c_join_channel.rs b/backend/canisters/community/impl/src/updates/c2c_join_channel.rs index dbc4d5f7d4..ab4caaab77 100644 --- a/backend/canisters/community/impl/src/updates/c2c_join_channel.rs +++ b/backend/canisters/community/impl/src/updates/c2c_join_channel.rs @@ -14,7 +14,7 @@ use gated_groups::{ CheckVerifiedCredentialGateArgs, GatePayment, }; use group_chat_core::{AddMemberSuccess, AddResult}; -use group_community_common::{ExpiringMember, Member}; +use group_community_common::ExpiringMember; use types::{ AccessGateConfigInternal, ChannelId, MemberJoined, TimestampMillis, UniquePersonProof, VerifiedCredentialGateArgs, }; @@ -29,7 +29,7 @@ async fn c2c_join_channel(args: Args) -> Response { .data .members .get_by_user_id(&args.user_id) - .map_or(false, |member| !member.lapsed()) + .map_or(false, |member| !member.lapsed().value) }) { check_gate_then_join_channel(&args).await } else { @@ -160,13 +160,13 @@ fn is_permitted_to_join( } if let Some(member) = state.data.members.get(user_principal) { - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); } if let Some(channel) = state.data.channels.get(&channel_id) { if let Some(channel_member) = channel.chat.members.get(&member.user_id) { - if !member.lapsed() && !channel_member.lapsed().value { + if !member.lapsed().value && !channel_member.lapsed().value { return Err(AlreadyInChannel(Box::new( channel .summary( diff --git a/backend/canisters/community/impl/src/updates/c2c_join_community.rs b/backend/canisters/community/impl/src/updates/c2c_join_community.rs index 82382981f2..028d697c70 100644 --- a/backend/canisters/community/impl/src/updates/c2c_join_community.rs +++ b/backend/canisters/community/impl/src/updates/c2c_join_community.rs @@ -10,7 +10,7 @@ use community_canister::c2c_join_community::{Response::*, *}; use gated_groups::{ check_if_passes_gate, CheckGateArgs, CheckIfPassesGateResult, CheckVerifiedCredentialGateArgs, GatePayment, }; -use group_community_common::{ExpiringMember, Member}; +use group_community_common::ExpiringMember; use types::{AccessGate, ChannelId, MemberJoined, UsersUnblocked}; #[update(guard = "caller_is_user_index_or_local_user_index", msgpack = true)] @@ -62,7 +62,7 @@ fn is_permitted_to_join(args: &Args, state: &RuntimeState) -> Result {} AddResult::AlreadyInCommunity => { let member = state.data.members.get_by_user_id(&args.user_id).unwrap(); - if !member.lapsed() { + if !member.lapsed().value { let summary = state.summary(Some(member), None); return Err(AlreadyInCommunity(Box::new(summary))); } diff --git a/backend/canisters/community/impl/src/updates/c2c_leave_community.rs b/backend/canisters/community/impl/src/updates/c2c_leave_community.rs index bd1e895007..3994f7d077 100644 --- a/backend/canisters/community/impl/src/updates/c2c_leave_community.rs +++ b/backend/canisters/community/impl/src/updates/c2c_leave_community.rs @@ -29,11 +29,11 @@ fn c2c_leave_community_impl(state: &mut RuntimeState) -> Response { None => return UserNotInCommunity, }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; } - if (member.role.is_owner() && state.data.members.owner_count() == 1) + if (member.role().is_owner() && state.data.members.owners().len() <= 1) || !state.data.channels.can_leave_all_channels(member.user_id) { return LastOwnerCannotLeave; diff --git a/backend/canisters/community/impl/src/updates/c2c_set_user_suspended.rs b/backend/canisters/community/impl/src/updates/c2c_set_user_suspended.rs index 0c4df3ab07..f82418b8b2 100644 --- a/backend/canisters/community/impl/src/updates/c2c_set_user_suspended.rs +++ b/backend/canisters/community/impl/src/updates/c2c_set_user_suspended.rs @@ -3,7 +3,6 @@ use crate::{mutate_state, run_regular_jobs, RuntimeState}; use canister_api_macros::update; use canister_tracing_macros::trace; use community_canister::c2c_set_user_suspended::{Response::*, *}; -use types::Timestamped; #[update(guard = "caller_is_user_index", msgpack = true)] #[trace] @@ -14,13 +13,12 @@ fn c2c_set_user_suspended(args: Args) -> Response { } fn c2c_set_user_suspended_impl(args: Args, state: &mut RuntimeState) -> Response { - if let Some(member) = state.data.members.get_by_user_id_mut(&args.user_id) { - if member.suspended.value != args.suspended { - let now = state.env.now(); - member.suspended = Timestamped::new(args.suspended, now); - - for channel_id in member.channels.iter() { - if let Some(channel) = state.data.channels.get_mut(channel_id) { + let now = state.env.now(); + if state.data.members.set_suspended(args.user_id, args.suspended, now).is_some() { + if let Some(member) = state.data.members.get_by_user_id(&args.user_id) { + let channels = member.channels.clone(); + for channel_id in channels { + if let Some(channel) = state.data.channels.get_mut(&channel_id) { channel.chat.members.set_suspended(member.user_id, args.suspended, now); } } diff --git a/backend/canisters/community/impl/src/updates/c2c_tip_message.rs b/backend/canisters/community/impl/src/updates/c2c_tip_message.rs index d3e3fd72b5..73a911f72b 100644 --- a/backend/canisters/community/impl/src/updates/c2c_tip_message.rs +++ b/backend/canisters/community/impl/src/updates/c2c_tip_message.rs @@ -24,9 +24,9 @@ fn c2c_tip_message_impl(args: Args, state: &mut RuntimeState) -> Response { let user_id = state.env.caller().into(); if let Some(member) = state.data.members.get_by_user_id(&user_id) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } diff --git a/backend/canisters/community/impl/src/updates/cancel_invites.rs b/backend/canisters/community/impl/src/updates/cancel_invites.rs index 44b6bb5a98..5cb32492e8 100644 --- a/backend/canisters/community/impl/src/updates/cancel_invites.rs +++ b/backend/canisters/community/impl/src/updates/cancel_invites.rs @@ -19,9 +19,9 @@ fn cancel_invites_impl(args: Args, state: &mut RuntimeState) -> Response { return NotAuthorized; }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } @@ -40,7 +40,7 @@ fn cancel_invites_impl(args: Args, state: &mut RuntimeState) -> Response { CancelInvitesResult::UserLapsed => return UserLapsed, } } else { - if !member.role.can_invite_users(&state.data.permissions) { + if !member.role().can_invite_users(&state.data.permissions) { return NotAuthorized; } diff --git a/backend/canisters/community/impl/src/updates/change_channel_role.rs b/backend/canisters/community/impl/src/updates/change_channel_role.rs index 7e72d6028c..456b79b5b8 100644 --- a/backend/canisters/community/impl/src/updates/change_channel_role.rs +++ b/backend/canisters/community/impl/src/updates/change_channel_role.rs @@ -21,9 +21,9 @@ fn change_channel_role_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } diff --git a/backend/canisters/community/impl/src/updates/change_role.rs b/backend/canisters/community/impl/src/updates/change_role.rs index 92ab907ede..5b2556d989 100644 --- a/backend/canisters/community/impl/src/updates/change_role.rs +++ b/backend/canisters/community/impl/src/updates/change_role.rs @@ -71,20 +71,20 @@ struct PrepareResult { fn prepare(user_id: UserId, state: &RuntimeState) -> Result { let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { Err(UserSuspended) - } else if member.lapsed.value { + } else if member.lapsed().value { Err(UserLapsed) } else { Ok(PrepareResult { caller_id: member.user_id, user_index_canister_id: state.data.user_index_canister_id, - is_caller_owner: member.role.is_owner(), + is_caller_owner: member.role().is_owner(), is_user_owner: state .data .members .get_by_user_id(&user_id) - .map_or(false, |p| p.role.is_owner()), + .map_or(false, |p| p.role().is_owner()), }) } } else { @@ -111,6 +111,7 @@ fn change_role_impl( &state.data.permissions, is_caller_platform_moderator, is_user_platform_moderator, + now, ) { ChangeRoleResult::Success(r) => { // Owners can't "lapse" so either add or remove user from expiry list if they lose or gain owner status diff --git a/backend/canisters/community/impl/src/updates/claim_prize.rs b/backend/canisters/community/impl/src/updates/claim_prize.rs index 43c264ed34..bb5733866f 100644 --- a/backend/canisters/community/impl/src/updates/claim_prize.rs +++ b/backend/canisters/community/impl/src/updates/claim_prize.rs @@ -67,9 +67,9 @@ fn prepare(args: &Args, state: &mut RuntimeState) -> Result return Err(Box::new(UserNotInCommunity)), }; - if member.suspended.value { + if member.suspended().value { return Err(Box::new(UserSuspended)); - } else if member.lapsed.value { + } else if member.lapsed().value { return Err(Box::new(UserLapsed)); } diff --git a/backend/canisters/community/impl/src/updates/create_channel.rs b/backend/canisters/community/impl/src/updates/create_channel.rs index 55c49c0ab0..5ae8d2edfd 100644 --- a/backend/canisters/community/impl/src/updates/create_channel.rs +++ b/backend/canisters/community/impl/src/updates/create_channel.rs @@ -79,9 +79,9 @@ fn create_channel_impl(args: Args, is_proposals_channel: bool, state: &mut Runti let caller = state.env.caller(); let channel_id = state.generate_channel_id(); if let Some(member) = state.data.members.get_mut(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } @@ -89,9 +89,9 @@ fn create_channel_impl(args: Args, is_proposals_channel: bool, state: &mut Runti if !is_proposals_channel { let is_authorized = if args.is_public { - member.role.can_create_public_channel(&state.data.permissions) + member.role().can_create_public_channel(&state.data.permissions) } else { - member.role.can_create_private_channel(&state.data.permissions) + member.role().can_create_private_channel(&state.data.permissions) }; if !is_authorized { diff --git a/backend/canisters/community/impl/src/updates/create_user_group.rs b/backend/canisters/community/impl/src/updates/create_user_group.rs index e5eeb22d0f..f3c2e73e19 100644 --- a/backend/canisters/community/impl/src/updates/create_user_group.rs +++ b/backend/canisters/community/impl/src/updates/create_user_group.rs @@ -20,13 +20,13 @@ fn create_user_group_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.members.get_mut(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } - if !member.role.can_manage_user_groups(&state.data.permissions) { + if !member.role().can_manage_user_groups(&state.data.permissions) { NotAuthorized } else if let Err(error) = validate_user_group_name(&args.name) { match error { diff --git a/backend/canisters/community/impl/src/updates/delete_channel.rs b/backend/canisters/community/impl/src/updates/delete_channel.rs index 03d92bac56..bedb8d17b7 100644 --- a/backend/canisters/community/impl/src/updates/delete_channel.rs +++ b/backend/canisters/community/impl/src/updates/delete_channel.rs @@ -27,9 +27,9 @@ fn delete_channel_impl(channel_id: ChannelId, state: &mut RuntimeState) -> Respo return UserNotInCommunity; }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } diff --git a/backend/canisters/community/impl/src/updates/delete_messages.rs b/backend/canisters/community/impl/src/updates/delete_messages.rs index dcfa8ab7e2..ec8585c0da 100644 --- a/backend/canisters/community/impl/src/updates/delete_messages.rs +++ b/backend/canisters/community/impl/src/updates/delete_messages.rs @@ -46,9 +46,9 @@ struct PrepareResult { fn prepare(state: &RuntimeState) -> Result { let caller = state.env.caller(); let user_id = if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); - } else if member.lapsed.value { + } else if member.lapsed().value { return Err(UserLapsed); } else { member.user_id diff --git a/backend/canisters/community/impl/src/updates/delete_user_groups.rs b/backend/canisters/community/impl/src/updates/delete_user_groups.rs index 2478192d9f..b2ba90511f 100644 --- a/backend/canisters/community/impl/src/updates/delete_user_groups.rs +++ b/backend/canisters/community/impl/src/updates/delete_user_groups.rs @@ -19,9 +19,9 @@ fn delete_user_groups_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); match state.data.members.get(caller) { - Some(m) if m.suspended.value => UserSuspended, - Some(m) if m.lapsed.value => UserLapsed, - Some(m) if m.role.can_manage_user_groups(&state.data.permissions) => { + Some(m) if m.suspended().value => UserSuspended, + Some(m) if m.lapsed().value => UserLapsed, + Some(m) if m.role().can_manage_user_groups(&state.data.permissions) => { let now = state.env.now(); let mut updated = false; diff --git a/backend/canisters/community/impl/src/updates/disable_invite_code.rs b/backend/canisters/community/impl/src/updates/disable_invite_code.rs index f00dbfd9a7..7d1b1a434f 100644 --- a/backend/canisters/community/impl/src/updates/disable_invite_code.rs +++ b/backend/canisters/community/impl/src/updates/disable_invite_code.rs @@ -22,13 +22,13 @@ fn disable_invite_code_impl(state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } - if member.role.can_invite_users(&state.data.permissions) { + if member.role().can_invite_users(&state.data.permissions) { state.data.invite_code_enabled = false; let now = state.env.now(); diff --git a/backend/canisters/community/impl/src/updates/edit_message.rs b/backend/canisters/community/impl/src/updates/edit_message.rs index 891c75c8c2..4efc64a067 100644 --- a/backend/canisters/community/impl/src/updates/edit_message.rs +++ b/backend/canisters/community/impl/src/updates/edit_message.rs @@ -23,9 +23,9 @@ fn edit_message_impl(args: Args, state: &mut RuntimeState) -> Response { return UserNotInCommunity; }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } diff --git a/backend/canisters/community/impl/src/updates/enable_invite_code.rs b/backend/canisters/community/impl/src/updates/enable_invite_code.rs index f718dbebe3..336e79e2f0 100644 --- a/backend/canisters/community/impl/src/updates/enable_invite_code.rs +++ b/backend/canisters/community/impl/src/updates/enable_invite_code.rs @@ -93,13 +93,13 @@ fn prepare(state: &RuntimeState) -> Result { let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); - } else if member.lapsed.value { + } else if member.lapsed().value { return Err(UserLapsed); } - if member.role.can_invite_users(&state.data.permissions) { + if member.role().can_invite_users(&state.data.permissions) { return Ok(PrepareResult { caller, code: state.data.invite_code, diff --git a/backend/canisters/community/impl/src/updates/follow_thread.rs b/backend/canisters/community/impl/src/updates/follow_thread.rs index 51d96dcf33..6e8765d596 100644 --- a/backend/canisters/community/impl/src/updates/follow_thread.rs +++ b/backend/canisters/community/impl/src/updates/follow_thread.rs @@ -22,8 +22,8 @@ fn follow_thread_impl(args: Args, state: &mut RuntimeState) -> Response { let now = state.env.now(); let (user_id, is_bot) = match state.data.members.get(caller) { - Some(member) if member.suspended.value => return UserSuspended, - Some(member) if member.lapsed.value => return UserLapsed, + Some(member) if member.suspended().value => return UserSuspended, + Some(member) if member.lapsed().value => return UserLapsed, Some(member) => (member.user_id, member.user_type.is_bot()), None => return UserNotInCommunity, }; diff --git a/backend/canisters/community/impl/src/updates/import_group.rs b/backend/canisters/community/impl/src/updates/import_group.rs index d82b1e7bfc..1349306c18 100644 --- a/backend/canisters/community/impl/src/updates/import_group.rs +++ b/backend/canisters/community/impl/src/updates/import_group.rs @@ -71,7 +71,7 @@ struct PrepareResult { fn prepare(args: &Args, state: &RuntimeState) -> Result { let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.role.is_owner() { + if member.role().is_owner() { if !state.data.groups_being_imported.contains(&args.group_id) { Ok(PrepareResult { group_index_canister_id: state.data.group_index_canister_id, diff --git a/backend/canisters/community/impl/src/updates/leave_channel.rs b/backend/canisters/community/impl/src/updates/leave_channel.rs index 5d38aaf62b..cfcc95ac54 100644 --- a/backend/canisters/community/impl/src/updates/leave_channel.rs +++ b/backend/canisters/community/impl/src/updates/leave_channel.rs @@ -22,7 +22,7 @@ fn leave_channel_impl(args: Args, state: &mut RuntimeState) -> Response { return UserNotInCommunity; }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; } diff --git a/backend/canisters/community/impl/src/updates/pin_message.rs b/backend/canisters/community/impl/src/updates/pin_message.rs index d5f409419c..bea7d6d580 100644 --- a/backend/canisters/community/impl/src/updates/pin_message.rs +++ b/backend/canisters/community/impl/src/updates/pin_message.rs @@ -27,9 +27,9 @@ fn pin_message_impl(args: Args, pin: bool, state: &mut RuntimeState) -> Response let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } diff --git a/backend/canisters/community/impl/src/updates/register_poll_vote.rs b/backend/canisters/community/impl/src/updates/register_poll_vote.rs index 2f8181f020..144f783ce4 100644 --- a/backend/canisters/community/impl/src/updates/register_poll_vote.rs +++ b/backend/canisters/community/impl/src/updates/register_poll_vote.rs @@ -27,9 +27,9 @@ fn register_poll_vote_impl(args: Args, state: &mut RuntimeState) -> Response { None => return UserNotInCommunity, }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } diff --git a/backend/canisters/community/impl/src/updates/register_proposal_vote.rs b/backend/canisters/community/impl/src/updates/register_proposal_vote.rs index 0f7463a4d3..d56e9f5c04 100644 --- a/backend/canisters/community/impl/src/updates/register_proposal_vote.rs +++ b/backend/canisters/community/impl/src/updates/register_proposal_vote.rs @@ -61,9 +61,9 @@ fn prepare(args: &Args, state: &RuntimeState) -> Result None => return Err(UserNotInCommunity), }; - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); - } else if member.lapsed.value { + } else if member.lapsed().value { return Err(UserLapsed); } diff --git a/backend/canisters/community/impl/src/updates/register_proposal_vote_v2.rs b/backend/canisters/community/impl/src/updates/register_proposal_vote_v2.rs index be984821ed..420072ec65 100644 --- a/backend/canisters/community/impl/src/updates/register_proposal_vote_v2.rs +++ b/backend/canisters/community/impl/src/updates/register_proposal_vote_v2.rs @@ -25,7 +25,7 @@ fn register_proposal_vote_impl(args: Args, state: &mut RuntimeState) -> Response None => return UserNotInCommunity, }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; } diff --git a/backend/canisters/community/impl/src/updates/remove_member.rs b/backend/canisters/community/impl/src/updates/remove_member.rs index e132165726..b200fa0ad4 100644 --- a/backend/canisters/community/impl/src/updates/remove_member.rs +++ b/backend/canisters/community/impl/src/updates/remove_member.rs @@ -71,15 +71,15 @@ fn prepare(user_id: UserId, block: bool, state: &RuntimeState) -> Result member_to_remove.role, + Some(member_to_remove) => member_to_remove.role(), None if block => { if state.data.members.is_blocked(&user_id) { return Err(Success); @@ -91,7 +91,7 @@ fn prepare(user_id: UserId, block: bool, state: &RuntimeState) -> Result Resp let caller = state.env.caller(); let user_id = match state.data.members.get(caller) { - Some(m) if m.suspended.value => return UserSuspended, - Some(m) if m.lapsed.value => return UserLapsed, + Some(m) if m.suspended().value => return UserSuspended, + Some(m) if m.lapsed().value => return UserLapsed, Some(m) => m.user_id, _ => return UserNotInCommunity, }; diff --git a/backend/canisters/community/impl/src/updates/remove_reaction.rs b/backend/canisters/community/impl/src/updates/remove_reaction.rs index 7c6a923c87..ff8478aad6 100644 --- a/backend/canisters/community/impl/src/updates/remove_reaction.rs +++ b/backend/canisters/community/impl/src/updates/remove_reaction.rs @@ -23,9 +23,9 @@ fn remove_reaction_impl(args: Args, state: &mut RuntimeState) -> Response { return UserNotInCommunity; }; - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } diff --git a/backend/canisters/community/impl/src/updates/report_message.rs b/backend/canisters/community/impl/src/updates/report_message.rs index 9b4f9331a6..4724f11799 100644 --- a/backend/canisters/community/impl/src/updates/report_message.rs +++ b/backend/canisters/community/impl/src/updates/report_message.rs @@ -44,9 +44,9 @@ fn build_c2c_args(args: &Args, state: &RuntimeState) -> Result<(c2c_report_messa return Err(UserNotInCommunity); }; - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); - } else if member.lapsed.value { + } else if member.lapsed().value { return Err(UserLapsed); } diff --git a/backend/canisters/community/impl/src/updates/send_message.rs b/backend/canisters/community/impl/src/updates/send_message.rs index 2d6d8d8d3b..096cfea9ed 100644 --- a/backend/canisters/community/impl/src/updates/send_message.rs +++ b/backend/canisters/community/impl/src/updates/send_message.rs @@ -157,9 +157,9 @@ fn validate_caller( let caller = caller_override.unwrap_or_else(|| state.env.caller()); if let Some(member) = state.data.members.get_mut(caller) { - if member.suspended.value { + if member.suspended().value { Err(UserSuspended) - } else if member.lapsed.value { + } else if member.lapsed().value { Err(UserLapsed) } else { if let Some(version) = community_rules_accepted { diff --git a/backend/canisters/community/impl/src/updates/set_member_display_name.rs b/backend/canisters/community/impl/src/updates/set_member_display_name.rs index 0b58df7bbd..585cfad73b 100644 --- a/backend/canisters/community/impl/src/updates/set_member_display_name.rs +++ b/backend/canisters/community/impl/src/updates/set_member_display_name.rs @@ -22,8 +22,8 @@ fn set_member_display_name_impl(args: Args, state: &mut RuntimeState) -> Respons let now = state.env.now(); let user_id = match state.data.members.get(caller) { - Some(member) if member.suspended.value => return UserSuspended, - Some(member) if member.lapsed.value => return UserLapsed, + Some(member) if member.suspended().value => return UserSuspended, + Some(member) if member.lapsed().value => return UserLapsed, Some(member) => { if let Some(display_name) = args.display_name.as_ref() { match validate_display_name(display_name) { diff --git a/backend/canisters/community/impl/src/updates/set_video_call_presence.rs b/backend/canisters/community/impl/src/updates/set_video_call_presence.rs index 5377918af7..9e2581dcd0 100644 --- a/backend/canisters/community/impl/src/updates/set_video_call_presence.rs +++ b/backend/canisters/community/impl/src/updates/set_video_call_presence.rs @@ -22,8 +22,8 @@ pub(crate) fn set_video_call_presence_impl(args: Args, state: &mut RuntimeState) let caller = state.env.caller(); let (user_id, is_bot) = match state.data.members.get(caller) { - Some(member) if member.suspended.value => return UserSuspended, - Some(member) if member.lapsed.value => return UserLapsed, + Some(member) if member.suspended().value => return UserSuspended, + Some(member) if member.lapsed().value => return UserLapsed, Some(member) => (member.user_id, member.user_type.is_bot()), None => return UserNotInCommunity, }; diff --git a/backend/canisters/community/impl/src/updates/start_video_call.rs b/backend/canisters/community/impl/src/updates/start_video_call.rs index 0e15de524c..a933eca73b 100644 --- a/backend/canisters/community/impl/src/updates/start_video_call.rs +++ b/backend/canisters/community/impl/src/updates/start_video_call.rs @@ -77,7 +77,7 @@ fn start_video_call_impl(args: Args, state: &mut RuntimeState) -> Response { let users_to_notify: Vec = result .users_to_notify .into_iter() - .filter(|u| state.data.members.get_by_user_id(u).map_or(false, |m| !m.suspended.value)) + .filter(|u| state.data.members.get_by_user_id(u).map_or(false, |m| !m.suspended().value)) .collect(); let notification = Notification::ChannelMessage(ChannelMessageNotification { diff --git a/backend/canisters/community/impl/src/updates/toggle_mute_notifications.rs b/backend/canisters/community/impl/src/updates/toggle_mute_notifications.rs index 7c51e0f1fd..177ebd96f1 100644 --- a/backend/canisters/community/impl/src/updates/toggle_mute_notifications.rs +++ b/backend/canisters/community/impl/src/updates/toggle_mute_notifications.rs @@ -20,8 +20,8 @@ fn toggle_mute_notifications_impl(args: Args, state: &mut RuntimeState) -> Respo let now = state.env.now(); match state.data.members.get_mut(caller) { - Some(member) if member.suspended.value => UserSuspended, - Some(member) if member.lapsed.value => UserLapsed, + Some(member) if member.suspended().value => UserSuspended, + Some(member) if member.lapsed().value => UserLapsed, Some(member) => { let updated = if let Some(channel_id) = args.channel_id { if let Some(channel) = state.data.channels.get_mut(&channel_id) { diff --git a/backend/canisters/community/impl/src/updates/unblock_user.rs b/backend/canisters/community/impl/src/updates/unblock_user.rs index 9dabcd44c1..aebaa3fa6f 100644 --- a/backend/canisters/community/impl/src/updates/unblock_user.rs +++ b/backend/canisters/community/impl/src/updates/unblock_user.rs @@ -25,16 +25,16 @@ fn unblock_user_impl(args: Args, state: &mut RuntimeState) -> Response { if !state.data.is_public { CommunityNotPublic } else if let Some(caller_member) = state.data.members.get(caller) { - if caller_member.suspended.value { + if caller_member.suspended().value { return UserSuspended; - } else if caller_member.lapsed.value { + } else if caller_member.lapsed().value { return UserLapsed; } let unblocked_by = caller_member.user_id; if unblocked_by == args.user_id { CannotUnblockSelf - } else if caller_member.role.can_unblock_users(&state.data.permissions) { + } else if caller_member.role().can_unblock_users(&state.data.permissions) { let now = state.env.now(); state.data.members.unblock(&args.user_id); diff --git a/backend/canisters/community/impl/src/updates/undelete_messages.rs b/backend/canisters/community/impl/src/updates/undelete_messages.rs index 6818a2df37..4cedab02c1 100644 --- a/backend/canisters/community/impl/src/updates/undelete_messages.rs +++ b/backend/canisters/community/impl/src/updates/undelete_messages.rs @@ -20,9 +20,9 @@ fn undelete_messages_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } diff --git a/backend/canisters/community/impl/src/updates/unfollow_thread.rs b/backend/canisters/community/impl/src/updates/unfollow_thread.rs index b72365d76f..92abf6fb9e 100644 --- a/backend/canisters/community/impl/src/updates/unfollow_thread.rs +++ b/backend/canisters/community/impl/src/updates/unfollow_thread.rs @@ -21,8 +21,8 @@ fn unfollow_thread_impl(args: Args, state: &mut RuntimeState) -> Response { let now = state.env.now(); let user_id = match state.data.members.get(caller) { - Some(member) if member.suspended.value => return UserSuspended, - Some(member) if member.lapsed.value => return UserLapsed, + Some(member) if member.suspended().value => return UserSuspended, + Some(member) if member.lapsed().value => return UserLapsed, Some(member) => member.user_id, None => return UserNotInCommunity, }; diff --git a/backend/canisters/community/impl/src/updates/update_community.rs b/backend/canisters/community/impl/src/updates/update_community.rs index 168f7f0698..2b517d5e3b 100644 --- a/backend/canisters/community/impl/src/updates/update_community.rs +++ b/backend/canisters/community/impl/src/updates/update_community.rs @@ -170,16 +170,16 @@ fn prepare(args: &Args, state: &RuntimeState) -> Result } if let Some(member) = state.data.members.get(caller) { - if member.suspended.value { + if member.suspended().value { return Err(UserSuspended); - } else if member.lapsed.value { + } else if member.lapsed().value { return Err(UserLapsed); } let permissions = &state.data.permissions; - if !member.role.can_update_details(permissions) - || (args.permissions.is_some() && !member.role.can_change_permissions()) - || (args.public.is_some() && !member.role.can_change_community_visibility()) + if !member.role().can_update_details(permissions) + || (args.permissions.is_some() && !member.role().can_change_permissions()) + || (args.public.is_some() && !member.role().can_change_community_visibility()) { Err(NotAuthorized) } else { diff --git a/backend/canisters/community/impl/src/updates/update_user_group.rs b/backend/canisters/community/impl/src/updates/update_user_group.rs index 296fce4fd8..0d6e2f734d 100644 --- a/backend/canisters/community/impl/src/updates/update_user_group.rs +++ b/backend/canisters/community/impl/src/updates/update_user_group.rs @@ -20,13 +20,13 @@ fn update_user_group_impl(args: Args, state: &mut RuntimeState) -> Response { let caller = state.env.caller(); if let Some(member) = state.data.members.get_mut(caller) { - if member.suspended.value { + if member.suspended().value { return UserSuspended; - } else if member.lapsed.value { + } else if member.lapsed().value { return UserLapsed; } - if !member.role.can_manage_user_groups(&state.data.permissions) { + if !member.role().can_manage_user_groups(&state.data.permissions) { NotAuthorized } else if let Err(error) = args.name.as_ref().map_or(Ok(()), |n| validate_user_group_name(n)) { match error { diff --git a/backend/libraries/group_chat_core/src/members.rs b/backend/libraries/group_chat_core/src/members.rs index 02b06521fc..f7c1161da5 100644 --- a/backend/libraries/group_chat_core/src/members.rs +++ b/backend/libraries/group_chat_core/src/members.rs @@ -416,12 +416,12 @@ impl GroupMembers { } } - pub fn update_lapsed(&mut self, user_id: UserId, lapse: bool, now: TimestampMillis) { + pub fn update_lapsed(&mut self, user_id: UserId, lapsed: bool, now: TimestampMillis) { let Some(member) = self.get_mut(&user_id) else { return; }; - let updated = if lapse { + let updated = if lapsed { // Owners can't lapse !member.is_owner() && member.set_lapsed(true, now) } else { @@ -429,7 +429,7 @@ impl GroupMembers { }; if updated { - if lapse { + if lapsed { self.lapsed.insert(user_id); } else { self.lapsed.remove(&user_id); @@ -438,7 +438,7 @@ impl GroupMembers { self.updates.insert(( now, user_id, - if lapse { MemberUpdate::Lapsed } else { MemberUpdate::Unlapsed }, + if lapsed { MemberUpdate::Lapsed } else { MemberUpdate::Unlapsed }, )); } } From 500044972cc91562ffa72380e58b85dddb5a96d6 Mon Sep 17 00:00:00 2001 From: Matt Grogan Date: Mon, 25 Nov 2024 14:27:37 +0200 Subject: [PATCH 10/32] Update FAQ for buying CHAT or ICP (#6886) --- .../components/landingpages/FAQPage.svelte | 19 ++++++++++--------- frontend/app/src/i18n/ar.json | 2 ++ frontend/app/src/i18n/cn.json | 4 +++- frontend/app/src/i18n/de.json | 4 +++- frontend/app/src/i18n/en.json | 4 +++- frontend/app/src/i18n/es.json | 4 +++- frontend/app/src/i18n/fa.json | 4 +++- frontend/app/src/i18n/fr.json | 4 +++- frontend/app/src/i18n/hi.json | 4 +++- frontend/app/src/i18n/it.json | 6 ++++-- frontend/app/src/i18n/iw.json | 4 +++- frontend/app/src/i18n/jp.json | 4 +++- frontend/app/src/i18n/pl.json | 4 +++- frontend/app/src/i18n/ru.json | 4 +++- frontend/app/src/i18n/uk.json | 4 +++- frontend/app/src/i18n/vi.json | 4 +++- frontend/openchat-shared/src/domain/faq.ts | 1 + 17 files changed, 56 insertions(+), 24 deletions(-) diff --git a/frontend/app/src/components/landingpages/FAQPage.svelte b/frontend/app/src/components/landingpages/FAQPage.svelte index c02664c26e..bb657c2e68 100644 --- a/frontend/app/src/components/landingpages/FAQPage.svelte +++ b/frontend/app/src/components/landingpages/FAQPage.svelte @@ -57,6 +57,16 @@
diff --git a/frontend/app/src/components/bots/CommandSelector.svelte b/frontend/app/src/components/bots/CommandSelector.svelte new file mode 100644 index 0000000000..2a7260048d --- /dev/null +++ b/frontend/app/src/components/bots/CommandSelector.svelte @@ -0,0 +1,269 @@ + + +
+

+ +

+ + + +
+
+ {#each commands as command, i} + + +
selectCommand(command)}> + {#if command.kind === "external_bot"} + {command.botName} + {:else} + + {/if} +
+
+
+ /{command.name} +
+ {#each command?.params ?? [] as param} + +
+ +
+
+ + + +
+
+ {/each} +
+ {#if command.description} +
+ +
+ {/if} +
+
+ +
+
+ {/each} +
+ +{#if $error !== undefined} +
+ + {$error} + +
+{/if} + + diff --git a/frontend/app/src/components/bots/botState.ts b/frontend/app/src/components/bots/botState.ts new file mode 100644 index 0000000000..6872e49c86 --- /dev/null +++ b/frontend/app/src/components/bots/botState.ts @@ -0,0 +1,191 @@ +import { + createParamInstancesFromSchema, + paramInstanceIsValid, + type Bot, + type BotCommandInstance, + type FlattenedCommand, + type MessageContext, + type MessageFormatter, + type SlashCommandParam, + type SlashCommandParamInstance, +} from "openchat-client"; +import { derived, get, writable } from "svelte/store"; +import { _ } from "svelte-i18n"; + +function filterCommand( + formatter: MessageFormatter, + c: FlattenedCommand, + selectedCommand: FlattenedCommand | undefined, + parsedPrefix: string, + prefixParts: string[], +): boolean { + if (c.devmode && process.env.NODE_ENV === "production") return false; + + if (selectedCommand !== undefined) { + return commandsMatch(selectedCommand, c); + } + + if (prefixParts.length > 1) { + return c.name.toLocaleLowerCase() === parsedPrefix.toLocaleLowerCase(); + } else { + const desc = c.description ? formatter(c.description).toLocaleLowerCase() : undefined; + return ( + c.name.toLocaleLowerCase().includes(parsedPrefix.toLocaleLowerCase()) || + (desc?.includes(parsedPrefix.toLocaleLowerCase()) ?? false) + ); + } +} + +function parseCommand(input: string): string[] { + const regex = /"([^"]+)"|(\S+)/g; + const result: string[] = []; + let match; + while ((match = regex.exec(input)) !== null) { + if (match[1]) { + result.push(match[1]); + } else if (match[2]) { + result.push(match[2]); + } + } + return result; +} + +export const error = writable(undefined); +export const prefix = writable(""); +export const selectedCommand = writable(undefined); +export const focusedCommandIndex = writable(0); +export const selectedCommandParamInstances = writable([]); +export const showingBuilder = writable(false); +export const bots = writable([]); + +export const prefixParts = derived(prefix, (prefix) => parseCommand(prefix)); +export const maybeParams = derived(prefixParts, (prefixParts) => prefixParts.slice(1) ?? []); +export const parsedPrefix = derived( + prefixParts, + (prefixParts) => prefixParts[0]?.slice(1)?.toLocaleLowerCase() ?? "", +); +export const commands = derived( + [_, bots, selectedCommand, parsedPrefix, prefixParts], + ([$_, bots, selectedCommand, parsedPrefix, prefixParts]) => { + return bots.flatMap((b) => { + switch (b.kind) { + case "external_bot": + return b.commands + .map((c) => { + return { + ...c, + kind: b.kind, + botName: b.name, + botIcon: b.icon, + botId: b.id, + botEndpoint: b.endpoint, + botDescription: b.description, + }; + }) + .filter((c) => + filterCommand($_, c, selectedCommand, parsedPrefix, prefixParts), + ) as FlattenedCommand[]; + case "internal_bot": + return b.commands + .map((c) => { + return { + ...c, + kind: b.kind, + botName: b.name, + botDescription: b.description, + }; + }) + .filter((c) => + filterCommand($_, c, selectedCommand, parsedPrefix, prefixParts), + ) as FlattenedCommand[]; + } + }); + }, +); +export const instanceValid = derived( + [selectedCommand, selectedCommandParamInstances], + ([selectedCommand, selectedCommandParamInstances]) => { + if (selectedCommand === undefined) return false; + if (selectedCommandParamInstances.length !== selectedCommand.params.length) { + return false; + } + const pairs: [SlashCommandParam, SlashCommandParamInstance][] = selectedCommand.params.map( + (p, i) => [p, selectedCommandParamInstances[i]], + ); + return pairs.every(([p, i]) => paramInstanceIsValid(p, i)); + }, +); + +function commandsMatch(a: FlattenedCommand | undefined, b: FlattenedCommand | undefined): boolean { + if (a === undefined || b === undefined) return false; + return a.botName === b.botName && a.name === b.name; +} + +export function focusPreviousCommand() { + focusedCommandIndex.update((idx) => { + return (idx + 1) % get(commands).length; + }); +} +export function focusNextCommand() { + focusedCommandIndex.update((idx) => { + const cmds = get(commands); + return (idx - 1 + cmds.length) % cmds.length; + }); +} + +export function createBotInstance( + command: FlattenedCommand, + context: MessageContext, +): BotCommandInstance { + switch (command.kind) { + case "external_bot": + return { + kind: "external_bot", + id: command.botId, + endpoint: command.botEndpoint, + command: { + name: command.name, + messageContext: context, + params: get(selectedCommandParamInstances), + }, + }; + case "internal_bot": + return { + kind: "internal_bot", + command: { + name: command.name, + messageContext: context, + params: get(selectedCommandParamInstances), + }, + }; + } +} + +export function setSelectedCommand(cmd?: FlattenedCommand) { + cmd = cmd ?? get(commands)[get(focusedCommandIndex)]; + + // make sure that we don't set the same command twice + if (!commandsMatch(get(selectedCommand), cmd)) { + selectedCommand.set(cmd); + if (cmd !== undefined) { + focusedCommandIndex.set(0); + if (cmd.params.length > 0) { + selectedCommandParamInstances.set( + createParamInstancesFromSchema(cmd.params, get(maybeParams)), + ); + } + // if the instance is not already valid (via inline params) show the builder modal + showingBuilder.set(!get(instanceValid)); + } + } + return selectedCommand; +} + +export function cancel() { + selectedCommand.set(undefined); + error.set(undefined); + prefix.set(""); + focusedCommandIndex.set(0); + selectedCommandParamInstances.set([]); + showingBuilder.set(false); +} diff --git a/frontend/app/src/components/home/CurrentChat.svelte b/frontend/app/src/components/home/CurrentChat.svelte index c9b95aefd4..abfef333e4 100644 --- a/frontend/app/src/components/home/CurrentChat.svelte +++ b/frontend/app/src/components/home/CurrentChat.svelte @@ -35,6 +35,11 @@ blockedUsers as directlyBlockedUsers, communities, selectedCommunity, + CreatePoll, + CreateTestMessages, + SearchChat, + AttachGif, + TokenTransfer, } from "openchat-client"; import PollBuilder from "./PollBuilder.svelte"; import CryptoTransferBuilder from "./CryptoTransferBuilder.svelte"; @@ -115,12 +120,52 @@ } onMount(() => { - return messagesRead.subscribe(() => { + client.addEventListener("openchat_event", clientEvent); + const unsub = messagesRead.subscribe(() => { unreadMessages = getUnreadMessageCount(chat); firstUnreadMention = client.getFirstUnreadMention(chat); }); + return () => { + unsub(); + client.removeEventListener("openchat_event", clientEvent); + }; }); + function clientEvent(ev: Event): void { + if (ev instanceof CreatePoll) { + if ( + ev.detail.chatId === messageContext.chatId && + ev.detail.threadRootMessageIndex === undefined + ) { + createPoll(); + } + } + if (ev instanceof CreateTestMessages) { + const [{ chatId, threadRootMessageIndex }, num] = ev.detail; + if (chatId === messageContext.chatId && threadRootMessageIndex === undefined) { + createTestMessages(num); + } + } + if (ev instanceof TokenTransfer) { + const { context } = ev.detail; + if ( + context.chatId === messageContext.chatId && + context.threadRootMessageIndex === undefined + ) { + tokenTransfer(ev); + } + } + if (ev instanceof AttachGif) { + const [{ chatId, threadRootMessageIndex }, search] = ev.detail; + if (chatId === messageContext.chatId && threadRootMessageIndex === undefined) { + attachGif(new CustomEvent("openchat_client", { detail: search })); + } + } + if (ev instanceof SearchChat) { + searchChat(ev); + } + } + function importToCommunity() { importToCommunities = $communities.filter((c) => c.membership.role === "owner"); if (importToCommunities.size === 0) { @@ -150,10 +195,10 @@ creatingPoll = true; } - function tokenTransfer(ev: CustomEvent<{ ledger: string; amount: bigint } | undefined>) { - creatingCryptoTransfer = ev.detail ?? { - ledger: $lastCryptoSent ?? LEDGER_CANISTER_ICP, - amount: BigInt(0), + function tokenTransfer(ev: CustomEvent<{ ledger?: string; amount?: bigint }>) { + creatingCryptoTransfer = { + ledger: ev.detail.ledger ?? $lastCryptoSent ?? LEDGER_CANISTER_ICP, + amount: ev.detail.amount ?? BigInt(0), }; } @@ -193,11 +238,9 @@ searchTerm = ev.detail; } - function createTestMessages(ev: CustomEvent): void { - if (process.env.NODE_ENV === "production") return; - + function createTestMessages(total: number): void { function send(n: number) { - if (n === ev.detail) return; + if (n === total) return; sendMessageWithAttachment(randomSentence(), false, undefined); @@ -410,6 +453,7 @@ {preview} {lapsed} {blocked} + {messageContext} externalContent={externalUrl !== undefined} on:joinGroup on:upgrade @@ -423,7 +467,6 @@ on:fileSelected={fileSelected} on:audioCaptured={fileSelected} on:sendMessage={sendMessage} - on:createTestMessages={createTestMessages} on:attachGif={attachGif} on:makeMeme={makeMeme} on:tokenTransfer={tokenTransfer} diff --git a/frontend/app/src/components/home/Footer.svelte b/frontend/app/src/components/home/Footer.svelte index 6ba27d6fa2..df3717c1f1 100644 --- a/frontend/app/src/components/home/Footer.svelte +++ b/frontend/app/src/components/home/Footer.svelte @@ -16,6 +16,7 @@ OpenChat, MultiUserChat, AttachmentContent, + MessageContext, } from "openchat-client"; import { createEventDispatcher, getContext } from "svelte"; import HoverIcon from "../HoverIcon.svelte"; @@ -38,6 +39,7 @@ export let user: CreatedUser; export let mode: "thread" | "message" = "message"; export let externalContent: boolean = false; + export let messageContext: MessageContext; const dispatch = createEventDispatcher(); @@ -149,6 +151,7 @@ {replyingTo} {textContent} {chat} + {messageContext} on:sendMessage on:cancelEditEvent on:setTextContent @@ -165,8 +168,7 @@ on:fileSelected on:audioCaptured on:joinGroup - on:upgrade - on:createTestMessages /> + on:upgrade /> diff --git a/frontend/app/src/components/home/PrizeContentBuilder.svelte b/frontend/app/src/components/home/PrizeContentBuilder.svelte index 1707f15fd6..53c620b3b3 100644 --- a/frontend/app/src/components/home/PrizeContentBuilder.svelte +++ b/frontend/app/src/components/home/PrizeContentBuilder.svelte @@ -34,12 +34,15 @@ cryptoBalance as cryptoBalanceStore, cryptoLookup, } from "openchat-client"; + import Checkbox from "../Checkbox.svelte"; + import Select from "../Select.svelte"; const ONE_HOUR = 1000 * 60 * 60; const ONE_DAY = ONE_HOUR * 24; const ONE_WEEK = ONE_DAY * 7; const client = getContext("client"); const dispatch = createEventDispatcher(); + const streaks = ["3", "7", "14", "30", "100"]; export let draftAmount: bigint; export let ledger: string; @@ -51,7 +54,11 @@ const durations: Duration[] = ["oneHour", "oneDay", "oneWeek"]; type Duration = "oneHour" | "oneDay" | "oneWeek"; let selectedDuration: Duration = "oneDay"; - let diamondOnly = true; + let diamondOnly = false; + let diamondType: "standard" | "lifetime" = "standard"; + let uniquePersonOnly = false; + let streakOnly = false; + let streakValue = "3"; let refreshing = false; let error: string | undefined = undefined; let message = ""; @@ -61,6 +68,7 @@ let tokenInputState: "ok" | "zero" | "too_low" | "too_high"; let sending = false; + $: anyUser = !diamondOnly && !uniquePersonOnly && !streakOnly; $: cryptoBalance = $cryptoBalanceStore[ledger] ?? BigInt(0); $: tokenDetails = $cryptoLookup[ledger]; $: symbol = tokenDetails.symbol; @@ -123,7 +131,10 @@ kind: "prize_content_initial", caption: message === "" ? undefined : message, endDate: getEndDate(), - diamondOnly, + diamondOnly: diamondOnly && diamondType === "standard", + lifetimeDiamondOnly: diamondOnly && diamondType === "lifetime", + uniquePersonOnly, + streakOnly: streakOnly ? parseInt(streakValue) : 0, transfer: { kind: "pending", ledger, @@ -248,6 +259,13 @@ const range = max - min; return Math.floor(min + Math.random() * range); } + + function onAnyUserChecked() { + anyUser = true; + diamondOnly = false; + uniquePersonOnly = false; + streakOnly = false; + } @@ -367,18 +385,49 @@
- (diamondOnly = true)} - checked={diamondOnly} - id={`restricted_diamond`} - label={i18nKey(`prizes.onlyDiamond`)} - group={"prize_restriction"} /> - (diamondOnly = false)} - checked={!diamondOnly} - id={`restricted_anyone`} + + bind:checked={anyUser} + on:change={onAnyUserChecked} /> + + {#if diamondOnly} +
+ (diamondType = "standard")} + checked={diamondType === "standard"} + label={i18nKey(`prizes.standardDiamond`)} + group={"diamond"} /> + (diamondType = "lifetime")} + checked={diamondType === "lifetime"} + label={i18nKey(`prizes.lifetimeDiamond`)} + group={"diamond"} /> +
+ {/if} + + + {#if streakOnly} + + {/if}
{#if errorMessage !== undefined} @@ -415,6 +464,10 @@
diff --git a/frontend/app/src/components/home/RightPanel.svelte b/frontend/app/src/components/home/RightPanel.svelte index 9fc9d82964..67207778d0 100644 --- a/frontend/app/src/components/home/RightPanel.svelte +++ b/frontend/app/src/components/home/RightPanel.svelte @@ -549,12 +549,15 @@ client.setSoftDisabled(true)} on:upgrade + on:verifyHumanity {user} on:closeProfile={popRightPanelHistory} /> {:else if threadRootEvent !== undefined && $selectedChat !== undefined} -{#if verifying} - (verifying = false)} on:success={() => (verifying = false)} /> -{/if} -

@@ -390,7 +384,7 @@ {:else}
-
diff --git a/frontend/app/src/components/home/profile/VerifyHumanity.svelte b/frontend/app/src/components/home/profile/VerifyHumanity.svelte index 12eca2474c..16a62ca1f0 100644 --- a/frontend/app/src/components/home/profile/VerifyHumanity.svelte +++ b/frontend/app/src/components/home/profile/VerifyHumanity.svelte @@ -11,7 +11,6 @@ import { uniquePersonCredentialGate } from "../../../utils/access"; import Markdown from "../Markdown.svelte"; import { _ } from "svelte-i18n"; - import Overlay from "../../Overlay.svelte"; import ModalContent from "../../ModalContent.svelte"; import LinkAccounts from "./LinkAccounts.svelte"; import HumanityConfirmation from "../HumanityConfirmation.svelte"; @@ -52,13 +51,15 @@ if (credential === undefined) { failed = true; } else { - return client.submitProofOfUniquePersonhood(credential, iiPrincipalCopy).then((resp) => { - if (resp.kind !== "success") { - failed = true; - } else { - dispatch("success"); - } - }); + return client + .submitProofOfUniquePersonhood(credential, iiPrincipalCopy) + .then((resp) => { + if (resp.kind !== "success") { + failed = true; + } else { + dispatch("success"); + } + }); } }) .catch(() => (failed = true)) @@ -66,73 +67,71 @@ } - - {#if checkingPrincipal} - -
-
- -
+{#if checkingPrincipal} + +
+
+
- - {:else if step === "linking"} - - (step = "verification")} - explanations={[i18nKey("identity.humanityWarning")]} /> - - {:else} - -
- -
- -
+
+
+{:else if step === "linking"} + + (step = "verification")} + explanations={[i18nKey("identity.humanityWarning")]} /> + +{:else} + +
+ +
+
-
- {#if failed} -

- - - -

-

- -

+
+
+ {#if failed} +

+ + + +

+

+ +

-

- -

+

+ +

-

- -

- {:else} -

- -

- - {/if} -
+

+ +

+ {:else} +

+ +

+ + {/if} +
-
- - - - - -
-
- {/if} - +
+ + + + + +
+ +{/if}