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:
parent
c549fdd274
commit
094dd5fdb5
8 changed files with 198 additions and 40 deletions
|
@ -65,14 +65,14 @@
|
|||
</button>
|
||||
|
||||
<div class="inner">
|
||||
{% if selected_community != 0 %}
|
||||
<a href="/community/{{ selected_community }}">
|
||||
{{ icon "book-heart" }}
|
||||
<span
|
||||
>{{ text "communities:label.show_community" }}</span
|
||||
>
|
||||
</a>
|
||||
|
||||
{% if can_manage_channels %}
|
||||
{% endif %} {% if can_manage_channels %}
|
||||
<a href="/community/{{ selected_community }}/manage">
|
||||
{{ icon "settings" }}
|
||||
<span>{{ text "general:action.manage" }}</span>
|
||||
|
@ -104,7 +104,6 @@
|
|||
<turbo-frame
|
||||
id="stream_body_frame"
|
||||
src="/chats/{{ selected_community }}/{{ selected_channel }}/_stream?page={{ page }}"
|
||||
target="_top"
|
||||
></turbo-frame>
|
||||
|
||||
<form
|
||||
|
@ -274,6 +273,7 @@
|
|||
.message {
|
||||
transition: background 0.15s;
|
||||
box-shadow: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message:hover {
|
||||
|
@ -286,11 +286,24 @@
|
|||
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 {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
.message.grouped {
|
||||
padding: 0.25rem 0 0.25rem calc(1rem + 0.5rem + 39px);
|
||||
}
|
||||
|
||||
body:not(.sidebars_shown) .sidebar {
|
||||
position: absolute;
|
||||
left: -200%;
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% for message in messages %}
|
||||
{{ components::message(user=message[1], message=message[0]) }}
|
||||
{{ components::message(user=message[1], message=message[0], grouped=message[2]) }}
|
||||
{% endfor %}
|
||||
|
||||
{% if messages|length > 0 %}
|
||||
|
|
|
@ -909,15 +909,40 @@ if state and state.data %}
|
|||
</div>
|
||||
{%- 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.name }} {% endif %} {%- endmacro %} {% macro message(user,
|
||||
message, can_manage_message=false) -%}
|
||||
<div class="card secondary message flex gap-2" id="message-{{ message.id }}">
|
||||
value[0].data.name }} {% endif %} {%- endmacro %} {% macro
|
||||
message_actions(can_manage_message, user, message) -%} {% if can_manage_message
|
||||
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">
|
||||
{{ self::avatar(username=user.username, size="52px") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<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">
|
||||
{{ self::full_username(user=user) }} {% if message.edited !=
|
||||
message.created %}
|
||||
|
@ -930,30 +955,16 @@ message, can_manage_message=false) -%}
|
|||
</div>
|
||||
|
||||
<div class="flex gap-2 hidden">
|
||||
{% if can_manage_message 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 %}
|
||||
{{ self::message_actions(user=user, message=message,
|
||||
can_manage_message=can_manage_message) }}
|
||||
</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>
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
model::{ApiReturn, Error},
|
||||
|
@ -8,19 +10,28 @@ use crate::{
|
|||
State,
|
||||
};
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
extract::Path,
|
||||
extract::{
|
||||
ws::{Message as WsMessage, WebSocket},
|
||||
Path, WebSocketUpgrade,
|
||||
},
|
||||
response::{IntoResponse, Redirect},
|
||||
Extension, Json,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use futures_util::{sink::SinkExt, stream::StreamExt};
|
||||
use tetratto_core::{
|
||||
cache::Cache,
|
||||
model::{
|
||||
auth::{Token, UserSettings},
|
||||
permissions::FinePermission,
|
||||
socket::{PacketType, SocketMessage, SocketMethod},
|
||||
},
|
||||
DataManager,
|
||||
};
|
||||
|
||||
#[cfg(feature = "redis")]
|
||||
use redis::Commands;
|
||||
|
||||
pub async fn redirect_from_id(
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<String>,
|
||||
|
@ -410,3 +421,114 @@ pub async fn has_totp_enabled_request(
|
|||
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");
|
||||
}
|
||||
|
|
|
@ -186,7 +186,7 @@ pub async fn handle_socket(socket: WebSocket, db: DataManager, community_id: Str
|
|||
|
||||
let dbc = db.clone();
|
||||
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 pubsub = con.as_pubsub();
|
||||
|
||||
|
|
|
@ -191,6 +191,10 @@ pub fn routes() -> Router {
|
|||
get(auth::profile::redirect_from_ip),
|
||||
)
|
||||
.route("/auth/ip/{ip}/block", post(auth::social::ip_block_request))
|
||||
.route(
|
||||
"/auth/user/{id}/_connect/{stream}",
|
||||
get(auth::profile::subscription_handler),
|
||||
)
|
||||
// warnings
|
||||
.route("/warnings/{id}", post(auth::user_warnings::create_request))
|
||||
.route(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue