5.0 KiB · text History 7a00c02
//! The friends page: your friends, requests both ways, and people you
//! share an area with as suggestions. Friends shape the "who's going"
//! pickers and whom admins are offered to invite. See `crate::friends`.
use std::collections::{HashMap, HashSet};

use axum::extract::{Form, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, Redirect};
use minijinja::context;
use sea_orm::*;
use serde::Deserialize;
use uuid::Uuid;

use super::{internal, non_empty, render};
use crate::entities::{area_members, users};
use crate::friends::{self, Link};
use crate::middleware::UserContext;
use crate::AppState;

#[derive(Debug, Deserialize)]
pub struct Notice {
    pub notice: Option<String>,
}

pub async fn show_friends(
    State(state): State<AppState>,
    user: UserContext,
    Query(q): Query<Notice>,
) -> Result<Html<String>, StatusCode> {
    let db = state.db.as_ref();
    let me = user.user.id;
    let links: HashMap<Uuid, Link> =
        friends::links(db, me).await.map_err(internal("load friends failed"))?.into_iter().collect();

    // People you share at least one area with, not yet linked: the likeliest
    // friends, and the only strangers this page will name.
    let my_areas: Vec<Uuid> = area_members::Entity::find()
        .filter(area_members::Column::UserId.eq(me))
        .all(db)
        .await
        .map_err(internal("load areas failed"))?
        .into_iter()
        .map(|m| m.area_id)
        .collect();
    let mut shared: HashMap<Uuid, usize> = HashMap::new();
    for m in area_members::Entity::find()
        .filter(area_members::Column::AreaId.is_in(my_areas))
        .filter(area_members::Column::UserId.ne(me))
        .all(db)
        .await
        .map_err(internal("load co-members failed"))?
    {
        *shared.entry(m.user_id).or_default() += 1;
    }

    let ids: HashSet<Uuid> = links.keys().chain(shared.keys()).copied().collect();
    let people = users::Entity::find()
        .filter(users::Column::Id.is_in(ids))
        .filter(users::Column::IsActive.eq(true))
        .order_by_asc(users::Column::Username)
        .all(db)
        .await
        .map_err(internal("load users failed"))?;

    let pick = |want: Link| -> Vec<minijinja::Value> {
        people
            .iter()
            .filter(|u| links.get(&u.id).copied().unwrap_or(Link::None) == want)
            .filter(|u| want != Link::None || shared.contains_key(&u.id))
            .map(|u| context! { id => u.id, username => u.username, shared_areas => shared.get(&u.id).copied().unwrap_or(0) })
            .collect()
    };

    render(
        &state,
        "friends.html",
        context! {
            title => "Friends",
            user => user.user,
            friends => pick(Link::Friends),
            incoming => pick(Link::Incoming),
            outgoing => pick(Link::Requested),
            suggestions => pick(Link::None),
            notice => q.notice,
        },
    )
}

#[derive(Debug, Deserialize)]
pub struct RequestForm {
    pub username: Option<String>,
    pub user_id: Option<Uuid>,
}

pub async fn request(
    State(state): State<AppState>,
    user: UserContext,
    Form(form): Form<RequestForm>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    let mut q = users::Entity::find().filter(users::Column::IsActive.eq(true));
    q = match (form.user_id, non_empty(form.username)) {
        (Some(id), _) => q.filter(users::Column::Id.eq(id)),
        (None, Some(name)) => q.filter(users::Column::Username.eq(name)),
        (None, None) => return Err(StatusCode::BAD_REQUEST),
    };
    let Some(other) = q.one(db).await.map_err(internal("load user failed"))? else {
        return Ok(Redirect::to("/friends?notice=no-such-user"));
    };
    if other.id == user.user.id {
        return Ok(Redirect::to("/friends?notice=self"));
    }
    let link = friends::request(db, user.user.id, other.id).await.map_err(internal("friend request failed"))?;
    Ok(Redirect::to(match link {
        Link::Friends => "/friends?notice=friends",
        _ => "/friends?notice=requested",
    }))
}

/// `accept` a request from them, or `remove` the link (decline, cancel,
/// unfriend).
pub async fn answer(
    State(state): State<AppState>,
    user: UserContext,
    Path((other, action)): Path<(Uuid, String)>,
) -> Result<Redirect, StatusCode> {
    let db = state.db.as_ref();
    match action.as_str() {
        "accept" => {
            friends::accept(db, user.user.id, other).await.map_err(internal("accept failed"))?;
        }
        "remove" => friends::remove(db, user.user.id, other).await.map_err(internal("remove failed"))?,
        _ => return Err(StatusCode::NOT_FOUND),
    }
    Ok(Redirect::to("/friends"))
}

/// Incoming requests waiting on `user_id`, for the home page.
pub async fn incoming_count(db: &DatabaseConnection, user_id: Uuid) -> Result<usize, StatusCode> {
    Ok(friends::links(db, user_id)
        .await
        .map_err(internal("load friends failed"))?
        .into_iter()
        .filter(|(_, l)| *l == Link::Incoming)
        .count())
}