feat: rank personal rooms by visit frequency

pull/370/head
zijiren233 2 months ago
parent 913e0e7fb0
commit 5d891b62ec
No known key found for this signature in database
GPG Key ID: 534E082AAA9B39DC

@ -16,6 +16,10 @@ CREATE TABLE IF NOT EXISTS room_members (
display_tag VARCHAR(16) NOT NULL DEFAULT '',
joined_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_visited_at TIMESTAMPTZ,
last_counted_visit_at TIMESTAMPTZ,
visit_count BIGINT NOT NULL DEFAULT 0
CHECK (visit_count >= 0),
version BIGINT NOT NULL DEFAULT 0,
@ -28,4 +32,6 @@ CREATE INDEX IF NOT EXISTS idx_room_members_room ON room_members(room_id);
CREATE INDEX IF NOT EXISTS idx_room_members_user_active
ON room_members(user_id, room_id, role, joined_at DESC);
CREATE INDEX IF NOT EXISTS idx_room_members_role
ON room_members(room_id, role);
ON room_members(room_id, role);
CREATE INDEX IF NOT EXISTS idx_room_members_user_frequency
ON room_members(user_id, visit_count DESC, last_visited_at DESC NULLS LAST, room_id);

@ -1035,6 +1035,10 @@ impl ClientApiImpl {
.create_ticket_with_control(user_id, &room_id, password_version, request_control)
.await
.map_err(ApiError::from)?;
self.room_service
.record_room_visit(&room_id, user_id)
.await
.map_err(ApiError::from)?;
let public_room_id = self
.public_id_codec
@ -1088,7 +1092,7 @@ impl ClientApiImpl {
.await
.map_err(ApiError::from)?
.version;
ws_ticket_service
let ticket = ws_ticket_service
.create_ticket_with_control(
&user_id,
&room_id,
@ -1096,7 +1100,12 @@ impl ClientApiImpl {
request_control,
)
.await
.map_err(ApiError::from)?
.map_err(ApiError::from)?;
self.room_service
.record_room_visit(&room_id, &user_id)
.await
.map_err(ApiError::from)?;
ticket
}
RoomActor::Guest(access) => ws_ticket_service
.create_guest_ticket_with_control(
@ -2817,7 +2826,7 @@ mod tests {
assert_eq!(query.relation, synctv_core::models::MyRoomRelation::All);
assert_eq!(
query.sort_by,
synctv_core::models::MyRoomListSortBy::JoinedAt
synctv_core::models::MyRoomListSortBy::Frequent
);
assert_eq!(
query.sort_direction,

@ -100,7 +100,13 @@ pub(super) fn proto_my_room_list_sort_by(
ApiError::InvalidInput("Unsupported related room list sort field".to_string())
})? {
synctv_proto::client::MyRoomListSortBy::Unspecified
| synctv_proto::client::MyRoomListSortBy::JoinedAt => {
| synctv_proto::client::MyRoomListSortBy::Frequent => {
Ok(synctv_core::models::MyRoomListSortBy::Frequent)
}
synctv_proto::client::MyRoomListSortBy::LastVisitedAt => {
Ok(synctv_core::models::MyRoomListSortBy::LastVisitedAt)
}
synctv_proto::client::MyRoomListSortBy::JoinedAt => {
Ok(synctv_core::models::MyRoomListSortBy::JoinedAt)
}
synctv_proto::client::MyRoomListSortBy::Name => {

@ -347,7 +347,7 @@ mod tests {
"last_activity_at",
"r.last_activity_at"
);
assert_eq!(MyRoomListSortBy::default(), MyRoomListSortBy::JoinedAt);
assert_eq!(MyRoomListSortBy::default(), MyRoomListSortBy::Frequent);
assert_sort_field!(
NotificationListSortBy,

@ -173,6 +173,11 @@ sort_field_enum! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MyRoomListSortBy {
Frequent => { display: "frequent", sql: "rm.visit_count" },
LastVisitedAt => {
display: "last_visited_at",
sql: "rm.last_visited_at"
},
Name => { display: "name", sql: "r.name" },
CreatedAt => { display: "created_at", sql: "r.created_at" },
UpdatedAt => { display: "updated_at", sql: "r.updated_at" },
@ -182,7 +187,7 @@ sort_field_enum! {
},
JoinedAt => { display: "joined_at", sql: "rm.joined_at" },
}
default = JoinedAt;
default = Frequent;
error = "Unknown related room list field";
}
@ -218,7 +223,7 @@ impl Default for MyRoomListQuery {
status: None,
is_banned: None,
relation: MyRoomRelation::All,
sort_by: MyRoomListSortBy::JoinedAt,
sort_by: MyRoomListSortBy::Frequent,
sort_direction: SortDirection::Desc,
}
}

@ -1096,7 +1096,12 @@ impl RoomRepository {
Self::push_where_prefix(&mut list_builder, &mut has_condition);
list_builder.push(ACCESSIBLE_ROOM_CREATOR_CONDITION);
list_builder
.push(" ORDER BY rf.created_at DESC, r.id DESC LIMIT ")
.push(
" ORDER BY rm.visit_count DESC,
rm.last_visited_at DESC NULLS LAST,
rf.created_at DESC,
r.id DESC LIMIT ",
)
.push_bind(limit)
.push(" OFFSET ")
.push_bind(offset);

@ -358,6 +358,18 @@ impl RoomMemberRepository {
use crate::models::SortDirection;
match (query.sort_by, query.sort_direction) {
(MyRoomListSortBy::Frequent, SortDirection::Asc) => {
"rm.visit_count ASC, rm.last_visited_at ASC NULLS FIRST, r.id ASC"
}
(MyRoomListSortBy::Frequent, SortDirection::Desc) => {
"rm.visit_count DESC, rm.last_visited_at DESC NULLS LAST, r.id DESC"
}
(MyRoomListSortBy::LastVisitedAt, SortDirection::Asc) => {
"rm.last_visited_at ASC NULLS FIRST, rm.visit_count ASC, r.id ASC"
}
(MyRoomListSortBy::LastVisitedAt, SortDirection::Desc) => {
"rm.last_visited_at DESC NULLS LAST, rm.visit_count DESC, r.id DESC"
}
(MyRoomListSortBy::JoinedAt, SortDirection::Asc) => "rm.joined_at ASC, r.id ASC",
(MyRoomListSortBy::JoinedAt, SortDirection::Desc) => "rm.joined_at DESC, r.id DESC",
(MyRoomListSortBy::Name, SortDirection::Asc) => "r.name ASC, r.id ASC",
@ -422,6 +434,38 @@ impl RoomMemberRepository {
Ok(added)
}
/// Record a room visit and increment its frequency counter when the
/// previous counted visit falls outside the application-defined window.
pub async fn record_visit(
&self,
room_id: &RoomId,
user_id: &UserId,
counted_before: chrono::DateTime<chrono::Utc>,
visited_at: chrono::DateTime<chrono::Utc>,
) -> Result<bool> {
let result = sqlx::query(
r"UPDATE room_members
SET visit_count = visit_count + CASE
WHEN last_counted_visit_at IS NULL OR last_counted_visit_at <= $3 THEN 1
ELSE 0
END,
last_counted_visit_at = CASE
WHEN last_counted_visit_at IS NULL OR last_counted_visit_at <= $3 THEN $4
ELSE last_counted_visit_at
END,
last_visited_at = $4
WHERE room_id = $1 AND user_id = $2",
)
.bind(room_id)
.bind(user_id)
.bind(counted_before)
.bind(visited_at)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
/// Add user to room using a provided transaction connection.
///
/// Accepts `&mut PgConnection` so callers can keep room lifecycle mutations
@ -2595,7 +2639,7 @@ impl RoomMemberRepository {
r.created_at, r.updated_at, r.deleted_at, r.version, r.last_activity_at,
rc.id, rc.key, rc.name, rc.description, rc.sort_order, rc.is_enabled,
rc.created_at, rc.updated_at,
rm.role, rm.joined_at
rm.role, rm.joined_at, rm.last_visited_at, rm.visit_count
ORDER BY {order_by_sql}
LIMIT $2 OFFSET $3
"
@ -2679,7 +2723,7 @@ impl RoomMemberRepository {
r.created_at, r.updated_at, r.deleted_at, r.version, r.last_activity_at,
rc.id, rc.key, rc.name, rc.description, rc.sort_order, rc.is_enabled,
rc.created_at, rc.updated_at,
rm.role, rm.joined_at
rm.role, rm.joined_at, rm.last_visited_at, rm.visit_count
ORDER BY {order_by_sql}
LIMIT $2 OFFSET $3
"

@ -17,6 +17,8 @@ use crate::{
};
impl RoomService {
const ROOM_VISIT_COUNT_INTERVAL_SECS: i64 = 30 * 60;
#[must_use]
pub const fn pool(&self) -> &PgPool {
&self.pool
@ -112,6 +114,22 @@ impl RoomService {
self.room_repo.exists(room_id).await
}
/// Record that an authenticated member entered a room.
pub async fn record_room_visit(&self, room_id: &RoomId, user_id: &UserId) -> Result<()> {
let visited_at = self.clock.now();
let counted_before =
visited_at - chrono::Duration::seconds(Self::ROOM_VISIT_COUNT_INTERVAL_SECS);
let recorded = self
.member_repo
.record_visit(room_id, user_id, counted_before, visited_at)
.await?;
if recorded {
Ok(())
} else {
Err(Error::NotFound("Room membership not found".to_string()))
}
}
pub async fn get_room(&self, room_id: &RoomId) -> Result<Room> {
let mut room = self
.room_repo

@ -119,6 +119,148 @@ async fn test_add_with_options_full_flow() {
assert_eq!(result.status, MemberStatus::Active);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_record_visit_deduplicates_and_drives_room_sorting() {
let (_container, pool) = create_test_pool().await;
let user_repo = UserRepository::new(pool.clone());
let room_repo = RoomRepository::new(pool.clone());
let member_repo = RoomMemberRepository::new(pool.clone());
let owner = user_repo
.create(&make_user("owner_room_visits"))
.await
.checked("test operation should succeed");
let visitor = user_repo
.create(&make_user("visitor_room_visits"))
.await
.checked("test operation should succeed");
let frequent_room = room_repo
.create(&make_room("Frequent Room", &owner.id))
.await
.checked("test operation should succeed");
let recent_room = room_repo
.create(&make_room("Recent Room", &owner.id))
.await
.checked("test operation should succeed");
for room in [&frequent_room, &recent_room] {
member_repo
.add(&make_member(room.id, visitor.id, RoomRole::Member))
.await
.checked("test operation should succeed");
}
let first_visit = Utc::now() - chrono::Duration::hours(1);
assert!(member_repo
.record_visit(
&frequent_room.id,
&visitor.id,
first_visit - chrono::Duration::minutes(30),
first_visit,
)
.await
.checked("first visit should be recorded"));
let duplicate_visit = first_visit + chrono::Duration::minutes(5);
member_repo
.record_visit(
&frequent_room.id,
&visitor.id,
duplicate_visit - chrono::Duration::minutes(30),
duplicate_visit,
)
.await
.checked("duplicate visit should refresh recency");
let reconnect_visit = first_visit + chrono::Duration::minutes(20);
member_repo
.record_visit(
&frequent_room.id,
&visitor.id,
reconnect_visit - chrono::Duration::minutes(30),
reconnect_visit,
)
.await
.checked("reconnect should preserve the original counting window");
let second_counted_visit = first_visit + chrono::Duration::minutes(40);
member_repo
.record_visit(
&frequent_room.id,
&visitor.id,
second_counted_visit - chrono::Duration::minutes(30),
second_counted_visit,
)
.await
.checked("visit outside the counting window should increment frequency");
let most_recent_visit = first_visit + chrono::Duration::minutes(45);
member_repo
.record_visit(
&recent_room.id,
&visitor.id,
most_recent_visit - chrono::Duration::minutes(30),
most_recent_visit,
)
.await
.checked("recent room visit should be recorded");
let visit_count: i64 = sqlx::query_scalar(
"SELECT visit_count FROM room_members WHERE room_id = $1 AND user_id = $2",
)
.bind(frequent_room.id)
.bind(visitor.id)
.fetch_one(&pool)
.await
.checked("visit count should be readable");
assert_eq!(visit_count, 2);
room_repo
.favorite_for_user(&visitor.id, &frequent_room.id)
.await
.checked("frequent room should be favorited");
room_repo
.favorite_for_user(&visitor.id, &recent_room.id)
.await
.checked("recent room should be favorited");
let (favorite_results, favorite_total) = room_repo
.list_favorites_for_user(&visitor.id, PageParams::new(Some(1), Some(10)), None)
.await
.checked("favorite room sorting should succeed");
assert_eq!(favorite_total, 2);
assert_eq!(favorite_results[0].id, frequent_room.id);
let base_query = MyRoomListQuery {
pagination: PageParams::new(Some(1), Some(10)),
sort_direction: SortDirection::Desc,
..Default::default()
};
let (frequent_results, _) = member_repo
.list_by_user_with_query(
&visitor.id,
&MyRoomListQuery {
sort_by: MyRoomListSortBy::Frequent,
..base_query.clone()
},
)
.await
.checked("frequent room sorting should succeed");
assert_eq!(frequent_results[0].0.id, frequent_room.id);
let (recent_results, _) = member_repo
.list_by_user_with_query(
&visitor.id,
&MyRoomListQuery {
sort_by: MyRoomListSortBy::LastVisitedAt,
..base_query
},
)
.await
.checked("recent room sorting should succeed");
assert_eq!(recent_results[0].0.id, recent_room.id);
}
#[tokio::test]
#[ignore = "Requires Docker"]
async fn test_add_with_options_capacity_at_max_members() {

@ -1426,11 +1426,13 @@ enum MediaListSortBy {
enum MyRoomListSortBy {
MY_ROOM_LIST_SORT_BY_UNSPECIFIED = 0;
MY_ROOM_LIST_SORT_BY_JOINED_AT = 1;
MY_ROOM_LIST_SORT_BY_CREATED_AT = 2;
MY_ROOM_LIST_SORT_BY_UPDATED_AT = 3;
MY_ROOM_LIST_SORT_BY_LAST_ACTIVITY_AT = 4;
MY_ROOM_LIST_SORT_BY_NAME = 5;
MY_ROOM_LIST_SORT_BY_FREQUENT = 1;
MY_ROOM_LIST_SORT_BY_LAST_VISITED_AT = 2;
MY_ROOM_LIST_SORT_BY_JOINED_AT = 3;
MY_ROOM_LIST_SORT_BY_CREATED_AT = 4;
MY_ROOM_LIST_SORT_BY_UPDATED_AT = 5;
MY_ROOM_LIST_SORT_BY_LAST_ACTIVITY_AT = 6;
MY_ROOM_LIST_SORT_BY_NAME = 7;
}
enum MyRoomRelation {

Loading…
Cancel
Save