add: user socket streams

add: group messages by author in ui
TODO: group messages by author in ui as they come in from socket
TODO: notifications stream connection
This commit is contained in:
trisua 2025-05-01 16:43:58 -04:00
parent c549fdd274
commit 094dd5fdb5
8 changed files with 198 additions and 40 deletions

View file

@ -65,14 +65,14 @@
</button> </button>
<div class="inner"> <div class="inner">
{% if selected_community != 0 %}
<a href="/community/{{ selected_community }}"> <a href="/community/{{ selected_community }}">
{{ icon "book-heart" }} {{ icon "book-heart" }}
<span <span
>{{ text "communities:label.show_community" }}</span >{{ text "communities:label.show_community" }}</span
> >
</a> </a>
{% endif %} {% if can_manage_channels %}
{% if can_manage_channels %}
<a href="/community/{{ selected_community }}/manage"> <a href="/community/{{ selected_community }}/manage">
{{ icon "settings" }} {{ icon "settings" }}
<span>{{ text "general:action.manage" }}</span> <span>{{ text "general:action.manage" }}</span>
@ -104,7 +104,6 @@
<turbo-frame <turbo-frame
id="stream_body_frame" id="stream_body_frame"
src="/chats/{{ selected_community }}/{{ selected_channel }}/_stream?page={{ page }}" src="/chats/{{ selected_community }}/{{ selected_channel }}/_stream?page={{ page }}"
target="_top"
></turbo-frame> ></turbo-frame>
<form <form
@ -274,6 +273,7 @@
.message { .message {
transition: background 0.15s; transition: background 0.15s;
box-shadow: none; box-shadow: none;
position: relative;
} }
.message:hover { .message:hover {
@ -286,11 +286,24 @@
display: flex !important; display: flex !important;
} }
.floating_message_actions {
position: absolute;
top: 0.25rem;
right: 1rem;
}
.message.grouped {
padding: 0.25rem 0 0.25rem calc(1rem + 0.5rem + 52px);
}
turbo-frame { turbo-frame {
display: contents; display: contents;
} }
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
.message.grouped {
padding: 0.25rem 0 0.25rem calc(1rem + 0.5rem + 39px);
}
body:not(.sidebars_shown) .sidebar { body:not(.sidebars_shown) .sidebar {
position: absolute; position: absolute;
left: -200%; left: -200%;

View file

@ -12,7 +12,7 @@
{% endif %} {% endif %}
{% for message in messages %} {% for message in messages %}
{{ components::message(user=message[1], message=message[0]) }} {{ components::message(user=message[1], message=message[0], grouped=message[2]) }}
{% endfor %} {% endfor %}
{% if messages|length > 0 %} {% if messages|length > 0 %}

View file

@ -909,15 +909,40 @@ if state and state.data %}
</div> </div>
{%- endmacro %} {% macro connection_url(key, value) -%} {% if value[0].data.url {%- endmacro %} {% macro connection_url(key, value) -%} {% if value[0].data.url
%} {{ value[0].data.url }} {% elif key == "LastFm" %} https://last.fm/user/{{ %} {{ value[0].data.url }} {% elif key == "LastFm" %} https://last.fm/user/{{
value[0].data.name }} {% endif %} {%- endmacro %} {% macro message(user, value[0].data.name }} {% endif %} {%- endmacro %} {% macro
message, can_manage_message=false) -%} message_actions(can_manage_message, user, message) -%} {% if can_manage_message
<div class="card secondary message flex gap-2" id="message-{{ message.id }}"> or (user and user.id == message.owner) %}
<div class="dropdown">
<button
class="camo small"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
>
{{ icon "ellipsis" }}
</button>
<div class="inner">
<button class="red" onclick="delete_message('{{ message.id }}')">
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
{% endif %} {%- endmacro %} {% macro message(user, message,
can_manage_message=false, grouped=false) -%}
<div
class="card secondary message flex gap-2 {% if grouped %}grouped{% endif %}"
id="message-{{ message.id }}"
>
{% if not grouped %}
<a href="/@{{ user.username }}" target="_top"> <a href="/@{{ user.username }}" target="_top">
{{ self::avatar(username=user.username, size="52px") }} {{ self::avatar(username=user.username, size="52px") }}
</a> </a>
{% endif %}
<div class="flex flex-col gap-1 w-full"> <div class="flex flex-col gap-1 w-full">
<div class="flex gap-2 w-full justify-between"> {% if not grouped %}
<div class="flex gap-2 w-full justify-between flex-wrap">
<div class="flex gap-2"> <div class="flex gap-2">
{{ self::full_username(user=user) }} {% if message.edited != {{ self::full_username(user=user) }} {% if message.edited !=
message.created %} message.created %}
@ -930,30 +955,16 @@ message, can_manage_message=false) -%}
</div> </div>
<div class="flex gap-2 hidden"> <div class="flex gap-2 hidden">
{% if can_manage_message or (user and user.id == message.owner) {{ self::message_actions(user=user, message=message,
%} can_manage_message=can_manage_message) }}
<div class="dropdown">
<button
class="camo small"
onclick="trigger('atto::hooks::dropdown', [event])"
exclude="dropdown"
>
{{ icon "ellipsis" }}
</button>
<div class="inner">
<button
class="red"
onclick="delete_message('{{ message.id }}')"
>
{{ icon "trash" }}
<span>{{ text "general:action.delete" }}</span>
</button>
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
{% else %}
<div class="floating_message_actions hidden">
{{ self::message_actions(user=user, message=message,
can_manage_message=can_manage_message) }}
</div>
{% endif %}
<span class="no_p_margin">{{ message.content|markdown|safe }}</span> <span class="no_p_margin">{{ message.content|markdown|safe }}</span>
</div> </div>

