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>
|
</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%;
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue