diff --git a/.claude/rules/broadcast.md b/.claude/rules/broadcast.md index 4daa878..d662889 100644 --- a/.claude/rules/broadcast.md +++ b/.claude/rules/broadcast.md @@ -38,7 +38,7 @@ Every notification is wrapped in a versioned envelope: `{ v, seq, type, createdA |---|---|---|---| | `ChatMessage { message, room_preview_text, sender }` | all room members | new message (`sender: RoomMember`) | no | | `RoomChangeEvent { message, room_preview_text }` | all room members | join/leave/invite | no | -| `NewRoom { room, created_by }` | invited user | room creation / invite | no | +| `NewRoom { room, created_by, first_message }` | invited user | room creation / invite (`first_message`: optional, embedded on creation) | no | | `LeaveRoom { room_id }` | leaving user | user leaves room | no | | `FriendRequestReceived { from_user }` | target user | friend request sent | no | | `FriendRequestAccepted { from_user }` | requester | request accepted | no | diff --git a/CLAUDE.md b/CLAUDE.md index bb52eb7..10bfc2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,7 +128,7 @@ BroadcastChannel::get().unsubscribe(user_id).await; |---|---|---| | `ChatMessage { message, room_preview_text, sender }` | all room members | new message (`sender: RoomMember` so clients render a first-time sender without a lookup) | | `RoomChangeEvent { message, room_preview_text }` | all room members | join/leave/invite | -| `NewRoom { room, created_by }` | invited user | room creation / invite | +| `NewRoom { room, created_by, first_message }` | invited user | room creation / invite (`first_message`: optional first message, embedded on creation) | | `LeaveRoom { room_id }` | leaving user | user leaves room | | `FriendRequestReceived { from_user }` | target user | friend request sent | | `FriendRequestAccepted { from_user }` | requester | request accepted | diff --git a/src/broadcast/notification.rs b/src/broadcast/notification.rs index d3ed6ae..8bad4f5 100644 --- a/src/broadcast/notification.rs +++ b/src/broadcast/notification.rs @@ -69,10 +69,17 @@ pub enum NotificationEvent { SystemMessage { message: serde_json::Value }, /** - * Sending this event to a newly invited user + * Sending this event to a newly invited user. `first_message` carries the optional + * first message the room was created with (authored by `created_by`), so the client + * can render it immediately without a separate timeline fetch. `None` for invites + * and rooms created without a first message. */ #[serde(rename_all = "camelCase")] - NewRoom { room: ChatRoomDto, created_by: User }, + NewRoom { + room: ChatRoomDto, + created_by: User, + first_message: Option, + }, /** * Sending this event to a user who has left a room diff --git a/src/messaging/model.rs b/src/messaging/model.rs index fe0cc1c..d5bd955 100644 --- a/src/messaging/model.rs +++ b/src/messaging/model.rs @@ -180,6 +180,26 @@ impl Validate for NewMessageBody { } } +/// Body of the optional first message that can be sent together with a new room. +/// A brand-new room has no prior messages, so a `Reply` is impossible here — only +/// `Text` and `Media` (a link to a post) are valid. `chat_room_id` is intentionally +/// absent: the room id does not exist until the room has been created. +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(untagged)] +pub enum FirstMessageBody { + Text(TextBody), + Media(MediaBody), +} + +impl Validate for FirstMessageBody { + fn validate(&self) -> Result<(), validator::ValidationErrors> { + match self { + FirstMessageBody::Text(body) => body.validate(), + FirstMessageBody::Media(body) => body.validate(), + } + } +} + #[derive(Deserialize, Serialize, Debug, Clone, Validate)] #[serde(rename_all = "camelCase")] pub struct NewReplyBody { diff --git a/src/rooms/handler.rs b/src/rooms/handler.rs index d9143ef..409bca0 100644 --- a/src/rooms/handler.rs +++ b/src/rooms/handler.rs @@ -9,6 +9,7 @@ use crate::rooms::room::{ }; use crate::rooms::room_member::RoomMember; use crate::rooms::room_service::RoomService; +use crate::rooms::share_service::{ShareService, ShareTarget, ShareTargetCursor}; use crate::rooms::timeline_service::TimelineService; use crate::users::user_service::UserService; use crate::utils::check_user_in_room; @@ -21,6 +22,7 @@ use serde::Deserialize; use std::collections::HashSet; use std::sync::Arc; use uuid::Uuid; +use validator::Validate; #[derive(Deserialize, Debug)] pub struct RoomSearchQueryParam { @@ -76,6 +78,21 @@ pub async fn handle_get_joined_rooms( Ok(Json(rooms)) } +pub async fn handle_get_share_targets( + State(state): State>, + Extension(token): Extension>, + Query(params): Query, +) -> Result>, AppError> { + let cursor: ShareTargetCursor = decode_cursor(params.cursor) + .map_err(|_| AppError::Validation("Invalid Cursor-Parameters.".to_string()))?; + let page_size = clamp_page_size(params.limit); + + let targets = + ShareService::get_share_targets(state, token.subject, params.name, cursor, page_size) + .await?; + Ok(Json(targets)) +} + pub async fn handle_get_room_with_details( State(state): State>, Extension(token): Extension>, @@ -105,6 +122,10 @@ pub async fn handle_create_room( )); } + if let Some(first_message) = &payload.first_message { + first_message.validate().map_err(AppError::from)?; + } + //filter out all users that have an ignore-relationship with the sender let ignored = UserService::get_blocked_users(state.clone(), &token.subject, &payload.invited_users) diff --git a/src/rooms/mod.rs b/src/rooms/mod.rs index 16eb7a9..be068f4 100644 --- a/src/rooms/mod.rs +++ b/src/rooms/mod.rs @@ -5,4 +5,5 @@ pub mod room_member; pub mod room_repository; pub mod room_service; pub mod routes; +pub mod share_service; mod timeline_service; diff --git a/src/rooms/room.rs b/src/rooms/room.rs index 1fd8e87..8d9b5a9 100644 --- a/src/rooms/room.rs +++ b/src/rooms/room.rs @@ -1,3 +1,4 @@ +use crate::messaging::model::FirstMessageBody; use crate::rooms::room_member::RoomMember; use crate::utils::truncate_and_serialize; use chrono::prelude::*; @@ -99,6 +100,11 @@ pub struct NewRoom { pub room_type: RoomType, pub room_name: Option, pub invited_users: Vec, + /// Optional first message sent together with the room. Only `Text` or `Media` + /// (a link to a post) — never a `Reply`, since the room starts empty. Embedded + /// into the `NewRoom` broadcast event so recipients render it without a lookup. + #[serde(default)] + pub first_message: Option, } #[derive(Deserialize, Serialize, Debug, Clone)] diff --git a/src/rooms/room_repository.rs b/src/rooms/room_repository.rs index 9b0534c..63bd810 100644 --- a/src/rooms/room_repository.rs +++ b/src/rooms/room_repository.rs @@ -2,6 +2,7 @@ use crate::rooms::room::{ ChatRoomEntity, LastMessagePreviewText, NewRoom, RoomPaginationCursor, RoomType, }; use crate::rooms::room_member::RoomMember; +use crate::rooms::share_service::{ActiveShareRow, InactiveShareRow}; use chrono::{DateTime, Utc}; use sqlx::types::Json; use sqlx::{Error, PgConnection, Pool, Postgres, QueryBuilder, Transaction}; @@ -117,6 +118,136 @@ impl RoomRepository { Ok(rooms) } + /// *Active* section of the share-target list: group rooms the client is in, plus + /// friends with whom an 1-1 room already exists, merged and ordered by recent + /// activity (`active_at DESC`, `room_id` tie-breaker). Friends without a 1-1 room + /// are excluded here — they belong to the inactive section (`inactive_share_targets`), + /// so the two halves of the friend set never overlap. + /// + /// Optional case-insensitive name filter (friend `raw_name`, group `room_name`). + /// Keyset over `(active_at, room_id)`; callers pass `limit = page_size + 1`. + /// + /// Runtime query (not the `query_as!` macro) because of the optional cursor/name + /// binds — consistent with `get_joined_rooms` and the `UserRepository` queries. + pub async fn active_share_targets( + &self, + client_id: &Uuid, + name_filter: Option<&str>, + cursor_active_at: Option>, + cursor_id: Option, + limit: i64, + ) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, ActiveShareRow>( + r#" + SELECT name, room_id, image_url, active_at, is_group, user_id + FROM ( + -- Friends with an existing 1-1 room (the share target is that room). + SELECT + u.display_name AS name, + sr.room_id AS room_id, + u.profile_picture AS image_url, + sr.active_at AS active_at, + false AS is_group, + u.id AS user_id + FROM app_user u + JOIN user_relationship rl + ON u.id = CASE + WHEN rl.user_a_id = $1 THEN rl.user_b_id + WHEN rl.user_b_id = $1 THEN rl.user_a_id + END + AND rl.state = 'FRIEND' + JOIN LATERAL ( + SELECT r.id AS room_id, + COALESCE(r.latest_message, r.created_at) AS active_at + FROM chat_room r + JOIN chat_room_participant p1 ON p1.room_id = r.id AND p1.user_id = $1 + JOIN chat_room_participant p2 ON p2.room_id = r.id AND p2.user_id = u.id + WHERE r.room_type = 'Single' + LIMIT 1 + ) sr ON true + WHERE ($2::text IS NULL OR u.raw_name LIKE lower(concat('%', $2, '%'))) + + UNION ALL + + -- Group rooms the client is a member of. + SELECT + r.room_name AS name, + r.id AS room_id, + r.room_image_url AS image_url, + COALESCE(r.latest_message, r.created_at) AS active_at, + true AS is_group, + NULL::uuid AS user_id + FROM chat_room r + JOIN chat_room_participant p ON p.room_id = r.id AND p.user_id = $1 + WHERE r.room_type = 'Group' + AND ($2::text IS NULL OR r.room_name ILIKE concat('%', $2, '%')) + ) AS merged + WHERE ( + $3::timestamptz IS NULL + OR active_at < $3 + OR (active_at = $3 AND room_id < $4) + ) + ORDER BY active_at DESC, room_id DESC + LIMIT $5 + "#, + ) + .bind(client_id) + .bind(name_filter) + .bind(cursor_active_at) + .bind(cursor_id) + .bind(limit) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + /// *Inactive* section of the share-target list: friends the client has no 1-1 room + /// with yet (sharing requires creating the room first). Ordered alphabetically + /// (`display_name ASC`, `id` tie-breaker), keyset over `(display_name, id)`. + /// + /// The `NOT EXISTS` is the exact complement of the 1-1-room join in + /// `active_share_targets`, so every friend appears in exactly one of the two sections. + pub async fn inactive_share_targets( + &self, + client_id: &Uuid, + name_filter: Option<&str>, + cursor_name: Option, + cursor_id: Option, + limit: i64, + ) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, InactiveShareRow>( + r#" + SELECT u.display_name AS name, u.id AS user_id, u.profile_picture AS image_url + FROM app_user u + JOIN user_relationship rl + ON u.id = CASE + WHEN rl.user_a_id = $1 THEN rl.user_b_id + WHEN rl.user_b_id = $1 THEN rl.user_a_id + END + AND rl.state = 'FRIEND' + WHERE ($2::text IS NULL OR u.raw_name LIKE lower(concat('%', $2, '%'))) + AND NOT EXISTS ( + SELECT 1 + FROM chat_room r + JOIN chat_room_participant p1 ON p1.room_id = r.id AND p1.user_id = $1 + JOIN chat_room_participant p2 ON p2.room_id = r.id AND p2.user_id = u.id + WHERE r.room_type = 'Single' + ) + AND ($3::text IS NULL OR (u.display_name, u.id) > ($3, $4)) + ORDER BY u.display_name ASC, u.id ASC + LIMIT $5 + "#, + ) + .bind(client_id) + .bind(name_filter) + .bind(cursor_name) + .bind(cursor_id) + .bind(limit) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + pub async fn delete_room( &self, conn: &mut PgConnection, @@ -178,11 +309,18 @@ impl RoomRepository { Ok(room) } - pub async fn insert_room(&self, new_room: NewRoom) -> Result { + /// Inserts the room row and its participants on the given connection. The caller + /// owns the transaction so room creation can be made atomic together with an + /// optional first message (see `RoomService::create_room`). + pub async fn insert_room( + &self, + conn: &mut PgConnection, + new_room: &NewRoom, + ) -> Result { let room_entity = ChatRoomEntity { id: Uuid::new_v4(), - room_type: new_room.room_type, - room_name: new_room.room_name, + room_type: new_room.room_type.clone(), + room_name: new_room.room_name.clone(), room_image_url: None, created_at: Utc::now(), latest_message: Some(Utc::now()), @@ -190,9 +328,6 @@ impl RoomRepository { unread: None, }; - //https://docs.rs/sqlx/latest/sqlx/struct.Transaction.html - let mut tx = self.pool.begin().await?; - let room = sqlx::query_as!( ChatRoomEntity, r#" @@ -206,7 +341,7 @@ impl RoomRepository { room_entity.created_at, room_entity.latest_message, room_entity.latest_message_preview_text as Option> - ).fetch_one(&mut *tx).await?; + ).fetch_one(&mut *conn).await?; //https://docs.rs/sqlx-core/0.5.13/sqlx_core/query_builder/struct.QueryBuilder.html#method.push_values let mut builder: QueryBuilder = @@ -216,10 +351,9 @@ impl RoomRepository { db.push_bind(user).push_bind(&room.id).push_bind(Utc::now()); }) .build() - .fetch_all(&mut *tx) + .execute(&mut *conn) .await?; - tx.commit().await?; Ok(room) } diff --git a/src/rooms/room_service.rs b/src/rooms/room_service.rs index 1f7c3a3..96856ed 100644 --- a/src/rooms/room_service.rs +++ b/src/rooms/room_service.rs @@ -1,9 +1,11 @@ use crate::broadcast::NotificationEvent::{LeaveRoom, RoomChangeEvent, UserReadChat}; -use crate::broadcast::{BroadcastChannel, Notification}; +use crate::broadcast::{BroadcastChannel, Notification, NotificationEvent}; use crate::core::AppState; use crate::core::cursor::{CursorResults, next_cursor}; use crate::core::errors::AppError; -use crate::messaging::model::{MessageBody, MessageDto, MessageEntity, RoomChangeBody}; +use crate::messaging::model::{ + FirstMessageBody, MessageBody, MessageDto, MessageEntity, RoomChangeBody, +}; use crate::rooms::model::UploadResponse; use crate::rooms::room::{ ChatRoomDto, ChatRoomEntity, ChatRoomWithUserDTO, LastMessagePreviewText, NewRoom, @@ -138,12 +140,49 @@ impl RoomService { client_id: Uuid, new_room: NewRoom, ) -> Result { - let room_entity = state.room_repository.insert_room(new_room.clone()).await?; let creator_entity = state .user_repository .find_user_by_id(&client_id) .await? .ok_or_else(|| AppError::NotFound("UserID not found.".to_string()))?; + + // Atomic: room + participants (+ optional first message) are created together, + // so a failing message insert never leaves a half-created room behind. + let mut tx = state.room_repository.start_transaction().await?; + let room_entity = state + .room_repository + .insert_room(&mut tx, &new_room) + .await?; + + let first_message = match &new_room.first_message { + Some(body) => { + let msg_body = match body.clone() { + FirstMessageBody::Text(text) => MessageBody::Text(text), + FirstMessageBody::Media(media) => MessageBody::Media(media), + }; + let entity = MessageEntity::new(room_entity.id, client_id, msg_body); + let preview_text = + first_message_preview_text(body, creator_entity.display_name.clone()); + state + .chat_repository + .insert_message(&mut *tx, &entity) + .await?; + state + .room_repository + .apply_message_to_room( + &mut tx, + &room_entity.id, + &preview_text, + &entity.sender_id, + entity.created_at, + ) + .await?; + Some(MessageDto::from(entity)) + } + None => None, + }; + tx.commit().await?; + let users = new_room.invited_users; if room_entity.room_type == RoomType::Single { @@ -168,9 +207,10 @@ impl RoomService { broadcast .send_event( - Notification::new(crate::broadcast::NotificationEvent::NewRoom { + Notification::new(NotificationEvent::NewRoom { room: participator_room.to_dto(), created_by: creator_entity.clone(), + first_message: first_message.clone(), }), other_user, ) @@ -178,9 +218,10 @@ impl RoomService { broadcast .send_event( - Notification::new(crate::broadcast::NotificationEvent::NewRoom { + Notification::new(NotificationEvent::NewRoom { room: creator_room.to_dto(), created_by: creator_entity, + first_message, }), &client_id, ) @@ -198,9 +239,10 @@ impl RoomService { BroadcastChannel::get() .send_event_to_all( users, - Notification::new(crate::broadcast::NotificationEvent::NewRoom { + Notification::new(NotificationEvent::NewRoom { room: room_dto.clone(), created_by: creator_entity.clone(), + first_message, }), ) .await; @@ -331,9 +373,10 @@ impl RoomService { BroadcastChannel::get() .send_event( - Notification::new(crate::broadcast::NotificationEvent::NewRoom { + Notification::new(NotificationEvent::NewRoom { room: room_for_user.to_dto(), created_by: creator_entity, + first_message: None, }), &user.id, ) @@ -385,6 +428,25 @@ impl RoomService { } } +/// Builds the room preview text for an optional first message sent on room creation. +/// Mirrors `MessageService::generate_room_preview_text`, but for the restricted +/// `FirstMessageBody` (no `Reply` in a brand-new room). +fn first_message_preview_text( + body: &FirstMessageBody, + sender_username: String, +) -> LastMessagePreviewText { + match body { + FirstMessageBody::Text(text) => LastMessagePreviewText::Text { + sender_username, + text: text.text.clone(), + }, + FirstMessageBody::Media(media) => LastMessagePreviewText::Media { + sender_username, + media_type: media.media_type.clone(), + }, + } +} + // Helper used by `get_read_states` — extracted for easier unit testing of the read logic. fn user_has_read(user: &RoomMember, room_latest: Option>) -> bool { match (room_latest, user.last_message_read_at) { diff --git a/src/rooms/routes.rs b/src/rooms/routes.rs index 421c4bc..ce9bc95 100644 --- a/src/rooms/routes.rs +++ b/src/rooms/routes.rs @@ -1,9 +1,9 @@ use crate::core::AppState; use crate::rooms::handler::{ handle_create_room, handle_get_joined_rooms, handle_get_read_states, - handle_get_room_list_item_by_id, handle_get_room_with_details, handle_get_users_in_room, - handle_invite_to_room, handle_leave_room, handle_save_room_image, handle_scroll_chat_timeline, - handle_search_existing_single_room, mark_room_as_read, + handle_get_room_list_item_by_id, handle_get_room_with_details, handle_get_share_targets, + handle_get_users_in_room, handle_invite_to_room, handle_leave_room, handle_save_room_image, + handle_scroll_chat_timeline, handle_search_existing_single_room, mark_room_as_read, }; use axum::Router; use axum::routing::{get, post}; @@ -13,12 +13,22 @@ pub fn create_room_routes() -> Router> { Router::new() .route("/rooms/create-room", post(handle_create_room)) .route("/rooms/{room_id}/users", get(handle_get_users_in_room)) - .route("/rooms/{room_id}/detailed", get(handle_get_room_with_details)) - .route("/rooms/{room_id}/timeline", get(handle_scroll_chat_timeline)) + .route( + "/rooms/{room_id}/detailed", + get(handle_get_room_with_details), + ) + .route( + "/rooms/{room_id}/timeline", + get(handle_scroll_chat_timeline), + ) .route("/rooms/{room_id}", get(handle_get_room_list_item_by_id)) .route("/rooms/{room_id}/leave", post(handle_leave_room)) .route("/rooms/search", get(handle_search_existing_single_room)) - .route("/rooms/{room_id}/invite/{user_id}", post(handle_invite_to_room)) + .route("/rooms/share-targets", get(handle_get_share_targets)) + .route( + "/rooms/{room_id}/invite/{user_id}", + post(handle_invite_to_room), + ) .route("/rooms/{room_id}/upload-img", post(handle_save_room_image)) .route("/rooms", get(handle_get_joined_rooms)) .route("/rooms/{room_id}/mark-read", post(mark_room_as_read)) diff --git a/src/rooms/share_service.rs b/src/rooms/share_service.rs new file mode 100644 index 0000000..6933fe8 --- /dev/null +++ b/src/rooms/share_service.rs @@ -0,0 +1,225 @@ +use crate::core::AppState; +use crate::core::cursor::{CursorResults, encode_cursor}; +use crate::core::errors::AppError; +use crate::rooms::room::RoomType; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use uuid::Uuid; + +/// Row of the *active* share-target section: a room the client can already send to — +/// either a group room or a friend's existing 1-1 room. Ordered by `active_at DESC`. +/// `user_id` is the friend behind a 1-1 room (`None` for groups); the share target is +/// always the existing `room_id`. Populated by `RoomRepository::active_share_targets`. +#[derive(Debug, sqlx::FromRow)] +pub struct ActiveShareRow { + /// Display name of the other user (1-1) or the room name (group); groups may be unnamed. + pub name: Option, + pub room_id: Uuid, + pub image_url: Option, + pub active_at: DateTime, + pub is_group: bool, + pub user_id: Option, +} + +/// Row of the *inactive* share-target section: a friend the client has no 1-1 room with +/// yet. Ordered by `display_name ASC`. Sharing requires creating the room first. +/// Populated by `RoomRepository::inactive_share_targets`. +#[derive(Debug, sqlx::FromRow)] +pub struct InactiveShareRow { + pub name: String, + pub user_id: Uuid, + pub image_url: Option, +} + +/// A single suggestion of where the client can send shared content (like an Instagram +/// "share to chat" sheet). Merges friends and group rooms into one list; `target` tells +/// the client whether to post into an existing room or to create one first. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ShareTarget { + /// Other user's display name (1-1) or the room name (group); a group may be unnamed. + pub name: Option, + pub image_url: Option, + pub target: ShareTargetRef, +} + +/// What the client must do to deliver content to a [`ShareTarget`]. +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ShareTargetRef { + /// An existing room — share via `POST /api/send-msg` with this `roomId`. + Room { room_id: Uuid, room_type: RoomType }, + /// A friend without a 1-1 room yet — create it via `POST /api/rooms/create-room` + /// (`NewRoom`) for this `userId`, then send into the returned room. + User { user_id: Uuid }, +} + +impl ShareTarget { + fn from_active(row: ActiveShareRow) -> Self { + let room_type = if row.is_group { + RoomType::Group + } else { + RoomType::Single + }; + ShareTarget { + name: row.name, + image_url: row.image_url, + target: ShareTargetRef::Room { + room_id: row.room_id, + room_type, + }, + } + } + + fn from_inactive(row: InactiveShareRow) -> Self { + ShareTarget { + name: Some(row.name), + image_url: row.image_url, + target: ShareTargetRef::User { + user_id: row.user_id, + }, + } + } +} + +/// Which section of the merged share list the next page resumes in. The list is +/// two-phase: active rooms first, then inactive friends. +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SharePhase { + /// Rooms with activity (groups + friends with an existing 1-1 room), `active_at DESC`. + #[default] + Active, + /// Friends without a 1-1 room, `displayName ASC`. + Inactive, +} + +/// Keyset cursor for the two-phase share-target list. The active section paginates over +/// `(active_at, room_id) DESC`; once it is exhausted the inactive section paginates over +/// `(name, user_id) ASC`. `phase` records which section the next page resumes in; the +/// default (`Active`, no bounds) starts at the top of the list. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ShareTargetCursor { + pub phase: SharePhase, + pub last_active_at: Option>, + pub last_name: Option, + pub last_id: Option, +} + +pub struct ShareService; + +impl ShareService { + /// Builds one page of share targets by merging two sources into a single + /// cursor-paginated list: + /// 1. **Active** — group rooms + friends with an existing 1-1 room, ordered by + /// recent activity. These resolve to an existing `room_id`. + /// 2. **Inactive** — friends without a 1-1 room, ordered alphabetically. These + /// require a `NewRoom` POST before a message can be sent. + /// + /// The two sections have different sort axes, so each is a focused keyset query + /// (`active_share_targets` / `inactive_share_targets`) and the cursor's `phase` + /// records which one to resume. A boundary page may run both queries to fill up to + /// `page_size`; all other pages run exactly one. + pub async fn get_share_targets( + state: Arc, + client_id: Uuid, + name_filter: Option, + cursor: ShareTargetCursor, + page_size: usize, + ) -> Result, AppError> { + let name = name_filter.as_deref(); + let mut content: Vec = Vec::with_capacity(page_size); + + // ── Phase 1: active section (rooms with recent activity) ────────────── + if cursor.phase == SharePhase::Active { + let mut rows = state + .room_repository + .active_share_targets( + &client_id, + name, + cursor.last_active_at, + cursor.last_id, + (page_size + 1) as i64, + ) + .await?; + + if rows.len() > page_size { + // More active rows remain — stay in the active phase. + rows.truncate(page_size); + let next = rows.last().map(|last| ShareTargetCursor { + phase: SharePhase::Active, + last_active_at: Some(last.active_at), + last_name: None, + last_id: Some(last.room_id), + }); + content.extend(rows.into_iter().map(ShareTarget::from_active)); + return Self::encode(content, next); + } + + // Active section fits entirely on this page. + content.extend(rows.into_iter().map(ShareTarget::from_active)); + + if content.len() >= page_size { + // Page already full; the inactive section starts on the next page. + let next = ShareTargetCursor { + phase: SharePhase::Inactive, + ..Default::default() + }; + return Self::encode(content, Some(next)); + } + // Otherwise fall through and fill the remainder from the inactive section. + } + + // ── Phase 2: inactive section (friends without a 1-1 room) ──────────── + let remaining = page_size - content.len(); + // Resuming mid-inactive keeps the cursor bounds; arriving from the active phase + // starts the inactive section from the beginning. + let (cursor_name, cursor_id) = if cursor.phase == SharePhase::Inactive { + (cursor.last_name.clone(), cursor.last_id) + } else { + (None, None) + }; + + let mut rows = state + .room_repository + .inactive_share_targets( + &client_id, + name, + cursor_name, + cursor_id, + (remaining + 1) as i64, + ) + .await?; + + let next = if rows.len() > remaining { + rows.truncate(remaining); + rows.last().map(|last| ShareTargetCursor { + phase: SharePhase::Inactive, + last_active_at: None, + last_name: Some(last.name.clone()), + last_id: Some(last.user_id), + }) + } else { + None + }; + + content.extend(rows.into_iter().map(ShareTarget::from_inactive)); + Self::encode(content, next) + } + + fn encode( + content: Vec, + next: Option, + ) -> Result, AppError> { + let cursor = match next { + Some(c) => Some( + encode_cursor(&c) + .map_err(|e| AppError::Processing(format!("Cursor encoding failed: {e}")))?, + ), + None => None, + }; + Ok(CursorResults { cursor, content }) + } +}