View file

@ -1,3 +1,5 @@
use std::time::Duration;
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
model::{ApiReturn, Error}, model::{ApiReturn, Error},
@ -8,19 +10,28 @@ use crate::{
State, State,
}; };
use axum::{ use axum::{
Extension, Json, extract::{
extract::Path, ws::{Message as WsMessage, WebSocket},
Path, WebSocketUpgrade,
},
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
Extension, Json,
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use futures_util::{sink::SinkExt, stream::StreamExt};
use tetratto_core::{ use tetratto_core::{
cache::Cache,
model::{ model::{
auth::{Token, UserSettings}, auth::{Token, UserSettings},
permissions::FinePermission, permissions::FinePermission,
socket::{PacketType, SocketMessage, SocketMethod},
}, },
DataManager, DataManager,
}; };
#[cfg(feature = "redis")]
use redis::Commands;
pub async fn redirect_from_id( pub async fn redirect_from_id(
Extension(data): Extension<State>, Extension(data): Extension<State>,
Path(id): Path<String>, Path(id): Path<String>,
@ -410,3 +421,114 @@ pub async fn has_totp_enabled_request(
payload: Some(!user.totp.is_empty()), payload: Some(!user.totp.is_empty()),
}) })
} }
/// Handle a subscription to the websocket.
#[cfg(feature = "redis")]
pub async fn subscription_handler(
jar: CookieJar,
ws: WebSocketUpgrade,
Extension(data): Extension<State>,
Path((user_id, id)): Path<(String, String)>,
) -> impl IntoResponse {
let data = &(data.read().await).0;
let user = match get_user_from_token!(jar, data) {
Some(ua) => ua,
None => return Err("Socket refused"),
};
if user.id.to_string() != user_id {
// TODO: maybe allow moderators to connect anyway
return Err("Socket refused (auth)");
}
let data = data.clone();
Ok(ws.on_upgrade(|socket| async move {
tokio::spawn(async move {
handle_socket(socket, data, user_id, id).await;
});
}))
}
#[cfg(feature = "redis")]
pub async fn handle_socket(socket: WebSocket, db: DataManager, user_id: String, stream_id: String) {
let (mut sink, mut stream) = socket.split();
// get channel permissions
let channel = format!("{user_id}_{stream_id}");
// ...
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(WsMessage::Text(text))) = stream.next().await {
if text != "Close" {
continue;
}
drop(stream);
break;
}
});
let dbc = db.clone();
let channel_c = channel.clone();
let mut redis_task = tokio::spawn(async move {
// forward messages from redis to the socket
let mut con = dbc.2.get_con().await;
let mut pubsub = con.as_pubsub();
pubsub.subscribe(channel_c).unwrap();
// listen for pubsub messages
while let Ok(msg) = pubsub.get_message() {
// payload is a stringified SocketMessage
let smsg = msg.get_payload::<String>().unwrap();
let packet: SocketMessage = serde_json::from_str(&smsg).unwrap();
if packet.method == SocketMethod::Forward(PacketType::Ping) {
// forward with custom message
if sink.send(WsMessage::Text("Ping".into())).await.is_err() {
drop(sink);
break;
}
} else if packet.method == SocketMethod::Message {
if sink.send(WsMessage::Text(smsg.into())).await.is_err() {
drop(sink);
break;
}
} else {
// forward to client
if sink.send(WsMessage::Text(smsg.into())).await.is_err() {
drop(sink);
break;
}
}
}
});
let db2c = db.2.clone();
let channel_c = channel.clone();
let heartbeat_task = tokio::spawn(async move {
let mut con = db2c.get_con().await;
let mut heartbeat = tokio::time::interval(Duration::from_secs(10));
loop {
con.publish::<String, String, ()>(
format!("{channel_c}_heartbeat"),
serde_json::to_string(&SocketMessage {
method: SocketMethod::Forward(PacketType::Ping),
data: "Ping".to_string(),
})
.unwrap(),
)
.unwrap();
heartbeat.tick().await;
}
});
tokio::select! {
_ = (&mut recv_task) => redis_task.abort(),
_ = (&mut redis_task) => recv_task.abort()
}
heartbeat_task.abort(); // kill
tracing::info!("socket terminate");
}

