clear messages
This commit is contained in:
+32
-14
@@ -7,11 +7,12 @@
|
|||||||
#
|
#
|
||||||
# Copy it to the server, then: tar xzf movim-*.tar.gz && cd movim-* && ./install.sh
|
# Copy it to the server, then: tar xzf movim-*.tar.gz && cd movim-* && ./install.sh
|
||||||
#
|
#
|
||||||
# Overridable: DEBIAN (default bookworm: Debian 12, also runs on 13 trixie),
|
# Overridable: DEBIAN (default trixie: Debian 13 and newer; bookworm for 12),
|
||||||
# RUST_IMAGE (default rust:1-$DEBIAN), DOCKER (default docker; podman works).
|
# RUST_IMAGE (default rust:1-$DEBIAN), DOCKER (default docker; podman works),
|
||||||
|
# REBUILD (yes/no; asked when a binary exists, default yes without a terminal).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
DEBIAN=${DEBIAN:-bookworm}
|
DEBIAN=${DEBIAN:-trixie}
|
||||||
RUST_IMAGE=${RUST_IMAGE:-rust:1-$DEBIAN}
|
RUST_IMAGE=${RUST_IMAGE:-rust:1-$DEBIAN}
|
||||||
DOCKER=${DOCKER:-docker}
|
DOCKER=${DOCKER:-docker}
|
||||||
|
|
||||||
@@ -19,8 +20,6 @@ die() { echo "error: $*" >&2; exit 1; }
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
[ -f Cargo.toml ] || die "run this from the movim-rs repository"
|
[ -f Cargo.toml ] || die "run this from the movim-rs repository"
|
||||||
command -v "$DOCKER" >/dev/null || die "$DOCKER not found"
|
|
||||||
"$DOCKER" info >/dev/null 2>&1 || die "cannot reach $DOCKER; start it (sudo systemctl start docker) and make sure you may use it (docker group)"
|
|
||||||
|
|
||||||
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
|
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)
|
||||||
name="movim-$version-$DEBIAN"
|
name="movim-$version-$DEBIAN"
|
||||||
@@ -29,20 +28,39 @@ name="movim-$version-$DEBIAN"
|
|||||||
cache="target/docker-$DEBIAN"
|
cache="target/docker-$DEBIAN"
|
||||||
mkdir -p "$cache/cargo"
|
mkdir -p "$cache/cargo"
|
||||||
|
|
||||||
echo "==> Building $name in $RUST_IMAGE"
|
bin="$cache/release/movim"
|
||||||
"$DOCKER" run --rm \
|
rebuild=${REBUILD:-}
|
||||||
--user "$(id -u):$(id -g)" \
|
if [ -z "$rebuild" ] && [ -x "$bin" ]; then
|
||||||
-v "$PWD:/src" -w /src \
|
if [ -t 0 ]; then
|
||||||
-e CARGO_HOME="/src/$cache/cargo" \
|
printf 'A binary exists (built %s). Rebuild? [Y/n] ' "$(date -r "$bin" '+%Y-%m-%d %H:%M')"
|
||||||
-e CARGO_TARGET_DIR="/src/$cache" \
|
read -r answer || answer=n
|
||||||
"$RUST_IMAGE" \
|
case $answer in [nN]*) rebuild=no ;; *) rebuild=yes ;; esac
|
||||||
sh -c 'rustc --version && cargo build --release --locked'
|
else
|
||||||
|
rebuild=yes
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$rebuild" = no ]; then
|
||||||
|
[ -x "$bin" ] || die "REBUILD=no but there is no binary at $bin"
|
||||||
|
echo "==> Reusing $bin"
|
||||||
|
else
|
||||||
|
command -v "$DOCKER" >/dev/null || die "$DOCKER not found"
|
||||||
|
"$DOCKER" info >/dev/null 2>&1 || die "cannot reach $DOCKER; start it (sudo systemctl start docker) and make sure you may use it (docker group)"
|
||||||
|
echo "==> Building $name in $RUST_IMAGE"
|
||||||
|
"$DOCKER" run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
-v "$PWD:/src" -w /src \
|
||||||
|
-e CARGO_HOME="/src/$cache/cargo" \
|
||||||
|
-e CARGO_TARGET_DIR="/src/$cache" \
|
||||||
|
"$RUST_IMAGE" \
|
||||||
|
sh -c 'rustc --version && cargo build --release --locked'
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Packing dist/$name.tar.gz"
|
echo "==> Packing dist/$name.tar.gz"
|
||||||
stage=$(mktemp -d)
|
stage=$(mktemp -d)
|
||||||
trap 'rm -rf "$stage"' EXIT
|
trap 'rm -rf "$stage"' EXIT
|
||||||
mkdir "$stage/$name"
|
mkdir "$stage/$name"
|
||||||
cp "$cache/release/movim" install.sh "$stage/$name/"
|
cp "$bin" install.sh "$stage/$name/"
|
||||||
cp -R frontend templates "$stage/$name/"
|
cp -R frontend templates "$stage/$name/"
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
tar -C "$stage" -czf "dist/$name.tar.gz" "$name"
|
tar -C "$stage" -czf "dist/$name.tar.gz" "$name"
|
||||||
|
|||||||
@@ -21,3 +21,7 @@ this_device = This device
|
|||||||
|
|
||||||
[omemo]
|
[omemo]
|
||||||
this_device = This device
|
this_device = This device
|
||||||
|
|
||||||
|
[chat]
|
||||||
|
restore_history = Restore the history from the server
|
||||||
|
history_restored = The history will be fetched again from the server
|
||||||
|
|||||||
+43
-14
@@ -9,8 +9,8 @@
|
|||||||
#
|
#
|
||||||
# Overridable: MOVIM_USER (default movim, created if missing), PREFIX
|
# Overridable: MOVIM_USER (default movim, created if missing), PREFIX
|
||||||
# (default /opt/movim), DATA_DIR (default /var/lib/movim), CONF_DIR
|
# (default /opt/movim), DATA_DIR (default /var/lib/movim), CONF_DIR
|
||||||
# (default /etc/movim), XMPP_DOMAIN and WEB_HOST (asked when run in a
|
# (default /etc/movim), XMPP_DOMAIN, WEB_HOST and MOVIM_PORT (asked when run
|
||||||
# terminal and the configuration does not exist yet).
|
# in a terminal and the configuration does not exist yet).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
MOVIM_USER=${MOVIM_USER:-movim}
|
MOVIM_USER=${MOVIM_USER:-movim}
|
||||||
@@ -19,19 +19,47 @@ DATA_DIR=${DATA_DIR:-/var/lib/movim}
|
|||||||
CONF_DIR=${CONF_DIR:-/etc/movim}
|
CONF_DIR=${CONF_DIR:-/etc/movim}
|
||||||
XMPP_DOMAIN=${XMPP_DOMAIN:-}
|
XMPP_DOMAIN=${XMPP_DOMAIN:-}
|
||||||
WEB_HOST=${WEB_HOST:-}
|
WEB_HOST=${WEB_HOST:-}
|
||||||
|
MOVIM_PORT=${MOVIM_PORT:-}
|
||||||
UNIT=/etc/systemd/system/movim.service
|
UNIT=/etc/systemd/system/movim.service
|
||||||
MIN_RUSTC=1.94
|
MIN_RUSTC=1.94
|
||||||
|
|
||||||
die() { echo "error: $*" >&2; exit 1; }
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Validators print why a value is refused.
|
||||||
valid_host() {
|
valid_host() {
|
||||||
printf '%s' "$1" | grep -Eq '^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}$'
|
printf '%s' "$1" | grep -Eq '^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}$' && return 0
|
||||||
|
echo " not a valid domain name: $1"
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# ask VAR "question" default: prompt on the terminal until a valid hostname.
|
port_in_use() {
|
||||||
|
command -v ss >/dev/null && [ -n "$(ss -Htln "sport = :$1")" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# The service has no capabilities, so no privileged ports.
|
||||||
|
valid_port() {
|
||||||
|
case $1 in ''|*[!0-9]*) echo " not a port number: $1"; return 1 ;; esac
|
||||||
|
if [ "$1" -lt 1024 ] || [ "$1" -gt 65535 ]; then
|
||||||
|
echo " port must be between 1024 and 65535"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if port_in_use "$1"; then
|
||||||
|
echo " port $1 is already used by another service (ss -tlnp)"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
free_port() {
|
||||||
|
p=$1
|
||||||
|
while port_in_use "$p"; do p=$((p + 1)); done
|
||||||
|
echo "$p"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ask VAR "question" default [validator]: prompt on the terminal until valid.
|
||||||
ask() {
|
ask() {
|
||||||
|
check=${4:-valid_host}
|
||||||
eval "current=\${$1}"
|
eval "current=\${$1}"
|
||||||
[ -z "$current" ] || { valid_host "$current" || die "$1=$current is not a valid domain name"; return 0; }
|
[ -z "$current" ] || { "$check" "$current" >&2 || die "invalid $1"; return 0; }
|
||||||
if [ ! -t 0 ]; then
|
if [ ! -t 0 ]; then
|
||||||
echo " no terminal, $1 defaults to $3 (set $1=... to choose)"
|
echo " no terminal, $1 defaults to $3 (set $1=... to choose)"
|
||||||
eval "$1=\$3"
|
eval "$1=\$3"
|
||||||
@@ -41,11 +69,10 @@ ask() {
|
|||||||
printf '%s [%s]: ' "$2" "$3"
|
printf '%s [%s]: ' "$2" "$3"
|
||||||
read -r answer || die "aborted"
|
read -r answer || die "aborted"
|
||||||
answer=${answer:-$3}
|
answer=${answer:-$3}
|
||||||
if valid_host "$answer"; then
|
if "$check" "$answer"; then
|
||||||
eval "$1=\$answer"
|
eval "$1=\$answer"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
echo " not a valid domain name: $answer"
|
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +98,7 @@ fi
|
|||||||
# --- Questions (before the build, so nobody has to wait for it) -----------
|
# --- Questions (before the build, so nobody has to wait for it) -----------
|
||||||
if [ ! -e "$CONF_DIR/movim.env" ]; then
|
if [ ! -e "$CONF_DIR/movim.env" ]; then
|
||||||
ask XMPP_DOMAIN "XMPP domain users log in with (MOVIM_DOMAIN)" example.org
|
ask XMPP_DOMAIN "XMPP domain users log in with (MOVIM_DOMAIN)" example.org
|
||||||
|
ask MOVIM_PORT "Local port for Movim (behind nginx)" "$(free_port 8080)" valid_port
|
||||||
fi
|
fi
|
||||||
if [ -d /etc/nginx/sites-available ] && [ ! -e /etc/nginx/sites-available/movim ]; then
|
if [ -d /etc/nginx/sites-available ] && [ ! -e /etc/nginx/sites-available/movim ]; then
|
||||||
default_web=movim.${XMPP_DOMAIN:-example.org}
|
default_web=movim.${XMPP_DOMAIN:-example.org}
|
||||||
@@ -147,7 +175,7 @@ if [ ! -e "$CONF_DIR/movim.env" ]; then
|
|||||||
MOVIM_ROOT=$PREFIX
|
MOVIM_ROOT=$PREFIX
|
||||||
MOVIM_DATA_DIR=$DATA_DIR
|
MOVIM_DATA_DIR=$DATA_DIR
|
||||||
# Listen on localhost only, behind a TLS reverse proxy (WebSocket on /ws/).
|
# Listen on localhost only, behind a TLS reverse proxy (WebSocket on /ws/).
|
||||||
MOVIM_BIND=127.0.0.1:8080
|
MOVIM_BIND=127.0.0.1:$MOVIM_PORT
|
||||||
# XMPP domain proposed on the login page.
|
# XMPP domain proposed on the login page.
|
||||||
MOVIM_DOMAIN=$XMPP_DOMAIN
|
MOVIM_DOMAIN=$XMPP_DOMAIN
|
||||||
# Comma separated XMPP domains allowed to log in (empty: all).
|
# Comma separated XMPP domains allowed to log in (empty: all).
|
||||||
@@ -176,9 +204,7 @@ if [ -d /etc/nginx/sites-available ]; then
|
|||||||
if [ ! -e "$nginx_site" ]; then
|
if [ ! -e "$nginx_site" ]; then
|
||||||
echo " writing $nginx_site"
|
echo " writing $nginx_site"
|
||||||
sudo tee "$nginx_site" >/dev/null <<EOF
|
sudo tee "$nginx_site" >/dev/null <<EOF
|
||||||
# Movim behind nginx. Get the certificate first, e.g.
|
# Movim behind nginx. Add your HTTPS settings (certificates, headers) below.
|
||||||
# sudo certbot certonly --nginx -d $WEB_HOST
|
|
||||||
# or point ssl_certificate* to your own.
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
@@ -192,8 +218,11 @@ server {
|
|||||||
http2 on;
|
http2 on;
|
||||||
server_name $WEB_HOST;
|
server_name $WEB_HOST;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/$WEB_HOST/fullchain.pem;
|
# TODO: your HTTPS settings, e.g. include snippets/ssl.conf;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/$WEB_HOST/privkey.pem;
|
# Movim already sends X-Content-Type-Options, X-Frame-Options and
|
||||||
|
# Referrer-Policy on pages; if your file adds them too, hide Movim's to
|
||||||
|
# avoid duplicates (proxy_hide_header X-Frame-Options; ... in both
|
||||||
|
# locations below).
|
||||||
|
|
||||||
# HTTP file uploads go through Movim, match your XMPP upload limit.
|
# HTTP file uploads go through Movim, match your XMPP upload limit.
|
||||||
client_max_body_size 100m;
|
client_max_body_size 100m;
|
||||||
@@ -274,7 +303,7 @@ echo "Installed. Not enabled or started; as admin:"
|
|||||||
echo " 1. check $CONF_DIR/movim.env"
|
echo " 1. check $CONF_DIR/movim.env"
|
||||||
echo " 2. sudo systemctl enable --now movim"
|
echo " 2. sudo systemctl enable --now movim"
|
||||||
if [ -n "$nginx_site" ]; then
|
if [ -n "$nginx_site" ]; then
|
||||||
echo " 3. get a certificate for the nginx site ($nginx_site), then:"
|
echo " 3. add your HTTPS settings to $nginx_site (see the TODO), then:"
|
||||||
echo " sudo ln -s $nginx_site /etc/nginx/sites-enabled/movim"
|
echo " sudo ln -s $nginx_site /etc/nginx/sites-enabled/movim"
|
||||||
echo " sudo nginx -t && sudo systemctl reload nginx"
|
echo " sudo nginx -t && sudo systemctl reload nginx"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -124,6 +124,13 @@ CREATE TABLE IF NOT EXISTS mam_earliests (
|
|||||||
PRIMARY KEY (user_id, jid)
|
PRIMARY KEY (user_id, jid)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS history_clears (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
jid TEXT NOT NULL,
|
||||||
|
cleared_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, jid)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS omemo_states (
|
CREATE TABLE IF NOT EXISTS omemo_states (
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
jid TEXT NOT NULL,
|
jid TEXT NOT NULL,
|
||||||
@@ -694,17 +701,63 @@ pub async fn messages_count(db: &Db, user: &str, jid: &str) -> Result<i64> {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn messages_clear(db: &Db, user: &str, jid: &str) -> Result<()> {
|
/// Clear the local history of a conversation. The archive is marked as fully
|
||||||
sqlx::query("DELETE FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)")
|
/// known and the clear date is kept, so the cleared messages are not fetched
|
||||||
|
/// again from MAM or MUC history (`archive_key`: jid, or `muc:` + room).
|
||||||
|
pub async fn messages_clear(db: &Db, user: &str, jid: &str, archive_key: &str) -> Result<()> {
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
for sql in [
|
||||||
|
"DELETE FROM reactions WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))",
|
||||||
|
"DELETE FROM message_files WHERE message_mid IN (SELECT mid FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?))",
|
||||||
|
"DELETE FROM messages WHERE user_id = ? AND (jidfrom = ? OR jidto = ?)",
|
||||||
|
] {
|
||||||
|
sqlx::query(sql).bind(user).bind(jid).bind(jid).execute(&mut *tx).await?;
|
||||||
|
}
|
||||||
|
sqlx::query("INSERT OR REPLACE INTO mam_earliests (user_id, jid, earliest) VALUES (?, ?, NULL)")
|
||||||
.bind(user)
|
.bind(user)
|
||||||
.bind(jid)
|
.bind(archive_key)
|
||||||
.bind(jid)
|
.execute(&mut *tx)
|
||||||
.execute(db)
|
|
||||||
.await?;
|
.await?;
|
||||||
sqlx::query("DELETE FROM mam_earliests WHERE user_id = ? AND jid = ?").bind(user).bind(jid).execute(db).await?;
|
sqlx::query("INSERT OR REPLACE INTO history_clears (user_id, jid, cleared_at) VALUES (?, ?, ?)")
|
||||||
|
.bind(user)
|
||||||
|
.bind(archive_key)
|
||||||
|
.bind(crate::utils::now_sql())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Undo `messages_clear`: the cleared messages can be fetched again from MAM
|
||||||
|
pub async fn history_restore(db: &Db, user: &str, archive_key: &str) -> Result<()> {
|
||||||
|
let mut tx = db.begin().await?;
|
||||||
|
for sql in [
|
||||||
|
"DELETE FROM history_clears WHERE user_id = ? AND jid = ?",
|
||||||
|
"DELETE FROM mam_earliests WHERE user_id = ? AND jid = ?",
|
||||||
|
] {
|
||||||
|
sqlx::query(sql).bind(user).bind(archive_key).execute(&mut *tx).await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the history of a conversation was last cleared (`archive_key` as above)
|
||||||
|
pub async fn history_cleared_at(db: &Db, user: &str, archive_key: &str) -> Result<Option<String>> {
|
||||||
|
Ok(sqlx::query_scalar("SELECT cleared_at FROM history_clears WHERE user_id = ? AND jid = ?")
|
||||||
|
.bind(user)
|
||||||
|
.bind(archive_key)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Latest clear of a 1:1 conversation
|
||||||
|
pub async fn latest_chat_clear(db: &Db, user: &str) -> Result<Option<String>> {
|
||||||
|
Ok(sqlx::query_scalar("SELECT MAX(cleared_at) FROM history_clears WHERE user_id = ? AND jid NOT LIKE 'muc:%'")
|
||||||
|
.bind(user)
|
||||||
|
.fetch_one(db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn unreads_count(db: &Db, user: &str, jid: &str, muc: bool) -> Result<i64> {
|
pub async fn unreads_count(db: &Db, user: &str, jid: &str, muc: bool) -> Result<i64> {
|
||||||
let types = if muc { MESSAGE_TYPE_MUC } else { MESSAGE_TYPE };
|
let types = if muc { MESSAGE_TYPE_MUC } else { MESSAGE_TYPE };
|
||||||
let types_sql = types.iter().map(|t| format!("'{t}'")).collect::<Vec<_>>().join(",");
|
let types_sql = types.iter().map(|t| format!("'{t}'")).collect::<Vec<_>>().join(",");
|
||||||
@@ -1032,6 +1085,7 @@ pub async fn user_delete_data(db: &Db, user: &str) -> Result<()> {
|
|||||||
"DELETE FROM messages WHERE user_id = ?",
|
"DELETE FROM messages WHERE user_id = ?",
|
||||||
"DELETE FROM open_chats WHERE user_id = ?",
|
"DELETE FROM open_chats WHERE user_id = ?",
|
||||||
"DELETE FROM mam_earliests WHERE user_id = ?",
|
"DELETE FROM mam_earliests WHERE user_id = ?",
|
||||||
|
"DELETE FROM history_clears WHERE user_id = ?",
|
||||||
"DELETE FROM omemo_states WHERE user_id = ?",
|
"DELETE FROM omemo_states WHERE user_id = ?",
|
||||||
"DELETE FROM encrypted_passwords WHERE user_id = ?",
|
"DELETE FROM encrypted_passwords WHERE user_id = ?",
|
||||||
"DELETE FROM push_subscriptions WHERE user_id = ?",
|
"DELETE FROM push_subscriptions WHERE user_id = ?",
|
||||||
@@ -1083,3 +1137,48 @@ pub async fn push_subscription_toggle(db: &Db, user: &str, id: i64, enabled: boo
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clear_history_is_remembered() {
|
||||||
|
// A file: every pool connection to sqlite::memory: gets its own database
|
||||||
|
let path = std::env::temp_dir().join(format!("movim-test-{}.sqlite", std::process::id()));
|
||||||
|
let db = connect(&format!("sqlite://{}?mode=rwc", path.display())).await.unwrap();
|
||||||
|
let (me, bob) = ("me@example.org", "bob@example.org");
|
||||||
|
let m = Message {
|
||||||
|
user_id: me.into(),
|
||||||
|
id: "m1".into(),
|
||||||
|
jidfrom: bob.into(),
|
||||||
|
jidto: me.into(),
|
||||||
|
type_: "chat".into(),
|
||||||
|
body: Some("hi".into()),
|
||||||
|
published: "2026-01-01 10:00:00".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mid = message_save(&db, &m).await.unwrap();
|
||||||
|
reactions_replace(&db, mid, bob, &["👍".into()]).await.unwrap();
|
||||||
|
assert!(!mam_earliest_exists(&db, me, bob).await.unwrap());
|
||||||
|
|
||||||
|
messages_clear(&db, me, bob, bob).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(messages_count(&db, me, bob).await.unwrap(), 0);
|
||||||
|
assert!(reactions_for(&db, &[mid]).await.unwrap().is_empty());
|
||||||
|
// No MAM refetch when the chat is opened again, catch-up starts after the clear
|
||||||
|
assert!(mam_earliest_exists(&db, me, bob).await.unwrap());
|
||||||
|
let cleared = history_cleared_at(&db, me, bob).await.unwrap().unwrap();
|
||||||
|
assert!(cleared.as_str() > "2026-01-01 10:00:00");
|
||||||
|
assert_eq!(latest_chat_clear(&db, me).await.unwrap(), Some(cleared));
|
||||||
|
assert_eq!(history_cleared_at(&db, me, "muc:room@conference.example.org").await.unwrap(), None);
|
||||||
|
|
||||||
|
history_restore(&db, me, bob).await.unwrap();
|
||||||
|
assert!(!mam_earliest_exists(&db, me, bob).await.unwrap());
|
||||||
|
assert_eq!(history_cleared_at(&db, me, bob).await.unwrap(), None);
|
||||||
|
db.close().await;
|
||||||
|
for ext in ["", "-wal", "-shm"] {
|
||||||
|
let _ = std::fs::remove_file(format!("{}{ext}", path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-1
@@ -46,6 +46,9 @@ async fn query(ctx: &Ctx, q: MamQuery<'_>) -> Option<Page> {
|
|||||||
pub async fn catch_up(ctx: &Ctx) {
|
pub async fn catch_up(ctx: &Ctx) {
|
||||||
let me = ctx.jid();
|
let me = ctx.jid();
|
||||||
let latest = db::latest_message_date(ctx.db(), &me).await.ok().flatten();
|
let latest = db::latest_message_date(ctx.db(), &me).await.ok().flatten();
|
||||||
|
// Never before a history clear: the cleared messages would come back.
|
||||||
|
let cleared = db::latest_chat_clear(ctx.db(), &me).await.ok().flatten();
|
||||||
|
let latest = latest.max(cleared);
|
||||||
|
|
||||||
match latest {
|
match latest {
|
||||||
Some(latest) => {
|
Some(latest) => {
|
||||||
@@ -154,7 +157,8 @@ pub async fn fetch_history(ctx: &Ctx, jid: &str, muc: bool) -> usize {
|
|||||||
pub async fn catch_up_room(ctx: &Ctx, room: &str) {
|
pub async fn catch_up_room(ctx: &Ctx, room: &str) {
|
||||||
let me = ctx.jid();
|
let me = ctx.jid();
|
||||||
let latest = db::latest_muc_message_date(ctx.db(), &me, room).await.ok().flatten();
|
let latest = db::latest_muc_message_date(ctx.db(), &me, room).await.ok().flatten();
|
||||||
let Some(latest) = latest else {
|
let cleared = db::history_cleared_at(ctx.db(), &me, &format!("muc:{room}")).await.ok().flatten();
|
||||||
|
let Some(latest) = latest.max(cleared) else {
|
||||||
fetch_history(ctx, room, true).await;
|
fetch_history(ctx, room, true).await;
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
+33
-2
@@ -70,8 +70,29 @@ pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
|
|||||||
"ajaxClearHistoryConfirm" => {
|
"ajaxClearHistoryConfirm" => {
|
||||||
let jid = p.str(0);
|
let jid = p.str(0);
|
||||||
if utils::validate_jid(&jid) {
|
if utils::validate_jid(&jid) {
|
||||||
let _ = db::messages_clear(ctx.db(), &ctx.jid(), &jid).await;
|
|
||||||
let muc = ctx.st(|st| st.conferences.contains_key(&jid)).unwrap_or(false);
|
let muc = ctx.st(|st| st.conferences.contains_key(&jid)).unwrap_or(false);
|
||||||
|
let archive_key = if muc { format!("muc:{jid}") } else { jid.clone() };
|
||||||
|
if let Err(e) = db::messages_clear(ctx.db(), &ctx.jid(), &jid, &archive_key).await {
|
||||||
|
tracing::warn!("clear history of {jid}: {e}");
|
||||||
|
}
|
||||||
|
if muc {
|
||||||
|
get_room(ctx, &jid, true).await;
|
||||||
|
} else {
|
||||||
|
get(ctx, Some(&jid), false).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ajaxRestoreHistory" => {
|
||||||
|
let jid = p.str(0);
|
||||||
|
if utils::validate_jid(&jid) {
|
||||||
|
let muc = ctx.st(|st| st.conferences.contains_key(&jid)).unwrap_or(false);
|
||||||
|
let archive_key = if muc { format!("muc:{jid}") } else { jid.clone() };
|
||||||
|
if let Err(e) = db::history_restore(ctx.db(), &ctx.jid(), &archive_key).await {
|
||||||
|
tracing::warn!("restore history of {jid}: {e}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ctx.toast(&ctx.t("chat.history_restored", &[]));
|
||||||
|
// Reopening fetches from MAM, scrolling up fetches older pages
|
||||||
if muc {
|
if muc {
|
||||||
get_room(ctx, &jid, true).await;
|
get_room(ctx, &jid, true).await;
|
||||||
} else {
|
} else {
|
||||||
@@ -105,11 +126,20 @@ pub async fn call(ctx: &Ctx, func: &str, p: &P<'_>) -> bool {
|
|||||||
|
|
||||||
pub async fn prepare_header(ctx: &Ctx, jid: &str, muc: bool) -> String {
|
pub async fn prepare_header(ctx: &Ctx, jid: &str, muc: bool) -> String {
|
||||||
let counter = notif::unreads(ctx).await;
|
let counter = notif::unreads(ctx).await;
|
||||||
|
let archive_key = if muc { format!("muc:{jid}") } else { jid.to_string() };
|
||||||
|
let cleared = db::history_cleared_at(ctx.db(), &ctx.jid(), &archive_key).await.ok().flatten().is_some();
|
||||||
if muc {
|
if muc {
|
||||||
let conference = rooms::conference_view(ctx, jid).await;
|
let conference = rooms::conference_view(ctx, jid).await;
|
||||||
ctx.render(
|
ctx.render(
|
||||||
"Chat/_chat_header.html",
|
"Chat/_chat_header.html",
|
||||||
json!({ "jid": jid, "muc": true, "counter": counter, "conference": conference, "in_call": super::visio::has_call(ctx) }),
|
json!({
|
||||||
|
"jid": jid,
|
||||||
|
"muc": true,
|
||||||
|
"counter": counter,
|
||||||
|
"conference": conference,
|
||||||
|
"in_call": super::visio::has_call(ctx),
|
||||||
|
"cleared": cleared,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let contact = contact_view(ctx, jid).await;
|
let contact = contact_view(ctx, jid).await;
|
||||||
@@ -129,6 +159,7 @@ pub async fn prepare_header(ctx: &Ctx, jid: &str, muc: bool) -> String {
|
|||||||
"call_video": video,
|
"call_video": video,
|
||||||
"in_call": in_call,
|
"in_call": in_call,
|
||||||
"omemo": omemo,
|
"omemo": omemo,
|
||||||
|
"cleared": cleared,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use minidom::Element;
|
|||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use super::{contact_view, P};
|
use super::{contact_view, P};
|
||||||
use crate::{app::Ctx, proto, rpc, utils, xmpp};
|
use crate::{app::Ctx, db, proto, rpc, utils, xmpp};
|
||||||
|
|
||||||
pub fn fingerprint(b64: &str) -> String {
|
pub fn fingerprint(b64: &str) -> String {
|
||||||
let Ok(bytes) = B64.decode(b64) else { return String::new() };
|
let Ok(bytes) = B64.decode(b64) else { return String::new() };
|
||||||
@@ -46,6 +46,7 @@ pub async fn get_drawer(ctx: &Ctx, jid: &str) {
|
|||||||
|
|
||||||
let (audio, video) = super::visio::call_targets(ctx, jid);
|
let (audio, video) = super::visio::call_targets(ctx, jid);
|
||||||
let omemo = ctx.me().is_some_and(|u| u.omemoenabled) && jid != me;
|
let omemo = ctx.me().is_some_and(|u| u.omemoenabled) && jid != me;
|
||||||
|
let cleared = db::history_cleared_at(ctx.db(), &me, jid).await.ok().flatten().is_some();
|
||||||
|
|
||||||
let html = ctx.render(
|
let html = ctx.render(
|
||||||
"ContactActions/_contactactions_drawer.html",
|
"ContactActions/_contactactions_drawer.html",
|
||||||
@@ -57,6 +58,7 @@ pub async fn get_drawer(ctx: &Ctx, jid: &str) {
|
|||||||
"call_audio": audio,
|
"call_audio": audio,
|
||||||
"call_video": video,
|
"call_video": video,
|
||||||
"omemo": omemo,
|
"omemo": omemo,
|
||||||
|
"cleared": cleared,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
super::drawer(ctx, "contact_drawer", &html, false, false);
|
super::drawer(ctx, "contact_drawer", &html, false, false);
|
||||||
|
|||||||
@@ -494,10 +494,10 @@ pub async fn join(ctx: &Ctx, room: &str, nick: Option<&str>) {
|
|||||||
// History comes from MAM
|
// History comes from MAM
|
||||||
proto::muc_join(room, &nick, None, Some(0))
|
proto::muc_join(room, &nick, None, Some(0))
|
||||||
} else {
|
} else {
|
||||||
let since = db::latest_muc_message_date(ctx.db(), &ctx.jid(), room)
|
let latest = db::latest_muc_message_date(ctx.db(), &ctx.jid(), room).await.ok().flatten();
|
||||||
.await
|
let cleared = db::history_cleared_at(ctx.db(), &ctx.jid(), &format!("muc:{room}")).await.ok().flatten();
|
||||||
.ok()
|
let since = latest
|
||||||
.flatten()
|
.max(cleared)
|
||||||
.and_then(|d| crate::dates::parse(&d))
|
.and_then(|d| crate::dates::parse(&d))
|
||||||
.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
|
.map(|d| d.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
|
||||||
match since {
|
match since {
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ pub static WIDGETS: &[WidgetDef] = &[
|
|||||||
("ajaxDisplayed", &["jid", "id"]),
|
("ajaxDisplayed", &["jid", "id"]),
|
||||||
("ajaxClearHistory", &["jid"]),
|
("ajaxClearHistory", &["jid"]),
|
||||||
("ajaxClearHistoryConfirm", &["jid"]),
|
("ajaxClearHistoryConfirm", &["jid"]),
|
||||||
|
("ajaxRestoreHistory", &["jid"]),
|
||||||
("ajaxHttpGetPresences", &["room"]),
|
("ajaxHttpGetPresences", &["room"]),
|
||||||
("ajaxClearAndGetMessages", &["jid", "muc"]),
|
("ajaxClearAndGetMessages", &["jid", "muc"]),
|
||||||
("ajaxHttpGetExplore", &["page"]),
|
("ajaxHttpGetExplore", &["page"]),
|
||||||
|
|||||||
@@ -173,6 +173,16 @@
|
|||||||
<p class="line">{{ __('chat.clear_history') }}</p>
|
<p class="line">{{ __('chat.clear_history') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{% if cleared %}
|
||||||
|
<li onclick="Chat_ajaxRestoreHistory('{{ jid|echapJS }}')">
|
||||||
|
<span class="primary icon gray">
|
||||||
|
<i class="material-symbols">history</i>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="line">{{ __('chat.restore_history') }}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<li onclick="Rooms_ajaxExit('{{ jid|echapJS }}');">
|
<li onclick="Rooms_ajaxExit('{{ jid|echapJS }}');">
|
||||||
<span class="primary icon gray">
|
<span class="primary icon gray">
|
||||||
@@ -272,6 +282,16 @@
|
|||||||
<p class="line">{{ __('chat.clear_history') }}</p>
|
<p class="line">{{ __('chat.clear_history') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{% if cleared %}
|
||||||
|
<li onclick="Chat_ajaxRestoreHistory('{{ contact.id|echapJS }}')">
|
||||||
|
<span class="primary icon gray">
|
||||||
|
<i class="material-symbols">history</i>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="line">{{ __('chat.restore_history') }}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<hr />
|
<hr />
|
||||||
{% if contact.blocked %}
|
{% if contact.blocked %}
|
||||||
<li onclick="ChatActions_ajaxUnblock('{{ contact.id|echapJS }}')">
|
<li onclick="ChatActions_ajaxUnblock('{{ contact.id|echapJS }}')">
|
||||||
|
|||||||
@@ -133,6 +133,16 @@
|
|||||||
<p class="line">{{ __('chat.clear_history') }}</p>
|
<p class="line">{{ __('chat.clear_history') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{% if cleared %}
|
||||||
|
<li onclick="Chat_ajaxRestoreHistory('{{ contact.id|echapJS }}')">
|
||||||
|
<span class="primary icon gray">
|
||||||
|
<i class="material-symbols">history</i>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="line">{{ __('chat.restore_history') }}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user