Expand 305 hidden lines 306 306 307 307 308 308 309 + # [ allow ( clippy :: too_many_lines ) ] 309 310 pub async fn user_page ( 310 311 State ( state) : State < AppState > , 311 312 MaybeUser ( viewer) : MaybeUser , Expand 8 hidden lines 320 321 let tab = match lq. tab. as_deref ( ) { 321 322 Some ( "groups" ) => ProfileTab :: Groups , 322 323 Some ( "bookmarks" ) => ProfileTab :: Bookmarks , 324 + Some ( "followers" ) => ProfileTab :: Followers , 325 + Some ( "following" ) => ProfileTab :: Following , 323 326 _ => ProfileTab :: Repos , 324 327 } ; 325 328 let q = lq. q. unwrap_or_default ( ) ; Expand 58 hidden lines 384 387 let entries = resolve_bookmark_labels ( & state, & visible, entries) . await ; 385 388 html ! { ( repo_card ( "" , "No bookmarks yet." , & entries) ) } 386 389 } 390 + ProfileTab :: Followers => { 391 + let users = state. store. followers ( & owner. id) . await ?; 392 + user_list ( & state, & users, viewer. as_ref ( ) , "No followers yet." ) . await ? 393 + } 394 + ProfileTab :: Following => { 395 + let users = state. store. following ( & owner. id) . await ?; 396 + user_list ( & state, & users, viewer. as_ref ( ) , "Not following anyone yet." ) . await ? 397 + } 398 + } ; 399 + 400 + let followers = state. store. count_followers ( & owner. id) . await ?; 401 + let following = state. store. count_following ( & owner. id) . await ?; 402 + let follow = match & viewer { 403 + Some ( v) if v. id != owner. id => Some ( 404 + state 405 + . store 406 + . is_following ( & v. id, & owner. id) 407 + . await 408 + . unwrap_or ( false ) , 409 + ) , 410 + _ => None , 387 411 } ; 388 412 389 413 let ( jar, chrome) = build_chrome ( & state, jar, viewer. clone ( ) , uri. path ( ) . to_string ( ) ) ; Expand 4 hidden lines 394 418 } ; 395 419 let body = html ! { 396 420 div class="profile-layout" { 397 - aside class="profile-sidebar" { ( profile_sidebar ( & owner) ) } 421 + aside class="profile-sidebar" { ( profile_sidebar ( & owner, followers, following, follow) ) } 398 422 div class="profile-main" { 399 423 nav class="tabs profile-tabs" { 400 424 ( ptab ( ProfileTab :: Repos , "repos" , "Repositories" ) ) Expand 7 hidden lines 408 432 Ok ( ( jar, page ( & chrome, & owner. username, body) ) . into_response ( ) ) 409 433 } 410 434 411 - 435 + 436 + 412 437 # [ derive ( Debug , Clone , Copy , PartialEq , Eq ) ] 413 438 enum ProfileTab { 414 439 Repos , 415 440 Groups , 416 441 Bookmarks , 442 + Followers , 443 + Following , 444 + } 445 + 446 + 447 + 448 + async fn user_list ( 449 + state : & AppState , 450 + users : & [ User ] , 451 + viewer : Option < & User > , 452 + empty : & str , 453 + ) -> AppResult < Markup > { 454 + if users. is_empty ( ) { 455 + return Ok ( html ! { div class="card" { p class="muted" { ( empty) } } } ) ; 456 + } 457 + 458 + let mut following: std:: collections:: HashSet < String > = std:: collections:: HashSet :: new ( ) ; 459 + if let Some ( v) = viewer { 460 + for u in users { 461 + if u. id != v. id 462 + && state 463 + . store 464 + . is_following ( & v. id, & u. id) 465 + . await 466 + . unwrap_or ( false ) 467 + { 468 + following. insert ( u. id. clone ( ) ) ; 469 + } 470 + } 471 + } 472 + Ok ( html ! { 473 + ul class="user-list card" { 474 + @for u in users { 475 + @let name = u. display_name. as_deref ( ) . unwrap_or ( & u. username) ; 476 + li class="user-row" { 477 + img class="avatar" src={ "/avatar/" ( u. username) } alt="" ; 478 + div class="user-row-body" { 479 + div class="user-row-title" { 480 + a class="user-row-name" href={ "/" ( u. username) } { ( name) } 481 + " " span class="muted" { ( u. username) } 482 + } 483 + @if let Some ( bio) = non_empty ( u. bio. as_ref ( ) ) { 484 + p class="user-row-bio muted" { ( bio) } 485 + } 486 + @if let Some ( loc) = non_empty ( u. location. as_ref ( ) ) { 487 + p class="user-row-loc muted" { ( icon ( Icon :: MapPin ) ) " " ( loc ) } 488 + } 489 + } 490 + @ if let Some ( v ) = viewer { 491 + @ if v . id != u . id { 492 + a class= ( if following . contains ( & u . id ) { " btn" } else { "btn btn-primary" } ) 493 + href={ "/user-follow/" ( u. username) } { 494 + ( if following. contains ( & u. id) { "Unfollow" } else { "Follow" } ) 495 + } 496 + } 497 + } 498 + } 499 + } 500 + } 501 + } ) 417 502 } 418 503 419 504 Expand 1334 hidden lines 1754 1839 Ok ( Redirect :: to ( & format ! ( "/{owner}/{}" , repo. path) ) . into_response ( ) ) 1755 1840 } 1756 1841 1842 + 1843 + 1844 + pub async fn follow_toggle ( 1845 + State ( state) : State < AppState > , 1846 + MaybeUser ( viewer) : MaybeUser , 1847 + Path ( username) : Path < String > , 1848 + ) -> AppResult < Response > { 1849 + let Some ( me) = viewer else { 1850 + return Ok ( Redirect :: to ( "/login" ) . into_response ( ) ) ; 1851 + } ; 1852 + let Some ( target) = state. store. user_by_username ( & username) . await ? else { 1853 + return Err ( AppError :: NotFound ) ; 1854 + } ; 1855 + if target. id != me. id { 1856 + if state. store. is_following ( & me. id, & target. id) . await ? { 1857 + state. store. unfollow ( & me. id, & target. id) . await ?; 1858 + } else { 1859 + state. store. follow ( & me. id, & target. id) . await ?; 1860 + } 1861 + } 1862 + Ok ( Redirect :: to ( & format ! ( "/{}" , target. username) ) . into_response ( ) ) 1863 + } 1864 + 1757 1865 1758 1866 pub async fn fork_confirm ( 1759 1867 State ( state) : State < AppState > , Expand 1062 hidden lines 2822 2930 2823 2931 2824 2932 2825 - fn profile_sidebar ( owner : & User ) -> Markup { 2933 + fn profile_sidebar ( owner : & User , followers : i64 , following : i64 , follow : Option < bool > ) -> Markup { 2826 2934 let name = owner. display_name. as_deref ( ) . unwrap_or ( & owner. username) ; 2935 + let user = & owner. username; 2827 2936 html ! { 2828 2937 div class="card profile-card" { 2829 2938 img class="avatar avatar-xl" src={ "/avatar/" ( owner. username) } alt="" ; Expand 4 hidden lines 2834 2943 " · " ( pronouns) 2835 2944 } 2836 2945 } 2946 + p class="profile-follow muted" { 2947 + ( icon ( Icon :: Users ) ) " " 2948 + a href= { " / " ( user ) " ?tab=followers" } { 2949 + strong { ( followers) } " follower" @if followers != 1 { "s" } 2950 + } 2951 + " · " 2952 + a href={ "/" ( user) "?tab=following" } { 2953 + strong { ( following) } " following" 2954 + } 2955 + } 2956 + @match follow { 2957 + Some ( true ) => { 2958 + a class="btn profile-follow-btn" href={ "/user-follow/" ( user) } { "Unfollow" } 2959 + } 2960 + Some ( false ) => { 2961 + a class="btn btn-primary profile-follow-btn" href={ "/user-follow/" ( user) } { "Follow" } 2962 + } 2963 + None => { } 2964 + } 2837 2965 @if let Some ( bio) = non_empty ( owner. bio. as_ref ( ) ) { 2838 2966 div class="profile-bio markdown" { ( markdown:: render ( bio) ) } 2839 2967 }