View file

@ -186,7 +186,7 @@ pub async fn handle_socket(socket: WebSocket, db: DataManager, community_id: Str
let dbc = db.clone(); let dbc = db.clone();
let mut redis_task = tokio::spawn(async move { let mut redis_task = tokio::spawn(async move {
// forward messages from redis to the mpsc // forward messages from redis to the socket
let mut con = dbc.2.get_con().await; let mut con = dbc.2.get_con().await;
let mut pubsub = con.as_pubsub(); let mut pubsub = con.as_pubsub();

View file

@ -191,6 +191,10 @@ pub fn routes() -> Router {
get(auth::profile::redirect_from_ip), get(auth::profile::redirect_from_ip),
) )
.route("/auth/ip/{ip}/block", post(auth::social::ip_block_request)) .route("/auth/ip/{ip}/block", post(auth::social::ip_block_request))
.route(
"/auth/user/{id}/_connect/{stream}",
get(auth::profile::subscription_handler),
)
// warnings // warnings
.route("/warnings/{id}", post(auth::user_warnings::create_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request))
.route( .route(

View file

@ -46,15 +46,23 @@ impl DataManager {
auto_method!(get_message_by_id(usize as i64)@get_message_from_row -> "SELECT * FROM messages WHERE id = $1" --name="message" --returns=Message --cache-key-tmpl="atto.message:{}"); auto_method!(get_message_by_id(usize as i64)@get_message_from_row -> "SELECT * FROM messages WHERE id = $1" --name="message" --returns=Message --cache-key-tmpl="atto.message:{}");
/// Complete a vector of just messages with their owner as well. /// Complete a vector of just messages with their owner as well.
///
/// # Returns
/// `(message, owner, group with previous messages in ui)`
pub async fn fill_messages( pub async fn fill_messages(
&self, &self,
messages: Vec<Message>, messages: Vec<Message>,
ignore_users: &[usize], ignore_users: &[usize],
) -> Result<Vec<(Message, User)>> { ) -> Result<Vec<(Message, User, bool)>> {
let mut out: Vec<(Message, User)> = Vec::new(); let mut out: Vec<(Message, User, bool)> = Vec::new();
let mut users: HashMap<usize, User> = HashMap::new(); let mut users: HashMap<usize, User> = HashMap::new();
for message in messages { for (i, message) in messages.iter().enumerate() {
let next_owner: usize = match messages.get(i + 1) {
Some(ref m) => m.owner,
None => 0,
};
let owner = message.owner; let owner = message.owner;
if ignore_users.contains(&owner) { if ignore_users.contains(&owner) {
@ -62,11 +70,11 @@ impl DataManager {
} }
if let Some(user) = users.get(&owner) { if let Some(user) = users.get(&owner) {
out.push((message, user.clone())); out.push((message.to_owned(), user.clone(), next_owner == owner));
} else { } else {
let user = self.get_user_by_id(owner).await?; let user = self.get_user_by_id(owner).await?;
users.insert(owner, user.clone()); users.insert(owner, user.clone());
out.push((message, user)); out.push((message.to_owned(), user, next_owner == owner));
} }
} }

View file

@ -68,7 +68,7 @@ impl Channel {
} }
} }
#[derive(Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct Message { pub struct Message {
pub id: usize, pub id: usize,
pub channel: usize, pub channel: usize,
@ -98,7 +98,7 @@ impl Message {
} }
} }
#[derive(Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
pub struct MessageContext; pub struct MessageContext;
impl Default for MessageContext { impl Default for MessageContext {