feat(space): implement join request functionality and related notifications
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified config/default.toml config/default.toml +2 −0 View config/default.toml
@@ -157,6 +157,7 @@
157157comment_create_per_day = 1000
158158dm_open_per_day = 100
159159webhook_create_per_day = 20
160space_join_request_per_day = 20
160161
161162[space]
162163max_pagination = 100
@@ -167,6 +168,7 @@
167168max_alias_name_len = 64
168169max_invite_email_len = 254
169170allow_self_join_groups = false
171join_request_cooldown_hours = 24
170172max_dir_nodes = 5000
171173
172174[chat]
Modified lib/config/src/quota.rs lib/config/src/quota.rs +1 −0 View lib/config/src/quota.rs
@@ -11,4 +11,5 @@
1111 pub comment_create_per_day: i64,
1212 pub dm_open_per_day: i64,
1313 pub webhook_create_per_day: i64,
14 pub space_join_request_per_day: i64,
1415}
Modified lib/config/src/space.rs lib/config/src/space.rs +2 −0 View lib/config/src/space.rs
@@ -11,6 +11,7 @@
1111 pub max_alias_name_len: usize,
1212 pub max_invite_email_len: usize,
1313 pub allow_self_join_groups: bool,
14 pub join_request_cooldown_hours: i64,
1415 pub max_dir_nodes: u64,
1516}
1617
@@ -25,6 +26,7 @@
2526 max_alias_name_len: 64,
2627 max_invite_email_len: 254,
2728 allow_self_join_groups: false,
29 join_request_cooldown_hours: 24,
2830 max_dir_nodes: 5000,
2931 }
3032 }
Modified lib/export/src/api_error.rs lib/export/src/api_error.rs +10 −0 View lib/export/src/api_error.rs
@@ -196,6 +196,16 @@
196196 )
197197 }
198198
199 pub fn forbidden_with_code(
200 error_code: impl ::std::convert::Into<String>,
201 message: impl ::std::convert::Into<String>,
202 ) -> Self {
203 Self::build(
204 403,
205 $crate::rest::AppResponseData::err(403, message)
206 .with_error_code(error_code),
207 )
208 }
199209 pub fn conflict_with_code(
200210 error_code: impl ::std::convert::Into<String>,
201211 message: impl ::std::convert::Into<String>,
Modified lib/i18n/src/catalog.rs lib/i18n/src/catalog.rs +30 −4 View lib/i18n/src/catalog.rs
@@ -35,7 +35,9 @@
3535pub fn verify_body(locale: UiLocale, minutes: u64) -> String {
3636 match locale {
3737 UiLocale::ZhCn => format!("输入下列验证码以继续,{minutes} 分钟内有效。"),
38 UiLocale::En => format!("Enter the following code to continue. It expires in {minutes} minutes."),
38 UiLocale::En => {
39 format!("Enter the following code to continue. It expires in {minutes} minutes.")
40 }
3941 }
4042}
4143
@@ -78,7 +80,9 @@
7880pub fn reset_body(locale: UiLocale) -> &'static str {
7981 match locale {
8082 UiLocale::ZhCn => "点击下方按钮重置密码,链接 30 分钟内有效。",
81 UiLocale::En => "Click the button below to reset your password. This link expires in 30 minutes.",
83 UiLocale::En => {
84 "Click the button below to reset your password. This link expires in 30 minutes."
85 }
8286 }
8387}
8488
@@ -121,7 +125,9 @@
121125pub fn email_change_body(locale: UiLocale, new_email: &str) -> String {
122126 match locale {
123127 UiLocale::ZhCn => format!("你申请把邮箱改为 {new_email},点击下方按钮完成确认。"),
124 UiLocale::En => format!("You requested to change your email to {new_email}. Click the button below to confirm."),
128 UiLocale::En => format!(
129 "You requested to change your email to {new_email}. Click the button below to confirm."
130 ),
125131 }
126132}
127133
@@ -360,6 +366,26 @@
360366 }
361367}
362368
369pub fn notification_join_request_submitted_title(locale: UiLocale, space: &str) -> String {
370 match locale {
371 UiLocale::ZhCn => format!("{space} 有一条新的加入申请"),
372 UiLocale::En => format!("New join request for {space}"),
373 }
374}
375
376pub fn notification_join_request_approved_title(locale: UiLocale, space: &str) -> String {
377 match locale {
378 UiLocale::ZhCn => format!("你加入 {space} 的申请已通过"),
379 UiLocale::En => format!("Your request to join {space} was approved"),
380 }
381}
382
383pub fn notification_join_request_rejected_title(locale: UiLocale, space: &str) -> String {
384 match locale {
385 UiLocale::ZhCn => format!("你加入 {space} 的申请未通过"),
386 UiLocale::En => format!("Your request to join {space} was declined"),
387 }
388}
363389pub fn notification_channel_invite_title(locale: UiLocale) -> &'static str {
364390 match locale {
365391 UiLocale::ZhCn => "你被邀请加入一个频道",
@@ -444,4 +470,4 @@
444470 UiLocale::ZhCn => format!("或复制此链接:{url}"),
445471 UiLocale::En => format!("Or copy this link: {url}"),
446472 }
447}
No newline at end of file
473}
Modified lib/models/src/space/mod.rs lib/models/src/space/mod.rs +1 −0 View lib/models/src/space/mod.rs
@@ -3,6 +3,7 @@
33pub mod space_groups;
44pub mod space_invite;
55pub mod space_invite_groups;
6pub mod space_join_request;
67pub mod space_join_rule;
78pub mod space_member_groups;
89pub mod space_members;
Added lib/models/src/space/space_join_request.rs lib/models/src/space/space_join_request.rs +55 −0 View lib/models/src/space/space_join_request.rs
@@ -0,0 +1,55 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[serde(rename_all = "snake_case")]
20#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")]
21pub enum SpaceJoinRequestStatus {
22 #[default]
23 #[sea_orm(string_value = "pending")]
24 Pending,
25 #[sea_orm(string_value = "approved")]
26 Approved,
27 #[sea_orm(string_value = "rejected")]
28 Rejected,
29 #[sea_orm(string_value = "withdrawn")]
30 Withdrawn,
31}
32
33#[sea_orm::model]
34#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
35#[sea_orm(table_name = "space_join_request")]
36pub struct Model {
37 #[sea_orm(primary_key, auto_increment = false)]
38 pub id: Uuid,
39 pub space_id: Uuid,
40 pub user_id: Uuid,
41 pub status: SpaceJoinRequestStatus,
42 pub note: Option<String>,
43 pub decided_by: Option<Uuid>,
44 pub decided_at: Option<OffsetDateTime>,
45 pub created_at: OffsetDateTime,
46 pub updated_at: OffsetDateTime,
47 #[sea_orm(belongs_to, from = "space_id", to = "id")]
48 pub space: BelongsTo<super::spaces::Entity>,
49 #[sea_orm(belongs_to, relation_enum = "Applicant", from = "user_id", to = "uid")]
50 pub applicant: BelongsTo<super::super::users::user::Entity>,
51 #[sea_orm(belongs_to, relation_enum = "Decider", from = "decided_by", to = "uid")]
52 pub decider: BelongsTo<Option<super::super::users::user::Entity>>,
53}
54
55impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/schema_extras.sql lib/models/schema_extras.sql +3 −0 View lib/models/schema_extras.sql
@@ -158,6 +158,9 @@
158158CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_draft_unique
159159 ON chat_user_draft (user_id, room_id, thread_id) NULLS NOT DISTINCT;
160160
161CREATE UNIQUE INDEX IF NOT EXISTS idx_space_join_request_pending
162 ON space_join_request (space_id, user_id) WHERE status = 'pending';
163
161164CREATE INDEX IF NOT EXISTS idx_chat_webhook_token
162165 ON chat_room_webhook (token_hash);
163166
Modified lib/models/schema_pre_sync.sql lib/models/schema_pre_sync.sql +1 −0 View lib/models/schema_pre_sync.sql
@@ -16,6 +16,7 @@
1616DROP INDEX IF EXISTS idx_chat_dm_pair;
1717DROP INDEX IF EXISTS idx_chat_gdm_key;
1818DROP INDEX IF EXISTS idx_chat_draft_unique;
19DROP INDEX IF EXISTS idx_space_join_request_pending;
1920
2021-- scheduled messages feature removed; drop the legacy table before sync
2122DROP TABLE IF EXISTS user_scheduled_message;
Added lib/space/src/handlers/explore_spaces.rs lib/space/src/handlers/explore_spaces.rs +111 −0 View lib/space/src/handlers/explore_spaces.rs
@@ -0,0 +1,111 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::{Session, SessionUser};
4
5use crate::errors::ApiError;
6use crate::services::join_requests::{
7 ExploreQuery, ExploreSpacePage, JoinRequestDto, JoinRequestPage, JoinRequestPath,
8 ListJoinRequestsQuery,
9};
10use crate::services::service::SpaceService;
11use crate::services::spaces::SpaceNamePath;
12use session::share::session::require_uid;
13
14#[utoipa::path(
15 get,
16 path = "/spaces/explore",
17 tag = "space",
18 description = "分页列出公开的活跃空间;已登录时返回调用者的成员与申请状态。",
19 params(ExploreQuery),
20 responses(
21 (status = 200, description = "explore public spaces", body = AppResponseData<ExploreSpacePage>),
22 (status = 400, description = "bad request", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn explore_spaces(
26 State(service): State<SpaceService>,
27 session: Session,
28 Query(query): Query<ExploreQuery>,
29) -> Result<AppResponseData<ExploreSpacePage>, ApiError> {
30 let viewer = session.uid();
31 let page = service.explore_spaces(viewer, query).await?;
32 Ok(AppResponseData::ok(page))
33}
34
35#[utoipa::path(
36 get,
37 path = "/spaces/{space_name}/join-requests",
38 tag = "space",
39 description = "分页列出空间的加入申请,需要成员管理权限。",
40 params(SpaceNamePath, ListJoinRequestsQuery),
41 responses(
42 (status = 200, description = "list join requests", body = AppResponseData<JoinRequestPage>),
43 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
44 (status = 404, description = "not found", body = AppResponseData<NoData>),
45 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
46 )
47)]
48pub async fn list_join_requests(
49 State(service): State<SpaceService>,
50 session: Session,
51 Path(path): Path<SpaceNamePath>,
52 Query(query): Query<ListJoinRequestsQuery>,
53) -> Result<AppResponseData<JoinRequestPage>, ApiError> {
54 let uid = require_uid(&session)?;
55 let page = service
56 .list_join_requests(uid, &path.space_name, query)
57 .await?;
58 Ok(AppResponseData::ok(page))
59}
60
61#[utoipa::path(
62 post,
63 path = "/spaces/{space_name}/join-requests/{request_id}/approve",
64 tag = "space",
65 description = "通过一条待审批的加入申请并把申请人加入空间,需要成员管理权限。",
66 params(JoinRequestPath),
67 responses(
68 (status = 200, description = "join request approved", body = AppResponseData<JoinRequestDto>),
69 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
70 (status = 404, description = "not found", body = AppResponseData<NoData>),
71 (status = 409, description = "join request is not pending", body = AppResponseData<NoData>),
72 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
73 )
74)]
75pub async fn approve_join_request(
76 State(service): State<SpaceService>,
77 session: Session,
78 Path(path): Path<JoinRequestPath>,
79) -> Result<AppResponseData<JoinRequestDto>, ApiError> {
80 let uid = require_uid(&session)?;
81 let request = service
82 .approve_join_request(uid, &path.space_name, path.request_id)
83 .await?;
84 Ok(AppResponseData::ok(request))
85}
86
87#[utoipa::path(
88 post,
89 path = "/spaces/{space_name}/join-requests/{request_id}/reject",
90 tag = "space",
91 description = "驳回一条待审批的加入申请,需要成员管理权限。",
92 params(JoinRequestPath),
93 responses(
94 (status = 200, description = "join request rejected", body = AppResponseData<JoinRequestDto>),
95 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
96 (status = 404, description = "not found", body = AppResponseData<NoData>),
97 (status = 409, description = "join request is not pending", body = AppResponseData<NoData>),
98 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
99 )
100)]
101pub async fn reject_join_request(
102 State(service): State<SpaceService>,
103 session: Session,
104 Path(path): Path<JoinRequestPath>,
105) -> Result<AppResponseData<JoinRequestDto>, ApiError> {
106 let uid = require_uid(&session)?;
107 let request = service
108 .reject_join_request(uid, &path.space_name, path.request_id)
109 .await?;
110 Ok(AppResponseData::ok(request))
111}
Added lib/space/src/handlers/join_requests.rs lib/space/src/handlers/join_requests.rs +64 −0 View lib/space/src/handlers/join_requests.rs
@@ -0,0 +1,64 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::join_requests::{CreateJoinRequestCmd, JoinRequestDto, JoinRequestPath};
7use crate::services::service::SpaceService;
8use crate::services::spaces::SpaceNamePath;
9use session::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/spaces/{space_name}/join-requests",
14 tag = "space",
15 description = "提交一条加入公开空间的申请,可附一段说明。",
16 params(SpaceNamePath),
17 request_body = CreateJoinRequestCmd,
18 responses(
19 (status = 200, description = "join request submitted", body = AppResponseData<JoinRequestDto>),
20 (status = 400, description = "bad request", body = AppResponseData<NoData>),
21 (status = 403, description = "join not allowed or requirement not met", body = AppResponseData<NoData>),
22 (status = 404, description = "not found", body = AppResponseData<NoData>),
23 (status = 409, description = "already a member or request already pending", body = AppResponseData<NoData>),
24 (status = 429, description = "rate limit exceeded", body = AppResponseData<NoData>),
25 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
26 )
27)]
28pub async fn create_join_request(
29 State(service): State<SpaceService>,
30 session: Session,
31 Path(path): Path<SpaceNamePath>,
32 Json(cmd): Json<CreateJoinRequestCmd>,
33) -> Result<AppResponseData<JoinRequestDto>, ApiError> {
34 let uid = require_uid(&session)?;
35 let request = service
36 .create_join_request(uid, &path.space_name, cmd)
37 .await?;
38 Ok(AppResponseData::ok(request))
39}
40
41#[utoipa::path(
42 delete,
43 path = "/spaces/{space_name}/join-requests/{request_id}",
44 tag = "space",
45 description = "申请人撤回自己待审批的加入申请。",
46 params(JoinRequestPath),
47 responses(
48 (status = 200, description = "join request withdrawn", body = AppResponseData<NoData>),
49 (status = 404, description = "not found", body = AppResponseData<NoData>),
50 (status = 409, description = "join request is not pending", body = AppResponseData<NoData>),
51 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
52 )
53)]
54pub async fn withdraw_join_request(
55 State(service): State<SpaceService>,
56 session: Session,
57 Path(path): Path<JoinRequestPath>,
58) -> Result<AppResponseData<NoData>, ApiError> {
59 let uid = require_uid(&session)?;
60 service
61 .withdraw_join_request(uid, &path.space_name, path.request_id)
62 .await?;
63 Ok(AppResponseData::empty())
64}
Modified lib/space/src/handlers/mod.rs lib/space/src/handlers/mod.rs +2 −0 View lib/space/src/handlers/mod.rs
@@ -13,6 +13,7 @@
1313pub mod delete_room;
1414pub mod delete_space;
1515pub mod delete_space_image;
16pub mod explore_spaces;
1617pub mod get_ban;
1718pub mod get_group;
1819pub mod get_member;
@@ -21,6 +22,7 @@
2122pub mod get_space_image;
2223pub mod get_space_image_file;
2324pub mod join_group_self;
25pub mod join_requests;
2426pub mod leave_group_self;
2527pub mod list_bans;
2628pub mod list_dir_group_overwrites;
Added lib/space/src/services/join_requests.rs lib/space/src/services/join_requests.rs +724 −0 View lib/space/src/services/join_requests.rs
@@ -0,0 +1,724 @@
1use std::collections::{HashMap, HashSet};
2
3use models::space::space_group_permissions;
4use models::space::space_join_request::{self, SpaceJoinRequestStatus};
5use models::space::space_member_groups;
6use models::space::space_members::{self, MemberStatus};
7use models::space::spaces::{self, AllowJoinLevel};
8use models::users::user;
9use models::users::user_emails;
10use models::users::user_notifications::{self, NotificationType};
11use sea_orm::sea_query::Expr;
12use sea_orm::{
13 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
14 QuerySelect, Set, TransactionTrait,
15};
16use serde::{Deserialize, Serialize};
17use time::{Duration, OffsetDateTime};
18use utoipa::{IntoParams, ToSchema};
19use uuid::Uuid;
20
21use crate::errors::{SpaceError, is_unique_violation};
22use crate::services::members::{MemberActivationDefaults, activate_member_row};
23use crate::services::service::SpaceService;
24use crate::services::spaces::{UserDto, resolve_space_image_url};
25use crate::share::authz::{MANAGE_MEMBERS, assert_not_banned, require_perm};
26use crate::share::lookup::lookup_space_by_name;
27
28pub const MAX_JOIN_REQUEST_NOTE_LEN: usize = 512;
29
30pub type JoinRequestPage = export::pagination::Page<JoinRequestDto>;
31pub type ExploreSpacePage = export::pagination::Page<ExploreSpaceDto>;
32
33#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
34pub struct JoinRequestDto {
35 pub id: Uuid,
36 pub space_id: Uuid,
37 pub user: Option<UserDto>,
38 pub note: Option<String>,
39 pub status: SpaceJoinRequestStatus,
40 pub decided_by: Option<UserDto>,
41 pub decided_at: Option<OffsetDateTime>,
42 pub created_at: OffsetDateTime,
43 pub updated_at: OffsetDateTime,
44}
45
46#[derive(Debug, Clone, Deserialize, ToSchema)]
47pub struct CreateJoinRequestCmd {
48 #[serde(default)]
49 pub note: Option<String>,
50}
51
52#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
53pub struct JoinRequestPath {
54 pub space_name: String,
55 pub request_id: Uuid,
56}
57
58#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
59pub struct ListJoinRequestsQuery {
60 pub status: Option<SpaceJoinRequestStatus>,
61 pub limit: Option<u64>,
62 pub offset: Option<u64>,
63}
64
65#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
66pub struct ExploreQuery {
67 pub q: Option<String>,
68 pub sort: Option<String>,
69 pub limit: Option<u64>,
70 pub offset: Option<u64>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
74pub struct ExploreViewerRequestDto {
75 pub id: Uuid,
76 pub status: SpaceJoinRequestStatus,
77 pub created_at: OffsetDateTime,
78 pub decided_at: Option<OffsetDateTime>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub cooldown_until: Option<OffsetDateTime>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
84pub struct ExploreViewerDto {
85 pub is_member: bool,
86 pub request: Option<ExploreViewerRequestDto>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
90pub struct ExploreSpaceDto {
91 pub id: Uuid,
92 pub lower_name: String,
93 pub name: String,
94 pub description: Option<String>,
95 pub avatar_url: Option<String>,
96 pub banner_url: Option<String>,
97 pub members_count: i64,
98 pub allow_join: bool,
99 pub allow_join_level: AllowJoinLevel,
100 pub created_at: OffsetDateTime,
101 pub viewer: Option<ExploreViewerDto>,
102}
103
104fn join_request_dto(
105 row: space_join_request::Model,
106 users: &HashMap<Uuid, UserDto>,
107) -> JoinRequestDto {
108 JoinRequestDto {
109 id: row.id,
110 space_id: row.space_id,
111 user: users.get(&row.user_id).cloned(),
112 note: row.note,
113 status: row.status,
114 decided_by: row.decided_by.and_then(|uid| users.get(&uid).cloned()),
115 decided_at: row.decided_at,
116 created_at: row.created_at,
117 updated_at: row.updated_at,
118 }
119}
120
121impl SpaceService {
122 async fn has_activated_email(&self, uid: Uuid) -> Result<bool, SpaceError> {
123 let count = user_emails::Entity::find()
124 .filter(user_emails::Column::UserId.eq(uid))
125 .filter(user_emails::Column::IsActivated.eq(true))
126 .filter(user_emails::Column::DeletedAt.is_null())
127 .count(self.db().read())
128 .await?;
129 Ok(count > 0)
130 }
131
132 async fn assert_join_requirements(
133 &self,
134 space: &spaces::Model,
135 uid: Uuid,
136 applicant: &user::Model,
137 ) -> Result<(), SpaceError> {
138 match space.allow_join_level {
139 AllowJoinLevel::None => {}
140 AllowJoinLevel::NeedMailActiveAndPhoneActive
141 | AllowJoinLevel::NeedMailActive
142 | AllowJoinLevel::NeedMailActiveAndLastOneMid
143 | AllowJoinLevel::NeedMailActiveAndLastOneHours => {
144 if !self.has_activated_email(uid).await? {
145 return Err(SpaceError::JoinRequirementNotMet(
146 "an activated email address is required".to_string(),
147 ));
148 }
149 let now = OffsetDateTime::now_utc();
150 match space.allow_join_level {
151 AllowJoinLevel::NeedMailActiveAndLastOneMid => {
152 if applicant.created_at > now - Duration::days(1) {
153 return Err(SpaceError::JoinRequirementNotMet(
154 "the account must be at least one day old".to_string(),
155 ));
156 }
157 }
158 AllowJoinLevel::NeedMailActiveAndLastOneHours => {
159 if applicant.created_at > now - Duration::hours(1) {
160 return Err(SpaceError::JoinRequirementNotMet(
161 "the account must be at least one hour old".to_string(),
162 ));
163 }
164 }
165 _ => {}
166 }
167 }
168 }
169 if space.member_must_enable_2fa && !applicant.totp_enabled {
170 return Err(SpaceError::JoinRequirementNotMet(
171 "two-factor authentication is required".to_string(),
172 ));
173 }
174 Ok(())
175 }
176
177 async fn approver_uids(&self, space: &spaces::Model) -> Result<Vec<Uuid>, SpaceError> {
178 let mut uids: HashSet<Uuid> = HashSet::new();
179 uids.insert(space.owner_id);
180 let member_groups = space_member_groups::Entity::find()
181 .filter(space_member_groups::Column::SpaceId.eq(space.id))
182 .all(self.db().read())
183 .await?;
184 let group_ids: Vec<Uuid> = {
185 let mut ids: Vec<Uuid> = member_groups.iter().map(|mg| mg.group_id).collect();
186 ids.sort();
187 ids.dedup();
188 ids
189 };
190 if !group_ids.is_empty() {
191 let grants = space_group_permissions::Entity::find()
192 .filter(space_group_permissions::Column::GroupId.is_in(group_ids))
193 .filter(space_group_permissions::Column::Permission.eq(MANAGE_MEMBERS))
194 .all(self.db().read())
195 .await?;
196 let managing_groups: HashSet<Uuid> = grants.into_iter().map(|g| g.group_id).collect();
197 let candidates: Vec<Uuid> = member_groups
198 .into_iter()
199 .filter(|mg| managing_groups.contains(&mg.group_id))
200 .map(|mg| mg.user_id)
201 .collect();
202 if !candidates.is_empty() {
203 let active: Vec<Uuid> = space_members::Entity::find()
204 .filter(space_members::Column::SpaceId.eq(space.id))
205 .filter(space_members::Column::UserId.is_in(candidates.clone()))
206 .filter(space_members::Column::Status.eq(MemberStatus::Active))
207 .all(self.db().read())
208 .await?
209 .into_iter()
210 .map(|m| m.user_id)
211 .collect();
212 uids.extend(active);
213 }
214 }
215 Ok(uids.into_iter().collect())
216 }
217
218 async fn notify_join_event(
219 &self,
220 recipient: Uuid,
221 title: String,
222 data: serde_json::Value,
223 ) -> Result<(), SpaceError> {
224 let row = user_notifications::ActiveModel {
225 id: Set(Uuid::now_v7()),
226 user_id: Set(recipient),
227 notification_type: Set(NotificationType::System),
228 title: Set(title),
229 body: Set(None),
230 data_json: Set(Some(data)),
231 actor_uid: Set(None),
232 read_at: Set(None),
233 created_at: Set(OffsetDateTime::now_utc()),
234 }
235 .insert(self.db().write())
236 .await?;
237 export::notify::publish_notification(&self.state.redis, recipient, row.to_event_value())
238 .await;
239 Ok(())
240 }
241
242 pub async fn explore_spaces(
243 &self,
244 viewer: Option<Uuid>,
245 query: ExploreQuery,
246 ) -> Result<ExploreSpacePage, SpaceError> {
247 let max_pagination = self.config().space.max_pagination;
248 let default_pagination = self.config().space.default_pagination;
249 let limit = query
250 .limit
251 .unwrap_or(default_pagination)
252 .clamp(1, max_pagination);
253 let offset = query
254 .offset
255 .unwrap_or(0)
256 .clamp(0, export::pagination::MAX_OFFSET);
257
258 let mut select = spaces::Entity::find()
259 .filter(spaces::Column::IsActive.eq(true))
260 .filter(spaces::Column::IsPrivate.eq(false));
261 if let Some(raw) = query
262 .q
263 .as_deref()
264 .map(str::trim)
265 .filter(|value| !value.is_empty())
266 {
267 let needle = raw.to_ascii_lowercase();
268 select = select.filter(
269 Condition::any()
270 .add(spaces::Column::LowerName.contains(needle))
271 .add(spaces::Column::Name.contains(raw)),
272 );
273 }
274 select = if query.sort.as_deref() == Some("newest") {
275 select.order_by_desc(spaces::Column::CreatedAt)
276 } else {
277 select
278 .order_by_desc(spaces::Column::NumsMembers)
279 .order_by_desc(spaces::Column::CreatedAt)
280 };
281
282 let total = select.clone().count(self.db().read()).await? as i64;
283 let rows = select
284 .limit(limit)
285 .offset(offset)
286 .all(self.db().read())
287 .await?;
288
289 let mut memberships: HashSet<Uuid> = HashSet::new();
290 let mut latest_requests: HashMap<Uuid, space_join_request::Model> = HashMap::new();
291 if let Some(uid) = viewer
292 && !rows.is_empty()
293 {
294 let space_ids: Vec<Uuid> = rows.iter().map(|s| s.id).collect();
295 memberships = space_members::Entity::find()
296 .filter(space_members::Column::UserId.eq(uid))
297 .filter(space_members::Column::SpaceId.is_in(space_ids.clone()))
298 .filter(space_members::Column::Status.eq(MemberStatus::Active))
299 .all(self.db().read())
300 .await?
301 .into_iter()
302 .map(|m| m.space_id)
303 .collect();
304 let requests = space_join_request::Entity::find()
305 .filter(space_join_request::Column::UserId.eq(uid))
306 .filter(space_join_request::Column::SpaceId.is_in(space_ids))
307 .order_by_desc(space_join_request::Column::CreatedAt)
308 .all(self.db().read())
309 .await?;
310 for row in requests {
311 latest_requests.entry(row.space_id).or_insert(row);
312 }
313 }
314
315 let cooldown_hours = self.config().space.join_request_cooldown_hours.max(0);
316 let items = rows
317 .into_iter()
318 .map(|s| {
319 let viewer = viewer.map(|_| ExploreViewerDto {
320 is_member: memberships.contains(&s.id),
321 request: latest_requests
322 .get(&s.id)
323 .map(|row| ExploreViewerRequestDto {
324 id: row.id,
325 status: row.status,
326 created_at: row.created_at,
327 decided_at: row.decided_at,
328 cooldown_until: if row.status == SpaceJoinRequestStatus::Rejected {
329 Some(
330 row.decided_at.unwrap_or(row.created_at)
331 + Duration::hours(cooldown_hours),
332 )
333 } else {
334 None
335 },
336 }),
337 });
338 ExploreSpaceDto {
339 id: s.id,
340 lower_name: s.lower_name.clone(),
341 name: s.name,
342 description: s.description,
343 avatar_url: resolve_space_image_url(&s.lower_name, "avatar", s.avatar_url),
344 banner_url: resolve_space_image_url(&s.lower_name, "banner", s.banner_url),
345 members_count: s.nums_members,
346 allow_join: s.allow_join,
347 allow_join_level: s.allow_join_level,
348 created_at: s.created_at,
349 viewer,
350 }
351 })
352 .collect();
353
354 Ok(ExploreSpacePage {
355 items,
356 total,
357 limit,
358 offset,
359 })
360 }
361
362 pub async fn create_join_request(
363 &self,
364 caller: Uuid,
365 space_name: &str,
366 cmd: CreateJoinRequestCmd,
367 ) -> Result<JoinRequestDto, SpaceError> {
368 if !self
369 .state
370 .redis
371 .quota_allow(
372 &format!("quota:space_join_request:{caller}"),
373 self.config().quota.space_join_request_per_day,
374 86_400,
375 )
376 .await
377 {
378 return Err(SpaceError::RateLimited(
379 "join request quota exceeded, retry tomorrow".to_string(),
380 ));
381 }
382 let space = lookup_space_by_name(self, space_name).await?;
383 if space.is_private || !space.allow_join {
384 return Err(SpaceError::JoinNotAllowed(
385 "this space does not accept join requests".to_string(),
386 ));
387 }
388
389 let applicant = user::Entity::find_by_id(caller)
390 .one(self.db().read())
391 .await?
392 .ok_or(SpaceError::NotFound)?;
393
394 let existing = space_members::Entity::find_by_id((space.id, caller))
395 .one(self.db().read())
396 .await?;
397 if existing.is_some_and(|member| member.status == MemberStatus::Active) {
398 return Err(SpaceError::Conflict(
399 "user is already a member of this space".to_string(),
400 ));
401 }
402
403 if let Some(ban) = models::space::space_bans::Entity::find_by_id((space.id, caller))
404 .one(self.db().read())
405 .await?
406 && (ban.expires_at.is_none() || ban.expires_at > Some(OffsetDateTime::now_utc()))
407 {
408 return Err(SpaceError::JoinNotAllowed(
409 "you are banned from this space".to_string(),
410 ));
411 }
412
413 let pending = space_join_request::Entity::find()
414 .filter(space_join_request::Column::SpaceId.eq(space.id))
415 .filter(space_join_request::Column::UserId.eq(caller))
416 .filter(space_join_request::Column::Status.eq(SpaceJoinRequestStatus::Pending))
417 .one(self.db().read())
418 .await?;
419 if pending.is_some() {
420 return Err(SpaceError::Conflict(
421 "a join request is already pending".to_string(),
422 ));
423 }
424
425 if let Some(last) = space_join_request::Entity::find()
426 .filter(space_join_request::Column::SpaceId.eq(space.id))
427 .filter(space_join_request::Column::UserId.eq(caller))
428 .filter(space_join_request::Column::Status.eq(SpaceJoinRequestStatus::Rejected))
429 .order_by_desc(space_join_request::Column::CreatedAt)
430 .one(self.db().read())
431 .await?
432 {
433 let cooldown_hours = self.config().space.join_request_cooldown_hours.max(0);
434 let decided_at = last.decided_at.unwrap_or(last.created_at);
435 if OffsetDateTime::now_utc() < decided_at + Duration::hours(cooldown_hours) {
436 return Err(SpaceError::JoinRequestCooldown(
437 "a declined request must cool down before trying again".to_string(),
438 ));
439 }
440 }
441
442 self.assert_join_requirements(&space, caller, &applicant)
443 .await?;
444
445 let note = cmd
446 .note
447 .map(|raw| raw.trim().to_string())
448 .filter(|value| !value.is_empty());
449 if let Some(value) = ¬e
450 && value.chars().count() > MAX_JOIN_REQUEST_NOTE_LEN
451 {
452 return Err(SpaceError::BadRequest(format!(
453 "join request note must be at most {MAX_JOIN_REQUEST_NOTE_LEN} characters"
454 )));
455 }
456
457 let now = OffsetDateTime::now_utc();
458 let request_row = space_join_request::ActiveModel {
459 id: Set(Uuid::now_v7()),
460 space_id: Set(space.id),
461 user_id: Set(caller),
462 status: Set(SpaceJoinRequestStatus::Pending),
463 note: Set(note),
464 decided_by: Set(None),
465 decided_at: Set(None),
466 created_at: Set(now),
467 updated_at: Set(now),
468 };
469 let inserted = match request_row.insert(self.db().write()).await {
470 Ok(row) => row,
471 Err(error) if is_unique_violation(&error) => {
472 return Err(SpaceError::Conflict(
473 "a join request is already pending".to_string(),
474 ));
475 }
476 Err(error) => return Err(error.into()),
477 };
478
479 let data = serde_json::json!({
480 "space_id": space.id,
481 "space_name": space.lower_name,
482 "request_id": inserted.id,
483 "applicant_id": caller,
484 "kind": "join_request_submitted",
485 });
486 for uid in self.approver_uids(&space).await? {
487 if uid == caller {
488 continue;
489 }
490 let locale = self.recipient_locale(Some(uid)).await;
491 let title =
492 i18n::catalog::notification_join_request_submitted_title(locale, &space.name);
493 if let Err(error) = self.notify_join_event(uid, title, data.clone()).await {
494 tracing::warn!(%error, %uid, space_id = %space.id, "join request notification failed");
495 }
496 }
497
498 let users = self.load_user_briefs(&[caller]).await?;
499 Ok(join_request_dto(inserted, &users))
500 }
501
502 pub async fn withdraw_join_request(
503 &self,
504 caller: Uuid,
505 space_name: &str,
506 request_id: Uuid,
507 ) -> Result<(), SpaceError> {
508 let space = lookup_space_by_name(self, space_name).await?;
509 let request = space_join_request::Entity::find_by_id(request_id)
510 .filter(space_join_request::Column::SpaceId.eq(space.id))
511 .one(self.db().read())
512 .await?
513 .ok_or(SpaceError::NotFound)?;
514 if request.user_id != caller {
515 return Err(SpaceError::NotFound);
516 }
517 if request.status != SpaceJoinRequestStatus::Pending {
518 return Err(SpaceError::Conflict(
519 "join request is not pending".to_string(),
520 ));
521 }
522 let now = OffsetDateTime::now_utc();
523 let updated = space_join_request::Entity::update_many()
524 .col_expr(
525 space_join_request::Column::Status,
526 Expr::value(SpaceJoinRequestStatus::Withdrawn),
527 )
528 .col_expr(
529 space_join_request::Column::DecidedAt,
530 Expr::value(Some(now)),
531 )
532 .col_expr(space_join_request::Column::UpdatedAt, Expr::value(now))
533 .filter(space_join_request::Column::Id.eq(request_id))
534 .filter(space_join_request::Column::Status.eq(SpaceJoinRequestStatus::Pending))
535 .exec(self.db().write())
536 .await?;
537 if updated.rows_affected == 0 {
538 return Err(SpaceError::Conflict(
539 "join request is not pending".to_string(),
540 ));
541 }
542 Ok(())
543 }
544
545 pub async fn list_join_requests(
546 &self,
547 caller: Uuid,
548 space_name: &str,
549 query: ListJoinRequestsQuery,
550 ) -> Result<JoinRequestPage, SpaceError> {
551 let space = lookup_space_by_name(self, space_name).await?;
552 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
553
554 let max_pagination = self.config().space.max_pagination;
555 let default_pagination = self.config().space.default_pagination;
556 let limit = query
557 .limit
558 .unwrap_or(default_pagination)
559 .clamp(1, max_pagination);
560 let offset = query
561 .offset
562 .unwrap_or(0)
563 .clamp(0, export::pagination::MAX_OFFSET);
564
565 let mut select = space_join_request::Entity::find()
566 .filter(space_join_request::Column::SpaceId.eq(space.id));
567 if let Some(status) = query.status {
568 select = select.filter(space_join_request::Column::Status.eq(status));
569 }
570 select = select.order_by_desc(space_join_request::Column::CreatedAt);
571
572 let total = select.clone().count(self.db().read()).await? as i64;
573 let rows = select
574 .limit(limit)
575 .offset(offset)
576 .all(self.db().read())
577 .await?;
578
579 let mut uids: Vec<Uuid> = rows.iter().filter_map(|row| row.decided_by).collect();
580 uids.extend(rows.iter().map(|row| row.user_id));
581 let users = self.load_user_briefs(&uids).await?;
582
583 Ok(JoinRequestPage {
584 items: rows
585 .into_iter()
586 .map(|row| join_request_dto(row, &users))
587 .collect(),
588 total,
589 limit,
590 offset,
591 })
592 }
593
594 async fn decide_join_request(
595 &self,
596 caller: Uuid,
597 space_name: &str,
598 request_id: Uuid,
599 approve: bool,
600 ) -> Result<JoinRequestDto, SpaceError> {
601 let space = lookup_space_by_name(self, space_name).await?;
602 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
603
604 let request = space_join_request::Entity::find_by_id(request_id)
605 .filter(space_join_request::Column::SpaceId.eq(space.id))
606 .one(self.db().read())
607 .await?
608 .ok_or(SpaceError::NotFound)?;
609 if request.status != SpaceJoinRequestStatus::Pending {
610 return Err(SpaceError::Conflict(
611 "join request is not pending".to_string(),
612 ));
613 }
614
615 if approve {
616 assert_not_banned(self.db().read(), space.id, request.user_id).await?;
617 let applicant = user::Entity::find_by_id(request.user_id)
618 .one(self.db().read())
619 .await?
620 .ok_or(SpaceError::NotFound)?;
621 if space.member_must_enable_2fa && !applicant.totp_enabled {
622 return Err(SpaceError::Forbidden);
623 }
624 }
625
626 let now = OffsetDateTime::now_utc();
627 let status = if approve {
628 SpaceJoinRequestStatus::Approved
629 } else {
630 SpaceJoinRequestStatus::Rejected
631 };
632 let space_id = space.id;
633 let applicant_id = request.user_id;
634 self.db()
635 .primary()
636 .transaction::<_, (), SpaceError>(|txn| {
637 Box::pin(async move {
638 let updated = space_join_request::Entity::update_many()
639 .col_expr(space_join_request::Column::Status, Expr::value(status))
640 .col_expr(
641 space_join_request::Column::DecidedBy,
642 Expr::value(Some(caller)),
643 )
644 .col_expr(
645 space_join_request::Column::DecidedAt,
646 Expr::value(Some(now)),
647 )
648 .col_expr(space_join_request::Column::UpdatedAt, Expr::value(now))
649 .filter(space_join_request::Column::Id.eq(request_id))
650 .filter(
651 space_join_request::Column::Status.eq(SpaceJoinRequestStatus::Pending),
652 )
653 .exec(txn)
654 .await?;
655 if updated.rows_affected == 0 {
656 return Err(SpaceError::Conflict(
657 "join request is not pending".to_string(),
658 ));
659 }
660 if approve {
661 activate_member_row(
662 txn,
663 space_id,
664 applicant_id,
665 now,
666 MemberActivationDefaults::default(),
667 )
668 .await?;
669 }
670 Ok(())
671 })
672 })
673 .await?;
674
675 if approve {
676 self.grant_public_room_memberships(space_id, applicant_id)
677 .await;
678 }
679
680 let locale = self.recipient_locale(Some(applicant_id)).await;
681 let title = if approve {
682 i18n::catalog::notification_join_request_approved_title(locale, &space.name)
683 } else {
684 i18n::catalog::notification_join_request_rejected_title(locale, &space.name)
685 };
686 let data = serde_json::json!({
687 "space_id": space.id,
688 "space_name": space.lower_name,
689 "request_id": request_id,
690 "applicant_id": applicant_id,
691 "kind": if approve { "join_request_approved" } else { "join_request_rejected" },
692 });
693 if let Err(error) = self.notify_join_event(applicant_id, title, data).await {
694 tracing::warn!(%error, %applicant_id, %request_id, "join request decision notification failed");
695 }
696
697 let updated = space_join_request::Entity::find_by_id(request_id)
698 .one(self.db().read())
699 .await?
700 .ok_or(SpaceError::NotFound)?;
701 let users = self.load_user_briefs(&[updated.user_id, caller]).await?;
702 Ok(join_request_dto(updated, &users))
703 }
704
705 pub async fn approve_join_request(
706 &self,
707 caller: Uuid,
708 space_name: &str,
709 request_id: Uuid,
710 ) -> Result<JoinRequestDto, SpaceError> {
711 self.decide_join_request(caller, space_name, request_id, true)
712 .await
713 }
714
715 pub async fn reject_join_request(
716 &self,
717 caller: Uuid,
718 space_name: &str,
719 request_id: Uuid,
720 ) -> Result<JoinRequestDto, SpaceError> {
721 self.decide_join_request(caller, space_name, request_id, false)
722 .await
723 }
724}
Modified lib/space/src/services/members.rs lib/space/src/services/members.rs +136 −91 View lib/space/src/services/members.rs
@@ -4,8 +4,8 @@
44use models::users::user;
55use sea_orm::sea_query::Expr;
66use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
8 QuerySelect, Set, TransactionTrait,
7 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter,
8 QueryOrder, QuerySelect, Set, TransactionTrait,
99};
1010use serde::{Deserialize, Serialize};
1111use time::OffsetDateTime;
@@ -88,71 +88,52 @@
8888
8989pub type MemberPage = export::pagination::Page<MemberDto>;
9090
91impl SpaceService {
92 pub async fn add_member(
93 &self,
94 caller: Uuid,
95 space_name: &str,
96 cmd: AddMemberCmd,
97 ) -> Result<MemberDto, SpaceError> {
98 let space = lookup_space_by_name(self, space_name).await?;
99 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
100
101 let target_user = user::Entity::find_by_id(cmd.user_id)
102 .one(self.db().read())
103 .await?
104 .ok_or(SpaceError::NotFound)?;
105
106 assert_not_banned(self.db().read(), space.id, cmd.user_id).await?;
107
108 if space.member_must_enable_2fa && !target_user.totp_enabled {
109 return Err(SpaceError::Forbidden);
110 }
91/// Presentation/notification values a member row is created with when the
92/// caller does not sit in the admin "add member" flow (join-request approval,
93/// invite acceptance).
94#[derive(Debug, Clone, Default)]
95pub struct MemberActivationDefaults {
96 pub alias_name: Option<String>,
97 pub alias_avatar_url: Option<String>,
98 pub message_notifications: Option<MessageNotifications>,
99 pub muted: Option<bool>,
100 pub mute_until: Option<OffsetDateTime>,
101 pub mobile_push: Option<bool>,
102 pub suppress_everyone: Option<bool>,
103 pub suppress_roles: Option<bool>,
104 pub hide_muted_channels: Option<bool>,
105}
111106
112 if let Some(alias) = &cmd.alias_name
113 && alias.chars().count() > self.config().space.max_alias_name_len
114 {
115 return Err(SpaceError::BadRequest("alias name is too long".to_string()));
116 }
117
118 let existing = space_members::Entity::find_by_id((space.id, cmd.user_id))
119 .one(self.db().read())
120 .await?;
121
122 let now = OffsetDateTime::now_utc();
123
124 let saved: space_members::Model = if let Some(member) = existing {
125 if member.status == MemberStatus::Active {
126 return Err(SpaceError::Conflict(
127 "user is already a member of this space".to_string(),
128 ));
129 }
130
107/// Revive a left/removed member row or insert a fresh one, bumping
108/// `nums_members` exactly once. Returns `false` when the row was already
109/// active, so a racing caller can settle for the existing membership instead
110/// of double-counting. Callers own the transaction and every permission, ban
111/// and 2FA check.
112pub async fn activate_member_row<C: ConnectionTrait>(
113 txn: &C,
114 space_id: Uuid,
115 user_id: Uuid,
116 now: OffsetDateTime,
117 defaults: MemberActivationDefaults,
118) -> Result<bool, SpaceError> {
119 let existing = space_members::Entity::find_by_id((space_id, user_id))
120 .one(txn)
121 .await?;
122 match existing {
123 Some(member) if member.status == MemberStatus::Active => return Ok(false),
124 Some(member) => {
131125 let mut am: space_members::ActiveModel = member.into();
132126 am.status = Set(MemberStatus::Active);
133127 am.left_at = Set(None);
134128 am.updated_at = Set(Some(now));
135
136 self.db()
137 .primary()
138 .transaction::<_, space_members::Model, SpaceError>(|txn| {
139 Box::pin(async move {
140 let m = am.update(txn).await?;
141 spaces::Entity::update_many()
142 .filter(spaces::Column::Id.eq(space.id))
143 .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1"))
144 .exec(txn)
145 .await?;
146 Ok(m)
147 })
148 })
149 .await?
150 } else {
129 am.update(txn).await?;
130 }
131 None => {
151132 let member_row = space_members::ActiveModel {
152 space_id: Set(space.id),
153 user_id: Set(cmd.user_id),
154 alias_name: Set(cmd.alias_name),
155 alias_avatar_url: Set(cmd.alias_avatar_url),
133 space_id: Set(space_id),
134 user_id: Set(user_id),
135 alias_name: Set(defaults.alias_name),
136 alias_avatar_url: Set(defaults.alias_avatar_url),
156137 status: Set(MemberStatus::Active),
157138 last_active_at: Set(None),
158139 left_at: Set(None),
@@ -161,46 +142,48 @@
161142 joined_at: Set(now),
162143 created_at: Set(now),
163144 updated_at: Set(Some(now)),
164 message_notifications: Set(cmd
145 message_notifications: Set(defaults
165146 .message_notifications
166147 .unwrap_or(MessageNotifications::All)),
167 muted: Set(cmd.muted.unwrap_or(false)),
168 mute_until: Set(cmd.mute_until),
169 mobile_push: Set(cmd.mobile_push.unwrap_or(false)),
170 suppress_everyone: Set(cmd.suppress_everyone.unwrap_or(false)),
171 suppress_roles: Set(cmd.suppress_roles.unwrap_or(false)),
172 hide_muted_channels: Set(cmd.hide_muted_channels.unwrap_or(false)),
148 muted: Set(defaults.muted.unwrap_or(false)),
149 mute_until: Set(defaults.mute_until),
150 mobile_push: Set(defaults.mobile_push.unwrap_or(false)),
151 suppress_everyone: Set(defaults.suppress_everyone.unwrap_or(false)),
152 suppress_roles: Set(defaults.suppress_roles.unwrap_or(false)),
153 hide_muted_channels: Set(defaults.hide_muted_channels.unwrap_or(false)),
173154 };
155 member_row.insert(txn).await?;
156 }
157 }
174158
175 self.db()
176 .primary()
177 .transaction::<_, space_members::Model, SpaceError>(|txn| {
178 Box::pin(async move {
179 let m = member_row.insert(txn).await?;
180 spaces::Entity::update_many()
181 .filter(spaces::Column::Id.eq(space.id))
182 .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1"))
183 .exec(txn)
184 .await?;
185 Ok(m)
186 })
187 })
188 .await?
189 };
159 spaces::Entity::update_many()
160 .filter(spaces::Column::Id.eq(space_id))
161 .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1"))
162 .exec(txn)
163 .await?;
164 Ok(true)
165}
190166
167impl SpaceService {
168 /// Auto-join every active public room of a space. Room rows are advisory:
169 /// a failure is logged and the membership write still stands.
170 pub(crate) async fn grant_public_room_memberships(&self, space_id: Uuid, user_id: Uuid) {
191171 let public_rooms = models::room::rooms::Entity::find()
192 .filter(models::room::rooms::Column::SpaceId.eq(space.id))
172 .filter(models::room::rooms::Column::SpaceId.eq(space_id))
193173 .filter(models::room::rooms::Column::IsPrivate.eq(false))
194174 .filter(models::room::rooms::Column::IsActive.eq(true))
195175 .all(self.db().read())
196 .await?;
197
176 .await;
177 let Ok(public_rooms) = public_rooms else {
178 tracing::warn!(%space_id, %user_id, "public room lookup failed");
179 return;
180 };
198181 for room in &public_rooms {
199182 let now = OffsetDateTime::now_utc();
200183 let rm = models::room::room_members::ActiveModel {
201184 role: Set(models::room::room_members::RoomMemberRole::Member),
202185 room_id: Set(room.id),
203 user_id: Set(saved.user_id),
186 user_id: Set(user_id),
204187 nick: Set(None),
205188 joined_at: Set(now),
206189 left_at: Set(None),
@@ -208,13 +191,76 @@
208191 updated_at: Set(Some(now)),
209192 };
210193 if let Err(e) = rm.insert(self.db().write()).await {
211 tracing::warn!(error = %e, room_id = %room.id, user_id = %saved.user_id, "failed to create room_members record");
194 tracing::warn!(error = %e, room_id = %room.id, %user_id, "failed to create room_members record");
212195 }
213196 }
197 }
214198
215 let user_map = self.load_user_briefs(&[saved.user_id]).await?;
216 let user_brief = user_map.get(&saved.user_id).cloned();
199 pub async fn add_member(
200 &self,
201 caller: Uuid,
202 space_name: &str,
203 cmd: AddMemberCmd,
204 ) -> Result<MemberDto, SpaceError> {
205 let space = lookup_space_by_name(self, space_name).await?;
206 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
217207
208 let target_user = user::Entity::find_by_id(cmd.user_id)
209 .one(self.db().read())
210 .await?
211 .ok_or(SpaceError::NotFound)?;
212
213 assert_not_banned(self.db().read(), space.id, cmd.user_id).await?;
214
215 if space.member_must_enable_2fa && !target_user.totp_enabled {
216 return Err(SpaceError::Forbidden);
217 }
218
219 if let Some(alias) = &cmd.alias_name
220 && alias.chars().count() > self.config().space.max_alias_name_len
221 {
222 return Err(SpaceError::BadRequest("alias name is too long".to_string()));
223 }
224
225 let now = OffsetDateTime::now_utc();
226 let space_id = space.id;
227 let user_id = cmd.user_id;
228 let defaults = MemberActivationDefaults {
229 alias_name: cmd.alias_name,
230 alias_avatar_url: cmd.alias_avatar_url,
231 message_notifications: cmd.message_notifications,
232 muted: cmd.muted,
233 mute_until: cmd.mute_until,
234 mobile_push: cmd.mobile_push,
235 suppress_everyone: cmd.suppress_everyone,
236 suppress_roles: cmd.suppress_roles,
237 hide_muted_channels: cmd.hide_muted_channels,
238 };
239
240 let activated = self
241 .db()
242 .primary()
243 .transaction::<_, bool, SpaceError>(move |txn| {
244 Box::pin(
245 async move { activate_member_row(txn, space_id, user_id, now, defaults).await },
246 )
247 })
248 .await?;
249 if !activated {
250 return Err(SpaceError::Conflict(
251 "user is already a member of this space".to_string(),
252 ));
253 }
254
255 self.grant_public_room_memberships(space_id, user_id).await;
256
257 let saved = space_members::Entity::find_by_id((space_id, user_id))
258 .one(self.db().read())
259 .await?
260 .ok_or(SpaceError::NotFound)?;
261 let user_map = self.load_user_briefs(&[user_id]).await?;
262 let user_brief = user_map.get(&user_id).cloned();
263
218264 Ok(MemberDto {
219265 space_id: saved.space_id,
220266 user: user_brief,
@@ -237,7 +283,6 @@
237283 hide_muted_channels: saved.hide_muted_channels,
238284 })
239285 }
240
241286 pub async fn get_member(
242287 &self,
243288 caller: Uuid,
Modified lib/space/src/services/mod.rs lib/space/src/services/mod.rs +1 −0 View lib/space/src/services/mod.rs
@@ -3,6 +3,7 @@
33pub mod groups;
44pub mod images;
55pub mod invites;
6pub mod join_requests;
67pub mod join_rules;
78pub mod members;
89pub mod rooms;
Modified lib/space/src/services/spaces.rs lib/space/src/services/spaces.rs +17 −0 View lib/space/src/services/spaces.rs
@@ -22,6 +22,13 @@
2222 true
2323}
2424
25/// Slugs reserved by frontend routes; a space may not claim them.
26pub const RESERVED_SPACE_NAMES: [&str; 1] = ["explore"];
27
28fn is_reserved_lower_name(name: &str) -> bool {
29 RESERVED_SPACE_NAMES.contains(&name)
30}
31
2532fn is_valid_lower_name(name: &str) -> bool {
2633 !name.is_empty()
2734 && name
@@ -189,6 +196,11 @@
189196 .to_string(),
190197 ));
191198 }
199 if is_reserved_lower_name(&lower) {
200 return Err(SpaceError::Conflict(format!(
201 "space name {lower} is reserved"
202 )));
203 }
192204
193205 let existing = spaces::Entity::find()
194206 .filter(spaces::Column::LowerName.eq(&lower))
@@ -378,6 +390,11 @@
378390 .to_string(),
379391 ));
380392 }
393 if is_reserved_lower_name(&lower) {
394 return Err(SpaceError::Conflict(format!(
395 "space name {lower} is reserved"
396 )));
397 }
381398 let existing = spaces::Entity::find()
382399 .filter(spaces::Column::LowerName.eq(&lower))
383400 .filter(spaces::Column::Id.ne(am.id.clone().unwrap()))
Modified lib/space/src/errors.rs lib/space/src/errors.rs +13 −0 View lib/space/src/errors.rs
@@ -7,6 +7,9 @@
77 BadRequest(String),
88 Unauthorized,
99 Forbidden,
10 JoinNotAllowed(String),
11 JoinRequirementNotMet(String),
12 JoinRequestCooldown(String),
1013 NotFound,
1114 Conflict(String),
1215 RateLimited(String),
@@ -19,6 +22,9 @@
1922 Self::BadRequest(message) => write!(f, "{message}"),
2023 Self::Unauthorized => write!(f, "unauthorized"),
2124 Self::Forbidden => write!(f, "forbidden"),
25 Self::JoinNotAllowed(message) => write!(f, "{message}"),
26 Self::JoinRequirementNotMet(message) => write!(f, "{message}"),
27 Self::JoinRequestCooldown(message) => write!(f, "{message}"),
2228 Self::NotFound => write!(f, "not found"),
2329 Self::Conflict(message) => write!(f, "{message}"),
2430 Self::RateLimited(message) => write!(f, "{message}"),
@@ -53,6 +59,13 @@
5359 SpaceError::BadRequest(msg) => Self::bad_request(msg),
5460 SpaceError::Unauthorized => Self::unauthorized(),
5561 SpaceError::Forbidden => Self::forbidden(),
62 SpaceError::JoinNotAllowed(msg) => Self::forbidden_with_code("join_not_allowed", msg),
63 SpaceError::JoinRequirementNotMet(msg) => {
64 Self::forbidden_with_code("join_requirement_not_met", msg)
65 }
66 SpaceError::JoinRequestCooldown(msg) => {
67 Self::forbidden_with_code("join_request_cooldown", msg)
68 }
5669 SpaceError::NotFound => Self::not_found("space resource not found"),
5770 SpaceError::Conflict(msg) => Self::conflict(msg),
5871 SpaceError::RateLimited(msg) => Self::rate_limited(msg),
Modified lib/space/src/openapi.rs lib/space/src/openapi.rs +17 −0 View lib/space/src/openapi.rs
@@ -14,6 +14,12 @@
1414 crate::handlers::delete_space::delete_space,
1515 crate::handlers::upload_space_image::upload_space_image,
1616 crate::handlers::delete_space_image::delete_space_image,
17 crate::handlers::explore_spaces::explore_spaces,
18 crate::handlers::explore_spaces::list_join_requests,
19 crate::handlers::explore_spaces::approve_join_request,
20 crate::handlers::explore_spaces::reject_join_request,
21 crate::handlers::join_requests::create_join_request,
22 crate::handlers::join_requests::withdraw_join_request,
1723 crate::handlers::create_dir_node::create_dir_node,
1824 crate::handlers::get_space_dir_tree::get_space_dir_tree,
1925 crate::handlers::repair_dir_parents::repair_dir_parents,
@@ -59,6 +65,9 @@
5965 schemas(
6066 AppResponseData<crate::services::spaces::SpaceDto>,
6167 AppResponseData<crate::services::spaces::SpacePage>,
68 AppResponseData<crate::services::join_requests::ExploreSpacePage>,
69 AppResponseData<crate::services::join_requests::JoinRequestDto>,
70 AppResponseData<crate::services::join_requests::JoinRequestPage>,
6271 AppResponseData<crate::services::groups::GroupDto>,
6372 AppResponseData<crate::services::groups::GroupPage>,
6473 AppResponseData<crate::services::members::MemberDto>,
@@ -77,6 +86,14 @@
7786 AppResponseData<crate::services::dir::UserOverwriteDto>,
7887 AppResponseData<Vec<crate::services::dir::UserOverwriteDto>>,
7988 crate::services::spaces::SpaceDto,
89 crate::services::join_requests::ExploreSpaceDto,
90 crate::services::join_requests::ExploreSpacePage,
91 crate::services::join_requests::ExploreViewerDto,
92 crate::services::join_requests::ExploreViewerRequestDto,
93 crate::services::join_requests::JoinRequestDto,
94 crate::services::join_requests::JoinRequestPage,
95 crate::services::join_requests::CreateJoinRequestCmd,
96 models::space::space_join_request::SpaceJoinRequestStatus,
8097 export::pagination::Page<crate::services::spaces::SpaceDto>,
8198 crate::services::spaces::CreateSpaceCmd,
8299 crate::services::spaces::UpdateSpaceCmd,
Modified lib/space/src/routes.rs lib/space/src/routes.rs +22 −0 View lib/space/src/routes.rs
@@ -14,6 +14,10 @@
1414use crate::handlers::delete_join_rule::delete_join_rule;
1515use crate::handlers::delete_space::delete_space;
1616use crate::handlers::delete_space_image::delete_space_image;
17use crate::handlers::explore_spaces::explore_spaces;
18use crate::handlers::explore_spaces::{
19 approve_join_request, list_join_requests, reject_join_request,
20};
1721use crate::handlers::get_ban::get_ban;
1822use crate::handlers::get_group::get_group;
1923use crate::handlers::get_member::get_member;
@@ -22,6 +26,7 @@
2226use crate::handlers::get_space_image::get_space_image;
2327use crate::handlers::get_space_image_file::get_space_image_file;
2428use crate::handlers::join_group_self::join_group_self;
29use crate::handlers::join_requests::{create_join_request, withdraw_join_request};
2530use crate::handlers::leave_group_self::leave_group_self;
2631use crate::handlers::list_bans::list_bans;
2732use crate::handlers::list_dir_group_overwrites::list_dir_group_overwrites;
@@ -54,6 +59,7 @@
5459
5560pub fn router(service: SpaceService) -> Router<SpaceService> {
5661 let public: Router<SpaceService> = Router::new()
62 .route("/spaces/explore", get(explore_spaces))
5763 .route("/spaces/{space_name}", get(get_space))
5864 .route("/spaces/{space_name}/groups/{group_id}", get(get_group))
5965 .route("/spaces/{space_name}/images/{kind}", get(get_space_image))
@@ -149,6 +155,22 @@
149155 delete(unassign_group),
150156 )
151157 .route(
158 "/spaces/{space_name}/join-requests",
159 post(create_join_request).get(list_join_requests),
160 )
161 .route(
162 "/spaces/{space_name}/join-requests/{request_id}",
163 delete(withdraw_join_request),
164 )
165 .route(
166 "/spaces/{space_name}/join-requests/{request_id}/approve",
167 post(approve_join_request),
168 )
169 .route(
170 "/spaces/{space_name}/join-requests/{request_id}/reject",
171 post(reject_join_request),
172 )
173 .route(
152174 "/spaces/{space_name}/invites",
153175 post(create_invite).get(list_invites),
154176 )
Modified web/src/app/routes/router.tsx web/src/app/routes/router.tsx +1 −0 View web/src/app/routes/router.tsx
@@ -15,6 +15,7 @@
1515export const router = createBrowserRouter([
1616 { path: '/', element: <Navigate to="/app" replace /> },
1717 { path: '/app', element: <Navigate to="/app/@me" replace /> },
18 { path: '/app/explore', element: <WorkspaceShell /> },
1819 { path: '/app/@me', element: <WorkspaceShell /> },
1920 { path: '/app/@me/notify', element: <WorkspaceShell /> },
2021 { path: '/app/@me/chat/:roomId?', element: <WorkspaceShell /> },
Modified web/src/app/styles/workspace.css web/src/app/styles/workspace.css +9 −0 View web/src/app/styles/workspace.css
@@ -35,6 +35,13 @@
3535.workspace-collapse { width: 28px; height: 28px; }
3636.workspace-expand { position: absolute; z-index: 15; top: 20px; left: 14px; background: var(--shell-field); opacity: 0; visibility: hidden; transform: translateX(-8px) scale(.94); transition: opacity 120ms ease, transform 180ms ease, background 160ms, color 160ms, visibility 0s linear 120ms; }
3737.navigation-collapsed .workspace-expand { opacity: 1; visibility: visible; transform: none; transition: opacity 180ms ease calc(var(--nav-motion) * .5), transform 300ms var(--nav-ease) calc(var(--nav-motion) * .45), background 160ms, color 160ms, visibility 0s; }
38/*
39 * Directory column (explore): the rail is the whole navigation, so the space
40 * sidebar is dropped and the navigation column narrows to the rail width. The
41 * mobile drawer keeps the same rule; there is nothing behind it to open.
42 */
43.rail-only .workspace-navigation { width: var(--nav-rail); }
44.rail-only .workspace-sidebar { display: none; }
3845.navigation-collapsed .workspace-navigation { width: var(--nav-rail); }
3946.navigation-collapsed .workspace-sidebar { opacity: 0; visibility: hidden; transition: opacity calc(var(--nav-motion) * .45) ease, visibility 0s linear calc(var(--nav-motion) * .45); }
4047/*
@@ -174,6 +181,8 @@
174181 .workspace-navigation, .navigation-collapsed .workspace-navigation { position: fixed; inset: 0 auto 0 0; z-index: 45; width: min(338px, calc(100vw - 44px)); visibility: hidden; transform: translateX(-100%); transition: transform var(--nav-motion) var(--nav-ease), visibility 0s linear var(--nav-motion); }
175182 .navigation-open .workspace-navigation { visibility: visible; transform: translateX(0); box-shadow: 20px 0 60px rgb(15 17 19 / 18%); transition-delay: 0s, 0s; }
176183 .workspace-sidebar, .navigation-collapsed .workspace-sidebar { display: flex; flex: 1; min-width: 0; width: auto; opacity: 1; visibility: visible; }
184 .rail-only .workspace-sidebar, .rail-only .navigation-collapsed .workspace-sidebar { display: none; }
185 .rail-only .workspace-navigation, .rail-only.navigation-open .workspace-navigation { width: 64px; }
177186 .workspace-sidebar-header { padding: 14px; }
178187 .workspace-rail { width: 64px; }
179188 .workspace-drawer-close, .workspace-scrim { display: flex; }
Modified web/src/features/chat/ui/chat-room.tsx web/src/features/chat/ui/chat-room.tsx +1 −0 View web/src/features/chat/ui/chat-room.tsx
@@ -163,6 +163,7 @@
163163 setVersion(value => value + 1)
164164 }
165165 if (kind === 'member_joined' || kind === 'member_left') setVersion(value => value + 1)
166 if (kind === 'voice_presence' || kind === 'voice_moderation') setVersion(value => value + 1)
166167 },
167168 })
168169
Modified web/src/features/notifications/model/target.ts web/src/features/notifications/model/target.ts +14 −0 View web/src/features/notifications/model/target.ts
@@ -29,6 +29,9 @@
2929
3030export interface NotificationPayload {
3131 spaceName?: string
32 requestId?: string
33 applicantId?: string
34 kind?: string
3235 boardId?: string
3336 issueNumber?: string
3437 roomId?: string
@@ -60,6 +63,9 @@
6063 messageId: str(source, 'message_id'),
6164 inviteId: str(source, 'invite_id'),
6265 acceptCode: str(source, 'accept_code'),
66 requestId: str(source, 'request_id'),
67 applicantId: str(source, 'applicant_id'),
68 kind: str(source, 'kind'),
6369 }
6470}
6571
@@ -83,6 +89,14 @@
8389 */
8490export function notificationTarget(row: NotificationLike): NotificationTarget | null {
8591 const payload = readPayload(row)
92 // A join request asks a space manager for a decision, so it lands on the
93 // settings section that can make it rather than on the space's chat column.
94 if (payload.kind === 'join_request_submitted' && payload.spaceName) {
95 return {
96 href: `/app/${scope(payload.spaceName)}?settings=join-requests`,
97 label: t('notifications.target.joinRequests'),
98 }
99 }
86100 // An issue row opens the ticket itself, not the board's column list.
87101 if (payload.spaceName && payload.boardId && payload.issueNumber) {
88102 return {
Added web/src/features/space-discovery/model/api.ts web/src/features/space-discovery/model/api.ts +94 −0 View web/src/features/space-discovery/model/api.ts
@@ -0,0 +1,94 @@
1/**
2 * Explore API: the public space directory plus the applicant side of the
3 * join-request flow (submit / withdraw). The approval side lives in
4 * features/space-management.
5 *
6 * The generated client collapses `Page<ExploreSpaceDto>` into one untyped
7 * `Page` schema (same as every other paged space list), and the flattened
8 * document keeps `ExploreSpaceDto` out of `globals.d`, so the row shape is
9 * declared here instead.
10 */
11import Apis from '@/shared/api'
12import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
13import type {
14 AppResponseData_JoinRequestDto,
15 AllowJoinLevel,
16 CreateJoinRequestCmd,
17 SpaceJoinRequestStatus,
18} from '@/shared/api'
19
20export type { AllowJoinLevel, CreateJoinRequestCmd, SpaceJoinRequestStatus }
21
22/** One viewer-scoped request row, as the directory embeds it. */
23export interface ExploreViewerRequest {
24 id: string
25 status: SpaceJoinRequestStatus
26 created_at: string
27 decided_at?: string | null
28 cooldown_until?: string | null
29}
30
31export interface ExploreViewer {
32 is_member: boolean
33 request?: ExploreViewerRequest | null
34}
35
36/** One public space card row (`GET /spaces/explore`). */
37export interface ExploreSpace {
38 id: string
39 lower_name: string
40 name: string
41 description?: string | null
42 avatar_url?: string | null
43 banner_url?: string | null
44 members_count: number
45 allow_join: boolean
46 allow_join_level: AllowJoinLevel
47 created_at: string
48 viewer?: ExploreViewer | null
49}
50
51export interface ExplorePage {
52 items: ExploreSpace[]
53 total: number
54 limit: number
55 offset: number
56}
57
58export type JoinRequest = NonNullable<AppResponseData_JoinRequestDto['data']>
59
60export interface ExploreQuery {
61 q?: string
62 sort?: 'members' | 'newest'
63 limit?: number
64 offset?: number
65}
66
67/**
68 * Unset filters are omitted rather than sent as `null`: Alova serialises a
69 * `null` query value to the literal string `"null"`, which the backend's
70 * `Option<...>` parsers reject with a 400.
71 */
72export const exploreSpaces = (query: ExploreQuery) =>
73 unwrapApi<ExplorePage | null>(
74 Apis.space.explore_spaces({
75 params: {
76 ...(query.q ? { q: query.q } : {}),
77 ...(query.sort ? { sort: query.sort } : {}),
78 ...(query.limit === undefined ? {} : { limit: query.limit }),
79 ...(query.offset === undefined ? {} : { offset: query.offset }),
80 },
81 }) as unknown as PromiseLike<ApiEnvelope<ExplorePage | null>>,
82 ).then(page => page ?? { items: [], total: 0, limit: 0, offset: 0 })
83
84export const submitJoinRequest = (spaceName: string, cmd: CreateJoinRequestCmd) =>
85 unwrapApi<JoinRequest>(
86 Apis.space.create_join_request({ pathParams: { space_name: spaceName }, data: cmd }),
87 )
88
89export const withdrawJoinRequest = (spaceName: string, requestId: string) =>
90 unwrapApi<null>(
91 Apis.space.withdraw_join_request({
92 pathParams: { space_name: spaceName, request_id: requestId },
93 }),
94 )
Added web/src/features/space-discovery/ui/explore-view.tsx web/src/features/space-discovery/ui/explore-view.tsx +378 −0 View web/src/features/space-discovery/ui/explore-view.tsx
@@ -0,0 +1,378 @@
1import { useEffect, useState } from 'react'
2import { useNavigate } from 'react-router-dom'
3import { Compass, Loader2, Search, Users } from 'lucide-react'
4import { Alert, Button, EmptyNote, Modal, ShellTopBar, errText, fmtDate } from '@/shared/ui'
5import { ApiFailure, useAsync } from '@/shared/lib'
6import { useT, type MessageKey } from '@/shared/i18n'
7import {
8 exploreSpaces,
9 submitJoinRequest,
10 withdrawJoinRequest,
11 type AllowJoinLevel,
12 type ExploreSpace,
13} from '../model/api'
14
15/** Card-header gradients for spaces without a banner; the index is stable per id. */
16const BANNER_GRADIENTS = [
17 'linear-gradient(135deg, #4f7d3a 0%, #2f4f2a 100%)',
18 'linear-gradient(135deg, #3a6b8f 0%, #233f57 100%)',
19 'linear-gradient(135deg, #8f5a3a 0%, #573323 100%)',
20 'linear-gradient(135deg, #6b3a8f 0%, #3f2357 100%)',
21 'linear-gradient(135deg, #8f3a5a 0%, #572337 100%)',
22 'linear-gradient(135deg, #3a8f7a 0%, #235749 100%)',
23 'linear-gradient(135deg, #7a8f3a 0%, #4a5723 100%)',
24 'linear-gradient(135deg, #5a5f8f 0%, #333657 100%)',
25]
26
27/** Re-render clock for time-derived affordances (join-request cooldown). */
28function useNow(intervalMs = 30_000): number {
29 const [now, setNow] = useState(0)
30 useEffect(() => {
31 const tick = () => setNow(Date.now())
32 tick()
33 const timer = window.setInterval(tick, intervalMs)
34 return () => window.clearInterval(timer)
35 }, [intervalMs])
36 return now
37}
38function bannerGradient(id: string): string {
39 let hash = 0
40 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0
41 return BANNER_GRADIENTS[Math.abs(hash) % BANNER_GRADIENTS.length]
42}
43
44const JOIN_LEVEL_KEYS: Record<AllowJoinLevel, MessageKey> = {
45 None: 'space.discover.joinLevel.none',
46 NeedMailActive: 'space.joinLevel.mail',
47 NeedMailActiveAndLastOneMid: 'space.joinLevel.mailDay',
48 NeedMailActiveAndLastOneHours: 'space.joinLevel.mailHour',
49 NeedMailActiveAndPhoneActive: 'space.joinLevel.mailPhone',
50}
51
52const PAGE_SIZE = 24
53
54export interface ExploreViewProps {
55 /** Opens the create-space modal owned by the shell. */
56 onAddSpace: () => void
57}
58
59/**
60 * The public directory (`/app/explore`). It is deliberately outside the
61 * space-scoped shell data: the rows come from one public endpoint and every
62 * card either walks into a space or files a join request, so no sidebar list
63 * or room prefetch applies.
64 */
65export function ExploreView({ onAddSpace }: ExploreViewProps) {
66 const { t } = useT()
67 const navigate = useNavigate()
68 const [query, setQuery] = useState('')
69 const [debounced, setDebounced] = useState('')
70 const [sort, setSort] = useState<'members' | 'newest'>('members')
71 const [limit, setLimit] = useState(PAGE_SIZE)
72 const [reload, setReload] = useState(0)
73 const [busyId, setBusyId] = useState<string | null>(null)
74 const [actionError, setActionError] = useState('')
75 const [applying, setApplying] = useState<ExploreSpace | null>(null)
76 const [note, setNote] = useState('')
77 const [submitting, setSubmitting] = useState(false)
78
79 // Search is debounced so a typing burst issues one request, and a new query
80 // restarts pagination: the previous page count belongs to the previous list.
81 useEffect(() => {
82 const timer = window.setTimeout(() => {
83 setDebounced(query.trim())
84 setLimit(PAGE_SIZE)
85 }, 250)
86 return () => window.clearTimeout(timer)
87 }, [query])
88
89 const page = useAsync(
90 () => exploreSpaces({ q: debounced || undefined, sort, limit, offset: 0 }),
91 [debounced, sort, limit, reload],
92 )
93 const rows = page.data?.items ?? []
94 const total = page.data?.total ?? 0
95
96 const openSpace = (space: ExploreSpace) => {
97 navigate(`/app/${encodeURIComponent(space.name)}`)
98 }
99
100 const withdraw = async (space: ExploreSpace) => {
101 const request = space.viewer?.request
102 if (!request) return
103 setBusyId(space.id)
104 setActionError('')
105 try {
106 await withdrawJoinRequest(space.name, request.id)
107 setReload(key => key + 1)
108 } catch (error) {
109 setActionError(errText(error))
110 } finally {
111 setBusyId(null)
112 }
113 }
114
115 const submit = async () => {
116 if (!applying || submitting) return
117 setSubmitting(true)
118 setActionError('')
119 try {
120 await submitJoinRequest(applying.name, { note: note.trim() || null })
121 setApplying(null)
122 setNote('')
123 setReload(key => key + 1)
124 } catch (error) {
125 // The stable codes distinguish "wait" from "not eligible" from
126 // "closed"; anything else is surfaced verbatim.
127 const message =
128 error instanceof ApiFailure && error.errorCode === 'join_request_cooldown'
129 ? t('space.discover.cooldownError')
130 : error instanceof ApiFailure && error.errorCode === 'join_requirement_not_met'
131 ? t('space.discover.requirementError')
132 : error instanceof ApiFailure && error.errorCode === 'join_not_allowed'
133 ? t('space.discover.notAllowedError')
134 : errText(error)
135 setActionError(message)
136 } finally {
137 setSubmitting(false)
138 }
139 }
140
141 return (
142 <>
143 <ShellTopBar
144 icon={<Compass className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
145 title={t('space.discover.title')}
146 topic={t('space.discover.sub')}
147 />
148 <div className="flex min-h-0 flex-1 flex-col">
149 <div className="flex flex-wrap items-center gap-3 border-b border-shell-hairline px-6 py-3">
150 <div className="relative min-w-[220px] flex-1 md:max-w-[420px]">
151 <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-shell-ink-muted" aria-hidden />
152 <input
153 value={query}
154 onChange={event => setQuery(event.target.value)}
155 placeholder={t('space.discover.search')}
156 aria-label={t('space.discover.search')}
157 className="h-10 w-full rounded-field border border-shell-hairline bg-shell-field pl-9 pr-3 text-[13.5px] outline-none placeholder:text-shell-ink-muted focus:border-shell-ink"
158 />
159 </div>
160 <div className="flex items-center gap-1 rounded-field bg-shell-field p-1" role="group" aria-label={t('space.discover.title')}>
161 {(['members', 'newest'] as const).map(value => (
162 <button
163 key={value}
164 type="button"
165 aria-pressed={sort === value}
166 onClick={() => {
167 setSort(value)
168 setLimit(PAGE_SIZE)
169 }}
170 className={`rounded-[8px] px-3 py-1.5 text-[12.5px] transition-colors ${
171 sort === value ? 'bg-shell-surface font-semibold shadow-sm' : 'text-shell-ink-muted hover:text-shell-ink'
172 }`}
173 >
174 {t(value === 'members' ? 'space.discover.sort.members' : 'space.discover.sort.newest')}
175 </button>
176 ))}
177 </div>
178 <span className="text-[12.5px] text-shell-ink-muted">
179 {t('space.discover.resultCount', { count: total })}
180 </span>
181 <Button variant="secondary" className="ml-auto h-9 text-[12.5px]" onClick={onAddSpace}>
182 {t('shell.rail.addSpace')}
183 </Button>
184 </div>
185
186 {actionError ? (
187 <div className="px-6 pt-4">
188 <Alert tone="error">{actionError}</Alert>
189 </div>
190 ) : null}
191
192 <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
193 {page.loading && !page.data ? (
194 <div className="flex items-center justify-center gap-2 py-16 text-[13px] text-shell-ink-muted">
195 <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
196 {t('space.discover.loading')}
197 </div>
198 ) : page.error && !page.data ? (
199 <div className="py-16 text-center">
200 <EmptyNote>{t('space.discover.loadFailed')}</EmptyNote>
201 <Button variant="secondary" className="mt-3 h-9 text-[12.5px]" onClick={() => setReload(key => key + 1)}>
202 {t('common.action.retry')}
203 </Button>
204 </div>
205 ) : rows.length === 0 ? (
206 <div className="py-16 text-center">
207 <EmptyNote>{t('space.discover.empty')}</EmptyNote>
208 <p className="mt-1 text-[12.5px] text-shell-ink-muted">{t('space.discover.emptyHint')}</p>
209 </div>
210 ) : (
211 <>
212 <ul className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
213 {rows.map(space => (
214 <SpaceCard
215 key={space.id}
216 space={space}
217 busy={busyId === space.id}
218 onOpen={() => openSpace(space)}
219 onApply={() => {
220 setActionError('')
221 setNote('')
222 setApplying(space)
223 }}
224 onWithdraw={() => void withdraw(space)}
225 />
226 ))}
227 </ul>
228 {rows.length < total ? (
229 <div className="mt-6 flex justify-center">
230 <Button
231 variant="secondary"
232 className="h-9 text-[12.5px]"
233 disabled={page.loading}
234 onClick={() => setLimit(current => current + PAGE_SIZE)}
235 >
236 {page.loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
237 {t('space.discover.loadMore')}
238 </Button>
239 </div>
240 ) : null}
241 </>
242 )}
243 </div>
244 </div>
245
246 <Modal
247 title={applying ? t('space.discover.dialogTitle', { space: applying.name }) : ''}
248 description={t('space.discover.noteHint')}
249 open={Boolean(applying)}
250 onClose={() => {
251 if (submitting) return
252 setApplying(null)
253 }}
254 width="max-w-[520px]"
255 footer={
256 <>
257 <Button variant="ghost" className="h-9 text-[12.5px]" disabled={submitting} onClick={() => setApplying(null)}>
258 {t('common.action.cancel')}
259 </Button>
260 <Button className="h-9 text-[12.5px]" disabled={submitting} onClick={() => void submit()}>
261 {submitting ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
262 {t('space.discover.submit')}
263 </Button>
264 </>
265 }
266 >
267 <label className="block">
268 <span className="mb-1.5 block font-mono text-[10px] font-bold uppercase tracking-[1.2px] text-shell-ink-muted">
269 {t('space.discover.note')}
270 </span>
271 <textarea
272 value={note}
273 maxLength={512}
274 rows={4}
275 placeholder={t('space.discover.notePlaceholder')}
276 onChange={event => setNote(event.target.value)}
277 className="w-full resize-none rounded-field border border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] outline-none placeholder:text-shell-ink-muted focus:border-shell-ink"
278 />
279 </label>
280 </Modal>
281 </>
282 )
283}
284
285function SpaceCard({
286 space,
287 busy,
288 onOpen,
289 onApply,
290 onWithdraw,
291}: {
292 space: ExploreSpace
293 busy: boolean
294 onOpen: () => void
295 onApply: () => void
296 onWithdraw: () => void
297}) {
298 const { t } = useT()
299 const [bannerFailed, setBannerFailed] = useState(false)
300 const request = space.viewer?.request
301 const isMember = space.viewer?.is_member ?? false
302 const now = useNow()
303 // A rejected request only blocks until its cooldown lapses; the server is
304 // authoritative, so the card mirrors the timestamp until it passes, and the
305 // clock ticks so a lapsed cooldown stops reading as "cooling down".
306 const until =
307 request?.status === 'rejected' && request.cooldown_until
308 ? new Date(request.cooldown_until).getTime()
309 : Number.NaN
310 const cooldownUntil = Number.isFinite(until) && until > now ? until : null
311 const banner = !bannerFailed ? space.banner_url : null
312
313 const action = isMember ? (
314 <Button className="h-8 flex-1 text-[12.5px]" onClick={onOpen}>
315 {t('space.discover.enter')}
316 </Button>
317 ) : request?.status === 'pending' ? (
318 <>
319 <span className="flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-[12.5px] text-shell-ink-muted">
320 {t('space.discover.pending')}
321 </span>
322 <Button variant="secondary" className="h-8 text-[12.5px]" disabled={busy} onClick={onWithdraw}>
323 {t('space.discover.withdraw')}
324 </Button>
325 </>
326 ) : cooldownUntil ? (
327 <span className="flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-[12.5px] text-shell-ink-muted">
328 {t('space.discover.cooldownUntil', { time: fmtDate(new Date(cooldownUntil).toISOString()) })}
329 </span>
330 ) : !space.allow_join ? (
331 <span className="flex-1 rounded-field bg-shell-field px-3 py-1.5 text-center text-[12.5px] text-shell-ink-muted">
332 {t('space.discover.closed')}
333 </span>
334 ) : (
335 <Button className="h-8 flex-1 text-[12.5px]" onClick={onApply}>
336 {t('space.discover.apply')}
337 </Button>
338 )
339
340 return (
341 <li className="flex flex-col overflow-hidden rounded-xl border border-shell-hairline bg-shell-surface">
342 <div className="relative aspect-[16/7] w-full" style={banner ? undefined : { background: bannerGradient(space.id) }}>
343 {banner ? (
344 <img
345 src={banner}
346 alt=""
347 loading="lazy"
348 onError={() => setBannerFailed(true)}
349 className="h-full w-full object-cover"
350 />
351 ) : null}
352 <span className="absolute -bottom-5 left-4 flex h-12 w-12 items-center justify-center overflow-hidden rounded-full border-2 border-shell-surface bg-shell-tile text-[16px] font-semibold text-shell-ink">
353 {space.avatar_url ? (
354 <img src={space.avatar_url} alt="" className="h-full w-full object-cover" />
355 ) : (
356 space.name.slice(0, 1).toUpperCase()
357 )}
358 </span>
359 </div>
360 <div className="flex min-h-0 flex-1 flex-col px-4 pb-4 pt-7">
361 <div className="flex items-start justify-between gap-2">
362 <h3 className="truncate text-[14.5px] font-semibold">{space.name}</h3>
363 <span className="flex shrink-0 items-center gap-1 text-[11.5px] text-shell-ink-muted">
364 <Users className="h-3.5 w-3.5" aria-hidden />
365 {t('space.discover.members', { count: space.members_count })}
366 </span>
367 </div>
368 <p className="mt-1 line-clamp-2 min-h-[2.6em] text-[12.5px] leading-[1.3] text-shell-ink-muted">
369 {space.description || ''}
370 </p>
371 <p className="mt-2 text-[11.5px] text-shell-ink-muted">
372 {t('space.discover.joinLevel', { level: t(JOIN_LEVEL_KEYS[space.allow_join_level]) })}
373 </p>
374 <div className="mt-3 flex items-center gap-2">{action}</div>
375 </div>
376 </li>
377 )
378}
Added web/src/features/space-discovery/index.ts web/src/features/space-discovery/index.ts +15 −0 View web/src/features/space-discovery/index.ts
@@ -0,0 +1,15 @@
1// Public API of features/space-discovery — the public space directory and the
2// applicant side of the join-request flow.
3// Consumers import from '@/features/space-discovery', never from deeper files.
4export { ExploreView, type ExploreViewProps } from './ui/explore-view'
5export {
6 exploreSpaces,
7 submitJoinRequest,
8 withdrawJoinRequest,
9 type AllowJoinLevel,
10 type ExplorePage,
11 type ExploreSpace,
12 type ExploreViewer,
13 type ExploreViewerRequest,
14 type JoinRequest,
15} from './model/api'
No newline at end of file
Modified web/src/features/space-management/model/space-admin.ts web/src/features/space-management/model/space-admin.ts +52 −0 View web/src/features/space-management/model/space-admin.ts
@@ -16,6 +16,7 @@
1616 AppResponseData_BanDto,
1717 AppResponseData_GroupDto,
1818 AppResponseData_InviteDto,
19 AppResponseData_JoinRequestDto,
1920 AppResponseData_JoinRuleDto,
2021 AppResponseData_MemberDto,
2122 AppResponseData_SpaceImageDto,
@@ -29,6 +30,7 @@
2930 CreateJoinRuleCmd,
3031 MemberStatus,
3132 SpaceImageKind,
33 SpaceJoinRequestStatus,
3234 UpdateBanCmd,
3335 UpdateGroupCmd,
3436 UpdateJoinRuleCmd,
@@ -59,6 +61,23 @@
5961 return list ?? []
6062}
6163
64/** Paged list that keeps `total`/`offset`: the join-request queue pages. */
65const pageOf = async <T>(
66 method: unknown,
67): Promise<{ items: T[]; total: number; limit: number; offset: number }> => {
68 const page = await unwrapApi<{ items?: T[]; total?: number; limit?: number; offset?: number } | null>(
69 method as PromiseLike<
70 ApiEnvelope<{ items?: T[]; total?: number; limit?: number; offset?: number } | null>
71 >,
72 )
73 return {
74 items: page?.items ?? [],
75 total: page?.total ?? 0,
76 limit: page?.limit ?? 0,
77 offset: page?.offset ?? 0,
78 }
79}
80
6281/** Page size for every settings list; the overlay has no pagination controls. */
6382export const ADMIN_PAGE_LIMIT = 200
6483
@@ -195,6 +214,39 @@
195214 Apis.space.delete_join_rule({ pathParams: { space_name: spaceName, rule_id: ruleId } }),
196215 )
197216
217/* ---------------------------------------------------------- join requests */
218
219export type SpaceJoinRequest = NonNullable<AppResponseData_JoinRequestDto['data']>
220
221export const listSpaceJoinRequests = (
222 spaceName: string,
223 query: { status?: SpaceJoinRequestStatus; limit?: number; offset?: number } = {},
224) =>
225 pageOf<SpaceJoinRequest>(
226 Apis.space.list_join_requests({
227 pathParams: { space_name: spaceName },
228 params: {
229 ...(query.status ? { status: query.status } : {}),
230 ...(query.limit === undefined ? {} : { limit: query.limit }),
231 ...(query.offset === undefined ? {} : { offset: query.offset }),
232 },
233 }),
234 )
235
236export const approveSpaceJoinRequest = (spaceName: string, requestId: string) =>
237 unwrapApi<SpaceJoinRequest>(
238 Apis.space.approve_join_request({
239 pathParams: { space_name: spaceName, request_id: requestId },
240 }),
241 )
242
243export const rejectSpaceJoinRequest = (spaceName: string, requestId: string) =>
244 unwrapApi<SpaceJoinRequest>(
245 Apis.space.reject_join_request({
246 pathParams: { space_name: spaceName, request_id: requestId },
247 }),
248 )
249
198250/* ------------------------------------------------------------------ bans */
199251
200252export const listSpaceBans = (spaceName: string) =>
Added web/src/features/space-management/ui/space-settings/join-requests-section.tsx web/src/features/space-management/ui/space-settings/join-requests-section.tsx +180 −0 View web/src/features/space-management/ui/space-settings/join-requests-section.tsx
@@ -0,0 +1,180 @@
1import { useState } from 'react'
2import { Loader2 } from 'lucide-react'
3import {
4 Alert,
5 Button,
6 EmptyNote,
7 InitialAvatar,
8 SectionTitle,
9 Chip,
10 fmtDate,
11 useSave,
12} from '@/shared/ui'
13import { spaceAdminKey, useAsync } from '@/shared/lib'
14import { useT } from '@/shared/i18n'
15import {
16 approveSpaceJoinRequest,
17 listSpaceJoinRequests,
18 rejectSpaceJoinRequest,
19 type SpaceJoinRequest,
20} from '../../model/space-admin'
21import { userLabel } from './meta'
22import type { SpaceSectionProps } from './props'
23
24const PAGE_SIZE = 25
25
26type Filter = 'pending' | 'approved' | 'rejected' | 'all'
27
28const FILTERS: { id: Filter; label: 'space.joinRequests.filter.pending' | 'space.joinRequests.filter.approved' | 'space.joinRequests.filter.rejected' | 'space.joinRequests.filter.all' }[] = [
29 { id: 'pending', label: 'space.joinRequests.filter.pending' },
30 { id: 'approved', label: 'space.joinRequests.filter.approved' },
31 { id: 'rejected', label: 'space.joinRequests.filter.rejected' },
32 { id: 'all', label: 'space.joinRequests.filter.all' },
33]
34
35const STATUS_KEYS = {
36 pending: 'space.joinRequests.status.pending',
37 approved: 'space.joinRequests.status.approved',
38 rejected: 'space.joinRequests.status.rejected',
39 withdrawn: 'space.joinRequests.status.withdrawn',
40} as const
41
42/** The approval queue: who asked to join, their note, and approve/reject. */
43export function JoinRequestsSection({ spaceName }: SpaceSectionProps) {
44 const { t } = useT()
45 const [filter, setFilter] = useState<Filter>('pending')
46 const [limit, setLimit] = useState(PAGE_SIZE)
47 const [tick, setTick] = useState(0)
48 const [pendingId, setPendingId] = useState<string | null>(null)
49 const { notice, error, run } = useSave()
50 const page = useAsync(
51 () =>
52 listSpaceJoinRequests(spaceName, {
53 status: filter === 'all' ? undefined : filter,
54 limit,
55 offset: 0,
56 }),
57 [spaceName, filter, limit, tick],
58 { cacheKey: spaceAdminKey(spaceName, `join-requests:${filter}`) },
59 )
60 const rows = page.data?.items ?? []
61 const total = page.data?.total ?? 0
62
63 const decide = (row: SpaceJoinRequest, approve: boolean) => {
64 const name = row.user?.display_name || row.user?.username || ''
65 setPendingId(row.id)
66 void run(async () => {
67 await (approve
68 ? approveSpaceJoinRequest(spaceName, row.id)
69 : rejectSpaceJoinRequest(spaceName, row.id))
70 setTick(value => value + 1)
71 }, t(approve ? 'space.joinRequests.approved' : 'space.joinRequests.rejected', { name })).finally(
72 () => setPendingId(null),
73 )
74 }
75
76 return (
77 <section>
78 <SectionTitle title={t('space.joinRequests.title')} sub={t('space.joinRequests.sub')} />
79
80 <div className="mb-4 flex flex-wrap items-center gap-2">
81 {FILTERS.map(item => (
82 <button
83 key={item.id}
84 type="button"
85 aria-pressed={filter === item.id}
86 onClick={() => {
87 setFilter(item.id)
88 setLimit(PAGE_SIZE)
89 }}
90 className={`rounded-field border border-shell-hairline px-3 py-1.5 text-[12.5px] transition-colors ${
91 filter === item.id ? 'bg-shell-active font-semibold' : 'hover:bg-shell-field'
92 }`}
93 >
94 {t(item.label)}
95 </button>
96 ))}
97 <span className="ml-auto text-[12px] text-shell-ink-muted">
98 {t('space.joinRequests.count', { count: total })}
99 </span>
100 </div>
101
102 {notice ? <div className="mb-4"><Alert tone="success">{notice}</Alert></div> : null}
103 {error ? <div className="mb-4"><Alert tone="error">{error}</Alert></div> : null}
104
105 {page.loading && !page.data ? (
106 <EmptyNote>{t('ui.loading')}</EmptyNote>
107 ) : page.error && !page.data ? (
108 <Alert tone="error">{t('space.joinRequests.loadFailed')}</Alert>
109 ) : rows.length === 0 ? (
110 <div className="py-6 text-center">
111 <EmptyNote>{t('space.joinRequests.empty')}</EmptyNote>
112 <p className="mt-1 text-[12.5px] text-shell-ink-muted">{t('space.joinRequests.emptyHint')}</p>
113 </div>
114 ) : (
115 <div className="space-y-2">
116 {rows.map(row => (
117 <div key={row.id} className="settings-card p-3.5">
118 <div className="mb-2 flex items-center gap-3">
119 <InitialAvatar
120 name={row.user?.display_name || row.user?.username || '?'}
121 url={row.user?.avatar_url}
122 />
123 <div className="min-w-0 flex-1">
124 <p className="truncate text-[13.5px] font-semibold">{userLabel(row.user)}</p>
125 <p className="text-[11.5px] text-shell-ink-muted">
126 {t('space.joinRequests.requestedAt', { time: fmtDate(row.created_at) })}
127 </p>
128 </div>
129 <Chip>
130 {t(STATUS_KEYS[row.status])}
131 </Chip>
132 </div>
133 <p className="whitespace-pre-wrap break-words text-[12.5px] leading-[1.5]">
134 {row.note ? row.note : <span className="text-shell-ink-muted">{t('space.joinRequests.noNote')}</span>}
135 </p>
136 {row.decided_by ? (
137 <p className="mt-1.5 text-[11.5px] text-shell-ink-muted">
138 {t('space.joinRequests.decidedBy', { name: userLabel(row.decided_by) })}
139 {row.decided_at ? ` · ${fmtDate(row.decided_at)}` : ''}
140 </p>
141 ) : null}
142 {row.status === 'pending' ? (
143 <div className="mt-3 flex items-center gap-2">
144 <Button
145 className="h-8 text-[12.5px]"
146 disabled={pendingId === row.id}
147 onClick={() => decide(row, true)}
148 >
149 {pendingId === row.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
150 {t('space.joinRequests.approve')}
151 </Button>
152 <Button
153 variant="secondary"
154 className="h-8 text-[12.5px]"
155 disabled={pendingId === row.id}
156 onClick={() => decide(row, false)}
157 >
158 {t('space.joinRequests.reject')}
159 </Button>
160 </div>
161 ) : null}
162 </div>
163 ))}
164 {rows.length < total ? (
165 <div className="flex justify-center pt-2">
166 <Button
167 variant="secondary"
168 className="h-9 text-[12.5px]"
169 disabled={page.loading}
170 onClick={() => setLimit(value => value + PAGE_SIZE)}
171 >
172 {t('space.joinRequests.loadMore')}
173 </Button>
174 </div>
175 ) : null}
176 </div>
177 )}
178 </section>
179 )
180}
Modified web/src/features/space-management/ui/space-settings-modal.tsx web/src/features/space-management/ui/space-settings-modal.tsx +19 −1 View web/src/features/space-management/ui/space-settings-modal.tsx
@@ -10,6 +10,7 @@
1010import { GeneralSection } from './space-settings/general-section'
1111import { ImagesSection } from './space-settings/images-section'
1212import { InvitesSection } from './space-settings/invites-section'
13import { JoinRequestsSection } from './space-settings/join-requests-section'
1314import { JoinRulesSection } from './space-settings/join-rules-section'
1415import { MembersSection } from './space-settings/members-section'
1516import { MyMembershipSection } from './space-settings/my-membership-section'
@@ -22,6 +23,8 @@
2223 onClose: () => void
2324 mySpace: MySpace
2425 viewerId?: string
26 /** Deep-linked section to select on open (e.g. from a join-request notice). */
27 initialSection?: string
2528 /** The space row changed — the rail tile and header need the fresh name/icon. */
2629 onSpaceUpdated: () => void
2730 /** The space was deleted — the shell reloads the list and leaves the space. */
@@ -39,6 +42,7 @@
3942 onClose,
4043 mySpace,
4144 viewerId,
45 initialSection,
4246 onSpaceUpdated,
4347 onDeleted,
4448}: SpaceSettingsModalProps) {
@@ -64,7 +68,12 @@
6468 { id: 'mine', label: t('space.settings.sections.mine') },
6569 { id: 'general', label: t('space.settings.sections.general') },
6670 ...(perms.has(PERM.channels) ? [{ id: 'images', label: t('space.settings.sections.images') }] : []),
67 ...(perms.has(PERM.members) ? [{ id: 'members', label: t('space.members.title') }] : []),
71 ...(perms.has(PERM.members)
72 ? [
73 { id: 'members', label: t('space.members.title') },
74 { id: 'join-requests', label: t('space.settings.sections.joinRequests') },
75 ]
76 : []),
6877 ...(perms.has(PERM.roles) ? [{ id: 'roles', label: t('space.roles.title') }] : []),
6978 ...(perms.has(PERM.invites) ? [{ id: 'invites', label: t('space.invites.title') }] : []),
7079 ...(perms.has(PERM.roles) ? [{ id: 'join', label: t('space.settings.sections.join') }] : []),
@@ -75,6 +84,14 @@
7584 { id: 'danger', label: t('space.settings.danger') },
7685 ]
7786
87 // A deep-linked section applies on each open, as long as this space's
88 // permissions actually expose it.
89 useEffect(() => {
90 if (!open || !initialSection) return
91 if (visible.some(item => item.id === initialSection)) setSection(initialSection)
92 // eslint-disable-next-line react-hooks/exhaustive-deps
93 }, [open, initialSection, mySpace.space.name])
94
7895 // Losing the permission that owned the current section (or opening a
7996 // different space) must not leave the content column blank.
8097 useEffect(() => {
@@ -111,6 +128,7 @@
111128 {section === 'general' ? full || !loaded.loading ? <GeneralSection {...props} /> : <Busy /> : null}
112129 {section === 'images' ? <ImagesSection {...props} /> : null}
113130 {section === 'members' ? <MembersSection {...props} /> : null}
131 {section === 'join-requests' ? <JoinRequestsSection {...props} /> : null}
114132 {section === 'roles' ? <RolesSection {...props} /> : null}
115133 {section === 'invites' ? <InvitesSection {...props} /> : null}
116134 {section === 'join' ? <JoinRulesSection {...props} /> : null}
Modified web/src/pages/workspace/model/route.ts web/src/pages/workspace/model/route.ts +1 −1 View web/src/pages/workspace/model/route.ts
@@ -1,5 +1,5 @@
11/** Main-column variant, taken from the route's static section segment. */
2export type ShellMode = 'chat' | 'board' | 'repo' | 'notify'
2export type ShellMode = 'chat' | 'board' | 'repo' | 'notify' | 'explore'
33
44/** Repo page tabs, rendered by `RepoLayout`'s default tab strip. */
55export type RepoTabId =
Modified web/src/pages/workspace/model/workspace-data.ts web/src/pages/workspace/model/workspace-data.ts +11 −9 View web/src/pages/workspace/model/workspace-data.ts
@@ -38,29 +38,31 @@
3838 listsReloadKey = 0,
3939 meReloadKey = 0,
4040) {
41 // The personal scope owns DMs only; space-scoped lists would 404.
41 // The personal scope owns DMs only; space-scoped lists would 404. The
42 // explore column owns no space either, so it skips the same lists.
4243 const personal = spaceName === '@me'
44 const scoped = !personal && spaceName !== ''
4345 const me = useAsync(fetchMe, [meReloadKey], { cacheKey: meKey() })
4446 const spaces = useAsync(fetchMySpaces, [reloadKey], { cacheKey: spacesKey() })
4547 const rooms = useAsync(
46 () => (personal ? Promise.resolve([]) : fetchSpaceRooms(spaceName)),
48 () => (scoped ? fetchSpaceRooms(spaceName) : Promise.resolve([])),
4749 [spaceName, listsReloadKey],
48 { cacheKey: personal ? null : spaceRoomsKey(spaceName) },
50 { cacheKey: scoped ? spaceRoomsKey(spaceName) : null },
4951 )
50 const dirs = useAsync(() => (personal ? Promise.resolve([]) : fetchSpaceDir(spaceName)), [
52 const dirs = useAsync(() => (scoped ? fetchSpaceDir(spaceName) : Promise.resolve([])), [
5153 spaceName,
5254 listsReloadKey,
53 ], { cacheKey: personal ? null : spaceDirKey(spaceName) })
55 ], { cacheKey: scoped ? spaceDirKey(spaceName) : null })
5456 const dms = useAsync(fetchDms, [], { cacheKey: dmsKey() })
5557 const repos = useAsync(
56 () => (personal ? Promise.resolve([]) : fetchSpaceRepos(spaceName)),
58 () => (scoped ? fetchSpaceRepos(spaceName) : Promise.resolve([])),
5759 [spaceName, listsReloadKey],
58 { cacheKey: personal ? null : spaceReposKey(spaceName) },
60 { cacheKey: scoped ? spaceReposKey(spaceName) : null },
5961 )
6062 const boards = useAsync(
61 () => (personal ? Promise.resolve([]) : fetchSpaceBoards(spaceName)),
63 () => (scoped ? fetchSpaceBoards(spaceName) : Promise.resolve([])),
6264 [spaceName, listsReloadKey],
63 { cacheKey: personal ? null : spaceBoardsKey(spaceName) },
65 { cacheKey: scoped ? spaceBoardsKey(spaceName) : null },
6466 )
6567 const repoRef = mode === 'repo' && repoName ? `${spaceName}/${repoName}` : ''
6668 const tags = useAsync(
Modified web/src/pages/workspace/model/workspace-route.ts web/src/pages/workspace/model/workspace-route.ts +15 −4 View web/src/pages/workspace/model/workspace-route.ts
@@ -9,16 +9,27 @@
99export function useShellRoute(): ShellRoute {
1010 const { pathname } = useLocation()
1111 const params = useParams<ShellParams>()
12 const section = decodeURIComponent(pathname.split('/')[3] ?? '')
13 const mode: ShellMode =
14 section === 'notify' ? 'notify' : section === 'board' ? 'board' : section === 'repo' ? 'repo' : 'chat'
12 const segments = pathname.split('/')
13 // `/app/explore` is a static sibling of `/app/:spaceName`, so it has no
14 // section segment: the second path segment itself is the marker.
15 const explore = segments[2] === 'explore'
16 const section = decodeURIComponent(segments[3] ?? '')
17 const mode: ShellMode = explore
18 ? 'explore'
19 : section === 'notify'
20 ? 'notify'
21 : section === 'board'
22 ? 'board'
23 : section === 'repo'
24 ? 'repo'
25 : 'chat'
1526 // `/app/@me…` routes carry no :spaceName param; name the personal scope so
1627 // data loading and the rail can treat it like any other scope.
1728 const personal = pathname === '/app/@me' || pathname.startsWith('/app/@me/')
1829
1930 return {
2031 mode,
21 spaceName: params.spaceName ?? (personal ? '@me' : ''),
32 spaceName: explore ? '' : params.spaceName ?? (personal ? '@me' : ''),
2233 roomId: params.roomId,
2334 boardId: params.boardId,
2435 repoName: params.repoName,
Modified web/src/pages/workspace/ui/space-rail.tsx web/src/pages/workspace/ui/space-rail.tsx +6 −3 View web/src/pages/workspace/ui/space-rail.tsx
@@ -10,17 +10,20 @@
1010export function SpaceRail({
1111 spaces,
1212 activeName,
13 discovering = false,
1314 onAddSpace,
1415}: {
1516 spaces: MySpace[]
1617 activeName?: string
18 /** `/app/explore` is open: the compass tile takes the active treatment. */
19 discovering?: boolean
1720 /** Opens the create-space modal; falls back to a link when absent. */
1821 onAddSpace?: () => void
1922}) {
2023 const { t } = useT()
2124 return (
2225 <>
23 <RailLink to="/app/@me" active={!activeName} label={t('shell.rail.home')} square>
26 <RailLink to="/app/@me" active={!activeName && !discovering} label={t('shell.rail.home')} square>
2427 <Layers3 className="h-[26px] w-[26px]" />
2528 </RailLink>
2629 <div className="h-0.5 w-8 rounded-full bg-shell-tile" />
@@ -48,11 +51,11 @@
4851 <Plus className="h-[22px] w-[22px]" />
4952 </RailButton>
5053 ) : (
51 <RailLink to="/spaces/discover" active={false} label={t('shell.rail.addSpace')}>
54 <RailLink to="/app/explore" active={false} label={t('shell.rail.addSpace')}>
5255 <Plus className="h-[22px] w-[22px]" />
5356 </RailLink>
5457 )}
55 <RailLink to="/spaces/explore" active={false} label={t('shell.rail.discover')}>
58 <RailLink to="/app/explore" active={discovering} label={t('shell.rail.discover')}>
5659 <Compass className="h-[22px] w-[22px]" />
5760 </RailLink>
5861 </>
Modified web/src/pages/workspace/ui/workspace-shell.tsx web/src/pages/workspace/ui/workspace-shell.tsx +62 −7 View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -1,5 +1,5 @@
11import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2import { Navigate, useNavigate } from 'react-router-dom'
2import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
33import { Loader2 } from 'lucide-react'
44import { BoardView } from './board/board-view'
55import { ChatLayout } from './layout/chat-layout'
@@ -7,6 +7,7 @@
77import { VoiceCallBar } from '@/features/chat'
88import { RepoView } from './repo/repo-view'
99import { useT } from '@/shared/i18n'
10import { AppShell } from '@/shared/ui'
1011import { ApiFailure } from '@/shared/lib'
1112import { useShellRoute } from '../model/workspace-route'
1213import { useWorkspaceData } from '../model/workspace-data'
@@ -19,6 +20,7 @@
1920import { UserPanel } from './user-panel'
2021import { UserSettingsModal } from '@/features/user-settings'
2122import { GlobalSearchCommand } from '@/features/search'
23import { ExploreView } from '@/features/space-discovery'
2224
2325/**
2426 * Route-level layout for the three main interfaces: loads the workspace's
@@ -29,18 +31,33 @@
2931 const { t } = useT()
3032 const route = useShellRoute()
3133 const navigate = useNavigate()
34 // ?settings=<section> deep-links a settings section (the join-request
35 // notification points here); the param is consumed on open so a later
36 // manual open starts from the default section again.
37 const [searchParams, setSearchParams] = useSearchParams()
3238 const { mode, spaceName, roomId, boardId, repoName } = route
39 const exploring = mode === 'explore'
3340 const [creatingSpace, setCreatingSpace] = useState(false)
3441 const [spaceReload, setSpaceReload] = useState(0)
3542 const [listsReload, setListsReload] = useState(0)
3643 const [meReload, setMeReload] = useState(0)
3744 const [settingsOpen, setSettingsOpen] = useState(false)
3845 const [spaceSettingsOpen, setSpaceSettingsOpen] = useState(false)
46 // Section a deep link asked for; kept until the overlay closes so the
47 // consumed URL param does not race the modal's first open.
48 const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined)
3949 const badgeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
4050 const {
4151 me, spaces, rooms, dirs, dms, repos, boards,
4252 room, dm, board,
4353 } = useWorkspaceData(route, spaceReload, listsReload, meReload)
54 const requestedSection = searchParams.get('settings')
55 const consumeSectionParam = useCallback(() => {
56 if (!searchParams.has('settings')) return
57 const next = new URLSearchParams(searchParams)
58 next.delete('settings')
59 setSearchParams(next, { replace: true })
60 }, [searchParams, setSearchParams])
4461 const currentMySpace = spaces.data?.find(({ space }) => space.name === spaceName)
4562 // The inbox needs to know which spaces this account is already in, so an
4663 // invite you accepted elsewhere reads as settled instead of offering a
@@ -71,22 +88,40 @@
7188 // the prefetch layer's own freshness window throttles any repeat.
7289 const idleWarmed = useRef<string | null>(null)
7390 useEffect(() => {
74 if (spaceName === '@me' || idleWarmed.current === spaceName) return
91 if (exploring || spaceName === '@me' || idleWarmed.current === spaceName) return
7592 if (rooms.loading || repos.loading || boards.loading) return
7693 if (!rooms.data || !repos.data || !boards.data) return
7794 idleWarmed.current = spaceName
7895 prefetchLikelyTargets(spaceName, rooms.data, repos.data, boards.data)
79 }, [spaceName, rooms.loading, rooms.data, repos.loading, repos.data, boards.loading, boards.data])
96 }, [exploring, spaceName, rooms.loading, rooms.data, repos.loading, repos.data, boards.loading, boards.data])
8097
98 // A `?settings=<section>` link (join-request notifications) opens the
99 // overlay for the space in scope. Without the permission that owns the
100 // section the overlay would open on an empty column, so the param is
101 // consumed either way and only a permitted deep link opens anything.
102 useEffect(() => {
103 if (!requestedSection || spaceName === '@me' || !currentMySpace) return
104 const canSee =
105 requestedSection === 'join-requests'
106 ? currentMySpace.permissions.includes('manage_members')
107 : true
108 if (canSee) {
109 setSettingsSection(requestedSection)
110 setSpaceSettingsOpen(true)
111 }
112 consumeSectionParam()
113 }, [requestedSection, spaceName, currentMySpace, consumeSectionParam])
81114 // Browser tab title mirrors the active section. Derived from loaded data so
82115 // it stays correct across language switches (`t` is locale-bound) and across
83116 // in-app navigation, which never reloads the document.
84117 useEffect(() => {
85118 const base = t('shell.title')
86119 document.title =
87 mode === 'repo' && repoName
88 ? t('shell.titleRepo', { repo: repoName, space: spaceName })
89 : mode === 'board' && boardId
120 mode === 'explore'
121 ? t('space.discover.docTitle')
122 : mode === 'repo' && repoName
123 ? t('shell.titleRepo', { repo: repoName, space: spaceName })
124 : mode === 'board' && boardId
90125 ? t('shell.titleBoard', { board: board?.name || boardId, space: spaceName })
91126 : roomId
92127 ? t('shell.titleChat', { room: room?.name || dm?.name || roomId, space: spaceName })
@@ -117,6 +152,7 @@
117152 <SpaceRail
118153 spaces={spaces.data ?? []}
119154 activeName={personal ? undefined : spaceName || undefined}
155 discovering={exploring}
120156 onAddSpace={() => setCreatingSpace(true)}
121157 />
122158 ),
@@ -170,7 +206,11 @@
170206 const spaceSettingsModal = currentMySpace ? (
171207 <SpaceSettingsModal
172208 open={spaceSettingsOpen}
173 onClose={() => setSpaceSettingsOpen(false)}
209 onClose={() => {
210 setSpaceSettingsOpen(false)
211 setSettingsSection(undefined)
212 }}
213 initialSection={settingsSection}
174214 mySpace={currentMySpace}
175215 viewerId={me.data?.user.user_id}
176216 onSpaceUpdated={() => setSpaceReload(key => key + 1)}
@@ -194,6 +234,21 @@
194234 />
195235 )
196236
237 if (mode === 'explore') {
238 return (
239 <>
240 <AppShell {...shell} railOnly>
241 <ExploreView onAddSpace={() => setCreatingSpace(true)} />
242 </AppShell>
243 {createSpaceModal}
244 {settingsModal}
245 <NotifyToaster />
246
247 <VoiceCallBar />
248 <GlobalSearchCommand />
249 </>
250 )
251 }
197252 if (mode === 'notify') {
198253 return (
199254 <>
Modified web/src/shared/api/apiDefinitions.ts web/src/shared/api/apiDefinitions.ts +6 −0 View web/src/shared/api/apiDefinitions.ts
@@ -551,6 +551,7 @@
551551 'search.global_search': ['GET', '/search'],
552552 'space.list_spaces': ['GET', '/spaces'],
553553 'space.create_space': ['POST', '/spaces'],
554 'space.explore_spaces': ['GET', '/spaces/explore'],
554555 'space.accept_invite': ['POST', '/spaces/invites/accept'],
555556 'space.reject_invite': ['POST', '/spaces/invites/reject'],
556557 'space.get_space': ['GET', '/spaces/{space_name}'],
@@ -588,6 +589,11 @@
588589 'space.list_invites': ['GET', '/spaces/{space_name}/invites'],
589590 'space.create_invite': ['POST', '/spaces/{space_name}/invites'],
590591 'space.revoke_invite': ['DELETE', '/spaces/{space_name}/invites/{invite_id}'],
592 'space.list_join_requests': ['GET', '/spaces/{space_name}/join-requests'],
593 'space.create_join_request': ['POST', '/spaces/{space_name}/join-requests'],
594 'space.withdraw_join_request': ['DELETE', '/spaces/{space_name}/join-requests/{request_id}'],
595 'space.approve_join_request': ['POST', '/spaces/{space_name}/join-requests/{request_id}/approve'],
596 'space.reject_join_request': ['POST', '/spaces/{space_name}/join-requests/{request_id}/reject'],
591597 'space.list_join_rules': ['GET', '/spaces/{space_name}/join-rules'],
592598 'space.create_join_rule': ['POST', '/spaces/{space_name}/join-rules'],
593599 'space.delete_join_rule': ['DELETE', '/spaces/{space_name}/join-rules/{rule_id}'],
Modified web/src/shared/api/globals.d.ts web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts
Diff too large to display.
Modified web/src/shared/i18n/messages/en/misc.ts web/src/shared/i18n/messages/en/misc.ts +4 −0 View web/src/shared/i18n/messages/en/misc.ts
@@ -38,6 +38,10 @@
3838 'notifications.target.issue': 'View issue #{number}',
3939 'notifications.target.room': 'Open channel',
4040 'notifications.target.space': 'Enter space',
41 'notifications.target.joinRequests': 'Review join request',
42 'notifications.joinRequestSubmitted': 'New join request',
43 'notifications.joinRequestApproved': 'Join request approved',
44 'notifications.joinRequestRejected': 'Join request declined',
4145 'notifications.bell': 'Notifications',
4246 'notifications.bellUnread': 'Notifications, {count} unread',
4347 'notifications.region': 'System notifications',
Modified web/src/shared/i18n/messages/en/space.ts web/src/shared/i18n/messages/en/space.ts +61 −1 View web/src/shared/i18n/messages/en/space.ts
@@ -191,7 +191,66 @@
191191 'space.permission.manageBans': 'Manage bans',
192192 'space.permission.manageChannels': 'Manage channels',
193193 'space.permission.manageOverwrites': 'Manage permission overrides',
194 'space.joinLevel.none': 'Joining disabled',
194 'space.discover.title': 'Discover spaces',
195 'space.discover.sub': 'Browse public spaces, their intro and member count, then ask to join.',
196 'space.discover.search': 'Search space names or descriptions',
197 'space.discover.sort.members': 'Recommended',
198 'space.discover.sort.newest': 'Newest',
199 'space.discover.resultCount': '{count} public spaces',
200 'space.discover.loading': 'Loading spaces…',
201 'space.discover.empty': 'No matching spaces',
202 'space.discover.emptyHint': 'Try another keyword, or come back later.',
203 'space.discover.loadFailed': 'Failed to load spaces',
204 'space.discover.loadMore': 'Load more',
205 'space.discover.members': '{count} members',
206 'space.discover.joinLevel': 'Join requirement: {level}',
207 'space.discover.joinLevel.none': 'No extra requirement',
208 'space.discover.enter': 'Enter space',
209 'space.discover.requested': 'Request submitted',
210 'space.discover.pending': 'Pending review',
211 'space.discover.withdraw': 'Withdraw request',
212 'space.discover.withdrawn': 'Request withdrawn.',
213 'space.discover.cooldown': 'Cooling down',
214 'space.discover.cooldownUntil': 'You can apply again {time}',
215 'space.discover.apply': 'Ask to join',
216 'space.discover.closed': 'Not accepting requests',
217 'space.discover.rejected': 'Last request was declined',
218 'space.discover.dialogTitle': 'Ask to join {space}',
219 'space.discover.note': 'Message (optional)',
220 'space.discover.noteHint': 'Briefly say why you want to join, up to 512 characters.',
221 'space.discover.notePlaceholder': 'I would like to join because…',
222 'space.discover.submit': 'Submit request',
223 'space.discover.submitted': 'Request submitted; an admin will review it.',
224 'space.discover.submitFailed': 'Failed to submit request',
225 'space.discover.withdrawFailed': 'Failed to withdraw request',
226 'space.discover.cooldownError': 'Your last request was just declined; try again later.',
227 'space.discover.requirementError': "You do not meet this space's join requirement yet.",
228 'space.discover.notAllowedError': 'This space is not accepting join requests.',
229 'space.discover.docTitle': 'Discover spaces · GitDataAI',
230 'space.joinRequests.title': 'Join requests',
231 'space.joinRequests.sub': 'Review people asking to join; approving adds them as members.',
232 'space.joinRequests.filter.pending': 'Pending',
233 'space.joinRequests.filter.approved': 'Approved',
234 'space.joinRequests.filter.rejected': 'Declined',
235 'space.joinRequests.filter.all': 'All',
236 'space.joinRequests.empty': 'No join requests yet',
237 'space.joinRequests.emptyHint': 'Requests appear here, with a notification, as soon as someone applies.',
238 'space.joinRequests.loadFailed': 'Failed to load join requests',
239 'space.joinRequests.note': 'Message',
240 'space.joinRequests.noNote': 'No message provided',
241 'space.joinRequests.approve': 'Approve',
242 'space.joinRequests.reject': 'Decline',
243 'space.joinRequests.approved': 'Approved {name}.',
244 'space.joinRequests.rejected': 'Declined {name}.',
245 'space.joinRequests.status.pending': 'Pending',
246 'space.joinRequests.status.approved': 'Approved',
247 'space.joinRequests.status.rejected': 'Declined',
248 'space.joinRequests.status.withdrawn': 'Withdrawn',
249 'space.joinRequests.decidedBy': 'Decided by {name}',
250 'space.joinRequests.requestedAt': 'Requested {time}',
251 'space.joinRequests.loadMore': 'Load more',
252 'space.joinRequests.count': '{count} total',
253 'space.joinLevel.none': 'No extra requirement',
195254 'space.joinLevel.mail': 'Verified email required',
196255 'space.joinLevel.mailDay': 'Verified email and one-day-old account required',
197256 'space.joinLevel.mailHour': 'Verified email and one-hour-old account required',
@@ -217,6 +276,7 @@
217276 'space.settings.sections.general': 'Space profile',
218277 'space.settings.sections.images': 'Icons and banners',
219278 'space.settings.sections.join': 'Join rules',
279 'space.settings.sections.joinRequests': 'Join requests',
220280 'space.settings.sections.bans': 'Bans',
221281 'space.settings.fullLoadFailed': 'Could not load the full space record; these forms fall back to the cached list row and some fields may be stale.',
222282 'space.general.title': 'Space profile',
Modified web/src/shared/i18n/messages/zh-CN/misc.ts web/src/shared/i18n/messages/zh-CN/misc.ts +4 −0 View web/src/shared/i18n/messages/zh-CN/misc.ts
@@ -38,6 +38,10 @@
3838 'notifications.target.issue': '查看工单 #{number}',
3939 'notifications.target.room': '打开频道',
4040 'notifications.target.space': '进入社区',
41 'notifications.target.joinRequests': '处理加入申请',
42 'notifications.joinRequestSubmitted': '有新的加入申请',
43 'notifications.joinRequestApproved': '申请已通过',
44 'notifications.joinRequestRejected': '申请未通过',
4145 'notifications.bell': '通知',
4246 'notifications.bellUnread': '通知,{count} 条未读',
4347 'notifications.region': '系统通知',
Modified web/src/shared/i18n/messages/zh-CN/space.ts web/src/shared/i18n/messages/zh-CN/space.ts +61 −1 View web/src/shared/i18n/messages/zh-CN/space.ts
@@ -191,7 +191,66 @@
191191 'space.permission.manageBans': '管理封禁',
192192 'space.permission.manageChannels': '管理频道',
193193 'space.permission.manageOverwrites': '管理权限覆写',
194 'space.joinLevel.none': '不允许加入',
194 'space.discover.title': '发现社区',
195 'space.discover.sub': '浏览公开社区,浏览简介与成员规模,申请加入。',
196 'space.discover.search': '搜索社区名称或简介',
197 'space.discover.sort.members': '推荐',
198 'space.discover.sort.newest': '最新',
199 'space.discover.resultCount': '共 {count} 个公开社区',
200 'space.discover.loading': '正在加载社区…',
201 'space.discover.empty': '没有匹配的社区',
202 'space.discover.emptyHint': '换个关键词试试,或稍后再来看看。',
203 'space.discover.loadFailed': '社区列表加载失败',
204 'space.discover.loadMore': '加载更多',
205 'space.discover.members': '{count} 位成员',
206 'space.discover.joinLevel': '加入门槛:{level}',
207 'space.discover.joinLevel.none': '无额外要求',
208 'space.discover.enter': '进入社区',
209 'space.discover.requested': '已提交申请',
210 'space.discover.pending': '待审批',
211 'space.discover.withdraw': '撤回申请',
212 'space.discover.withdrawn': '已撤回申请。',
213 'space.discover.cooldown': '冷却中',
214 'space.discover.cooldownUntil': '{time} 后可再次申请',
215 'space.discover.apply': '申请加入',
216 'space.discover.closed': '暂不开放',
217 'space.discover.rejected': '上次申请未通过',
218 'space.discover.dialogTitle': '申请加入 {space}',
219 'space.discover.note': '申请附言(可选)',
220 'space.discover.noteHint': '简要说明你想加入的原因,最多 512 字。',
221 'space.discover.notePlaceholder': '我想加入这个社区,因为…',
222 'space.discover.submit': '提交申请',
223 'space.discover.submitted': '申请已提交,等待管理员审批。',
224 'space.discover.submitFailed': '申请提交失败',
225 'space.discover.withdrawFailed': '撤回失败',
226 'space.discover.cooldownError': '上次申请刚被驳回,请稍后再试。',
227 'space.discover.requirementError': '你还不满足该社区的加入门槛。',
228 'space.discover.notAllowedError': '该社区当前不接受申请。',
229 'space.discover.docTitle': '发现社区 · GitDataAI',
230 'space.joinRequests.title': '加入申请',
231 'space.joinRequests.sub': '审批想加入本社区的申请人,通过后他们会自动成为成员。',
232 'space.joinRequests.filter.pending': '待审批',
233 'space.joinRequests.filter.approved': '已通过',
234 'space.joinRequests.filter.rejected': '已拒绝',
235 'space.joinRequests.filter.all': '全部',
236 'space.joinRequests.empty': '还没有加入申请',
237 'space.joinRequests.emptyHint': '当有人申请加入时,你会在这里看到并收到通知。',
238 'space.joinRequests.loadFailed': '加入申请加载失败',
239 'space.joinRequests.note': '附言',
240 'space.joinRequests.noNote': '未填写附言',
241 'space.joinRequests.approve': '通过',
242 'space.joinRequests.reject': '拒绝',
243 'space.joinRequests.approved': '已通过 {name} 的申请。',
244 'space.joinRequests.rejected': '已拒绝 {name} 的申请。',
245 'space.joinRequests.status.pending': '待审批',
246 'space.joinRequests.status.approved': '已通过',
247 'space.joinRequests.status.rejected': '已拒绝',
248 'space.joinRequests.status.withdrawn': '已撤回',
249 'space.joinRequests.decidedBy': '审批人:{name}',
250 'space.joinRequests.requestedAt': '申请于 {time}',
251 'space.joinRequests.loadMore': '加载更多',
252 'space.joinRequests.count': '共 {count} 条',
253 'space.joinLevel.none': '无额外要求',
195254 'space.joinLevel.mail': '需验证邮箱',
196255 'space.joinLevel.mailDay': '需验证邮箱且账号满一天',
197256 'space.joinLevel.mailHour': '需验证邮箱且账号满一小时',
@@ -217,6 +276,7 @@
217276 'space.settings.sections.general': '社区资料',
218277 'space.settings.sections.images': '图标与横幅',
219278 'space.settings.sections.join': '加入规则',
279 'space.settings.sections.joinRequests': '加入申请',
220280 'space.settings.sections.bans': '封禁',
221281 'space.settings.fullLoadFailed': '读取完整社区资料失败,以下表单基于列表缓存,部分字段可能不是最新。',
222282 'space.general.title': '社区资料',
Modified web/src/shared/ui/app-shell.tsx web/src/shared/ui/app-shell.tsx +4 −2 View web/src/shared/ui/app-shell.tsx
@@ -10,10 +10,12 @@
1010 navPinned?: ReactNode
1111 nav?: ReactNode
1212 sideFooter?: ReactNode
13 /** Directory-style column (explore): the rail stays, the space sidebar goes. */
14 railOnly?: boolean
1315 children: ReactNode
1416}
1517
16export function AppShell({ rail, sideHeader, navPinned, nav, sideFooter, children }: AppShellProps) {
18export function AppShell({ rail, sideHeader, navPinned, nav, sideFooter, railOnly = false, children }: AppShellProps) {
1719 const { t } = useT()
1820 const [navigationOpen, setNavigationOpen] = useState(false)
1921 const [collapsed, setCollapsed] = useState(false)
@@ -49,7 +51,7 @@
4951 }, [])
5052
5153 return (
52 <div className={`workspace-shell ${collapsed ? 'navigation-collapsed' : ''} ${navigationOpen ? 'navigation-open' : ''}`}>
54 <div className={`workspace-shell ${collapsed ? 'navigation-collapsed' : ''} ${navigationOpen ? 'navigation-open' : ''} ${railOnly ? 'rail-only' : ''}`}>
5355 <button
5456 type="button"
5557 className="workspace-skip"
Modified web/src/shared/voice/session.ts web/src/shared/voice/session.ts +60 −1 View web/src/shared/voice/session.ts
@@ -97,6 +97,9 @@
9797 private leaving = false
9898 /** True once the media peer connection reached the connected state. */
9999 private hadMedia = false
100 /** Remote audio elements, keyed by track sid: a subscribed track is silent
101 * until it is attached to one, and the SDK never attaches it by itself. */
102 private readonly attachedAudio = new Map<string, { track: Track; element: HTMLMediaElement }>()
100103
101104 getSnapshot(): VoiceSessionSnapshot {
102105 return this.snapshot
@@ -214,6 +217,7 @@
214217 await this.primeMicrophone()
215218 }
216219 await room.connect(options.url, options.token)
220 this.adoptAudioTracks(room)
217221 this.patch({ status: 'connected' })
218222 this.persist(options.roomId)
219223 if (options.canPublish && options.startWithMic) {
@@ -221,6 +225,7 @@
221225 }
222226 } catch (error) {
223227 const message = error instanceof Error ? error.message : t('chat.voice.notice.connectFailed')
228 this.releaseAudio()
224229 this.room = null
225230 this.leaving = true
226231 this.patch({
@@ -257,12 +262,23 @@
257262 this.patch({ status: 'connected' })
258263 } else if (state === ConnectionState.Reconnecting) this.patch({ status: 'reconnecting' })
259264 })
260 .on(RoomEvent.Reconnected, () => this.patch({ status: 'connected', notice: null }))
265 .on(RoomEvent.Reconnected, () => {
266 // Re-subscribed tracks arrive as new objects; the stale elements go first.
267 this.releaseAudio()
268 this.adoptAudioTracks(room)
269 this.patch({ status: 'connected', notice: null })
270 })
261271 .on(RoomEvent.MediaDevicesError, (error: Error) => {
262272 this.patch({ notice: describeVoiceFailure(error.message) })
263273 })
264274 .on(RoomEvent.ParticipantConnected, () => refreshSelf())
265275 .on(RoomEvent.ParticipantDisconnected, () => refreshSelf())
276 .on(RoomEvent.TrackSubscribed, (track, publication) => {
277 this.attachAudio(track, publication.trackSid)
278 })
279 .on(RoomEvent.TrackUnsubscribed, (_track, publication) => {
280 this.detachAudio(publication.trackSid)
281 })
266282 .on(RoomEvent.LocalTrackPublished, () => refreshSelf())
267283 .on(RoomEvent.LocalTrackUnpublished, () => refreshSelf())
268284 .on(RoomEvent.TrackMuted, () => refreshSelf())
@@ -282,6 +298,7 @@
282298 reason === DisconnectReason.ROOM_DELETED ||
283299 reason === DisconnectReason.DUPLICATE_IDENTITY
284300 const intentional = this.leaving || reason === DisconnectReason.CLIENT_INITIATED
301 this.releaseAudio()
285302 const retainedNotice = this.snapshot.notice
286303 if (this.room === room) this.room = null
287304 if (leftForGood) this.persist(null)
@@ -310,6 +327,47 @@
310327 refreshSelf()
311328 }
312329
330 /**
331 * The room panel only renders video, so remote audio is adopted here — the
332 * session outlives every route, which keeps the call audible off the page.
333 */
334 private attachAudio(track: Track, sid: string): void {
335 if (track.kind !== Track.Kind.Audio) return
336 const existing = this.attachedAudio.get(sid)
337 // The same track must not be attached twice; a re-subscribed one replaces it.
338 if (existing?.track === track) return
339 if (existing) this.detachAudio(sid)
340 const element = track.attach()
341 element.style.display = 'none'
342 document.body.appendChild(element)
343 this.attachedAudio.set(sid, { track, element })
344 }
345
346 private detachAudio(sid: string): void {
347 const entry = this.attachedAudio.get(sid)
348 if (!entry) return
349 this.attachedAudio.delete(sid)
350 try {
351 entry.track.detach(entry.element)
352 } catch {
353 // Already detached by the SDK; the element still has to leave the DOM.
354 }
355 entry.element.remove()
356 }
357
358 private releaseAudio(): void {
359 for (const sid of [...this.attachedAudio.keys()]) this.detachAudio(sid)
360 }
361
362 /** Tracks subscribed while connecting or reconnecting never re-emit, so adopt them. */
363 private adoptAudioTracks(room: Room): void {
364 for (const participant of room.remoteParticipants.values()) {
365 for (const [sid, publication] of participant.audioTrackPublications) {
366 if (publication.track) this.attachAudio(publication.track, sid)
367 }
368 }
369 }
370
313371 private captureNames(remotes: RemoteParticipant[], local: LocalParticipant | null): void {
314372 for (const participant of remotes) {
315373 if (participant.name) this.participantNames.set(participant.identity, participant.name)
@@ -321,6 +379,7 @@
321379 const room = this.room
322380 this.leaving = true
323381 this.room = null
382 this.releaseAudio()
324383 this.persist(null)
325384 this.patch({
326385 status: 'idle',
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.
Modified web/steiger.config.js web/steiger.config.js +1 −0 View web/steiger.config.js
@@ -23,6 +23,7 @@
2323 'features/repo-ai',
2424 'features/repo-releases',
2525 'features/search',
26 'features/space-discovery',
2627 'features/space-management',
2728 'features/user-settings',
2829])
Modified openapi.json openapi.json View openapi.json
Diff too large to display.