Compare commits

...
Author SHA1 Message Date
HampusandGitHub 5474be3efa fix(api): close auth, billing and authorization bypasses (#2065) 2026-08-29 02:59:38 +02:00
fluxer-ci[bot]andGitHub 9a54bbba2d chore(i18n): update public marketing catalogs (#2064) 2026-08-29 01:52:48 +02:00
fluxer-ci[bot]andGitHub 5a0110ccc8 chore(marketing): advance pointer 0c78170 → 5908507 (#2063) 2026-08-29 01:52:44 +02:00
HampusandGitHub d032d577bf fix(app-proxy): drop leaked canary debug cert from assetlinks (#2062) 2026-08-29 01:43:26 +02:00
HampusandGitHub 243954c9c5 test(api): use a future baseline in invoice-skip premium tests (#2061) 2026-08-29 01:22:29 +02:00
HampusandGitHub ba1be73389 fix(media-proxy): cap ISO-BMFF box walker recursion depth (#2059) 2026-08-29 01:04:46 +02:00
HampusandGitHub af3ad02962 fix(voice): default noise suppression to standard 2026-08-28 20:43:22 +02:00
HampusandGitHub 53ddca725e fix(desktop): deduplicate macOS release feeds (#2056) 2026-08-28 19:16:35 +02:00
HampusandGitHub 094fb0d1c8 feat(downloads): route desktop releases through GitHub 2026-08-28 18:19:08 +02:00
HampusandGitHub dc230926a4 fix(desktop): skip glibc check for directory packs 2026-08-28 15:09:00 +02:00
HampusandGitHub 026ace6747 fix(ci): publish draft releases by ID (#2050) 2026-08-28 00:38:09 +02:00
HampusandGitHub 374db9ed2b fix(desktop): harden capture and release packaging (#2049) 2026-08-27 23:54:38 +02:00
5ee59c4675 chore(i18n): update public marketing catalogs (#2047)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:52 +01:00
33605171a8 chore(marketing): advance pointer 530c44e → 0c78170 (#2046)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:38 +01:00
HampusandGitHub 89fac5b088 fix(api): use webhook ID for mention author (#2045) 2026-08-27 17:01:26 +02:00
HampusandGitHub 4a34b942b7 fix(api): key mention chunks by content (#2044) 2026-08-27 16:02:24 +02:00
HampusandGitHub fc3065ebe4 fix(desktop): build with compatible PipeWire headers (#2043) 2026-08-27 15:18:15 +02:00
HampusandGitHub 23493b4ac2 fix(desktop): build libfido2 on Linux runners (#2042) 2026-08-27 14:44:44 +02:00
HampusandGitHub 13344096b7 fix(desktop): keep Linux builds compatible with glibc 2.35 (#2041) 2026-08-27 13:41:08 +02:00
HampusandGitHub a800430997 fix(app): sort interface languages by locale code (#2040) 2026-08-27 13:30:39 +02:00
HampusandGitHub 509562e6da feat(app): delete attachments from the media viewer (#2039) 2026-08-27 13:26:18 +02:00
HampusandGitHub 88d85919f1 fix(app): trim pasted friend tags (#2038) 2026-08-27 13:12:12 +02:00
HampusandGitHub 154e223284 fix(app): keep sticker sizes fixed while resizing the expression picker 2026-08-27 12:59:38 +02:00
HampusandGitHub 58732f7770 fix(api): configure email app base URL separately 2026-08-27 03:26:11 +02:00
HampusandGitHub cb4c847d41 fix(app): separate unread state from unread counts 2026-08-27 02:35:56 +02:00
HampusandGitHub d3976e33f8 fix(app): keep unread channel opens anchored to the divider 2026-08-27 02:02:45 +02:00
HampusandGitHub 8e17970632 fix(app): submit message edits in background 2026-08-26 23:54:28 +02:00
HampusandGitHub c0048504db fix(gifs): bound shard cache memory 2026-08-26 23:22:46 +02:00
HampusandGitHub 5015452280 fix(search): respect scope in channel suggestions 2026-08-26 23:06:07 +02:00
HampusandGitHub c504b68354 fix(api): store administrator user archives separately (#2025) 2026-08-26 21:40:57 +02:00
HampusandGitHub 5d2e5932a4 fix(workspace): stabilise the development stack (#2024) 2026-08-26 18:52:53 +02:00
HampusandGitHub cf1a7d7a8a fix(api): mask Tor blocks as administrator IP bans (#2023) 2026-08-26 16:07:19 +02:00
HampusandGitHub 14a935db81 feat(settings): add mobile camera upload preference 2026-08-26 15:31:53 +02:00
HampusandGitHub f98a40062a fix(markdown): render default-presentation emoji without variation selectors 2026-08-26 14:19:28 +02:00
227 changed files with 6834 additions and 5407 deletions
+10 -32
View File
@@ -7,7 +7,6 @@ ARG USER_UID=1000
ARG USER_GID=1000
ARG NODE_MAJOR=24
ARG ELP_VERSION=2026-02-27
ARG HELM_VERSION=4.2.0
ARG PNPM_VERSION=10.29.3
ARG WASM_BINDGEN_VERSION=0.2.122
@@ -15,8 +14,8 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
acl \
bash \
brotli \
build-essential \
ca-certificates \
clang \
@@ -42,13 +41,13 @@ RUN apt-get update \
libfido2-dev \
libgbm1 \
libgtk-3-0 \
libimage-exiftool-perl \
libnotify4 \
libnss3 \
libpipewire-0.3-dev \
libpulse-dev \
libsecret-1-0 \
libudev-dev \
libuv1-dev \
libcurl4-openssl-dev \
libswresample-dev \
libswscale-dev \
@@ -71,14 +70,12 @@ RUN apt-get update \
ninja-build \
openssh-client \
pkg-config \
protobuf-compiler \
python3 \
python3-pip \
rsync \
python3-venv \
sudo \
rpm \
unzip \
webp \
xz-utils \
xdg-utils \
zstd \
@@ -90,6 +87,7 @@ RUN apt-get update \
bat \
btop \
docker-cli \
docker-compose \
dnsutils \
fd-find \
gdb \
@@ -122,24 +120,12 @@ RUN apt-get update \
&& ln -sf /usr/bin/batcat /usr/local/bin/bat \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) HELM_ARCH="amd64" ;; \
arm64) HELM_ARCH="arm64" ;; \
*) echo "Unsupported architecture for Helm: $ARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://get.helm.sh/helm-v${HELM_VERSION}-linux-${HELM_ARCH}.tar.gz" -o /tmp/helm.tgz \
&& tar -C /tmp -xzf /tmp/helm.tgz "linux-${HELM_ARCH}/helm" \
&& mv "/tmp/linux-${HELM_ARCH}/helm" /usr/local/bin/helm \
&& chmod +x /usr/local/bin/helm \
&& rm -rf /tmp/helm.tgz "/tmp/linux-${HELM_ARCH}"
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli cqlsh
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
@@ -147,18 +133,13 @@ RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer
&& pyftsubset --help >/dev/null \
&& python3 -c "import fontTools, brotli"
RUN if ! command -v rebar3 >/dev/null 2>&1; then \
curl -fsSL https://s3.amazonaws.com/rebar3/rebar3 -o /usr/local/bin/rebar3 \
&& chmod +x /usr/local/bin/rebar3; \
fi
RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) ELP_ARCH="x86_64" ;; \
arm64) ELP_ARCH="aarch64" ;; \
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
&& tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
&& chmod +x /usr/local/bin/elp \
&& rm /tmp/elp.tgz
@@ -179,16 +160,13 @@ ENV DOCKER_HOST="unix:///var/run/docker.sock" \
ENV CC_wasm32_unknown_unknown="clang" \
AR_wasm32_unknown_unknown="llvm-ar"
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile default --component clippy,rustfmt \
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --component clippy,rustfmt \
&& rustup target add wasm32-unknown-unknown \
&& cargo install cargo-watch --locked \
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
RUN corepack prepare "pnpm@${PNPM_VERSION}" --activate \
&& pnpm --version
RUN sudo apt-get update \
&& sudo apt-get install -y --no-install-recommends python3-venv \
&& sudo rm -rf /var/lib/apt/lists/*
WORKDIR /workspaces/fluxer
+11 -35
View File
@@ -5,20 +5,17 @@
"workspaceFolder": "/workspaces/fluxer",
"shutdownAction": "stopCompose",
"remoteUser": "vscode",
"hostRequirements": {
"cpus": 4,
"memory": "8gb",
"storage": "32gb"
},
"remoteEnv": {
"DOCKER_HOST": "unix:///var/run/docker.sock"
},
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
"forwardPorts": [
3000, 8088, 8080, 8771, 8082, 3010, 3020, 8100, 8101, 8102, 8103, 8104, 8105, 8106, 8107, 8108, 8109, 8110, 8111,
8112, 8113, 8114, 8115, 8116, 8117, 8118, 8119, 8120, 8121, 8122, 8123, 8124, 8125, 3900, 8888, 9333, 9340, 23646,
4222, 7700, 7880, 7900, 9200, 8000
],
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3010, 3020, 8333],
"portsAttributes": {
"8000": {
"label": "Zensical docs",
"onAutoForward": "openBrowserOnce"
},
"8088": {
"label": "Fluxer dev proxy",
"onAutoForward": "notify"
@@ -29,36 +26,15 @@
"3020": {
"label": "Fluxer admin"
},
"8100": {
"label": "Fluxer Rust service health"
},
"3900": {
"8333": {
"label": "SeaweedFS S3"
},
"8888": {
"label": "SeaweedFS filer"
},
"9333": {
"label": "SeaweedFS master"
},
"9340": {
"label": "SeaweedFS volume"
},
"23646": {
"label": "SeaweedFS admin"
},
"7880": {
"label": "LiveKit"
},
"7700": {
"label": "Meilisearch"
},
"9200": {
"label": "Elasticsearch"
"8773": {
"label": "Fluxer app proxy"
}
},
"postCreateCommand": "sudo chown -R vscode:vscode /workspaces/fluxer/target && find /workspaces/fluxer -maxdepth 4 -type d -name node_modules -prune -exec sudo chown -R vscode:vscode {} + && sudo chown -R vscode:vscode /home/vscode/.local/share/pnpm && cargo run -p fluxer-dev -- bootstrap",
"postStartCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && cargo run -p fluxer-dev -- post-start && bash /workspaces/fluxer/fluxer_docs/serve.sh --daemon",
"postCreateCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && bash /workspaces/fluxer/.devcontainer/fix-workspace-permissions.sh && cargo run -p fluxer-dev -- bootstrap",
"postStartCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && bash /workspaces/fluxer/.devcontainer/fix-workspace-permissions.sh && cargo run -p fluxer-dev -- post-start",
"customizations": {
"vscode": {
"settings": {
+68 -69
View File
@@ -1,5 +1,3 @@
name: fluxer-dev
services:
workspace:
build:
@@ -7,15 +5,29 @@ services:
dockerfile: .devcontainer/Dockerfile
command: sleep infinity
init: true
env_file:
- ../config/env/development.env
environment:
FLUXER_SEARCH_ENGINE: meilisearch
FLUXER_SEARCH_URL: http://meilisearch:7700
FLUXER_SEARCH_API_KEY: fluxer-dev-meilisearch
FLUXER_POSTGRES_HOST: postgres
FLUXER_SELF_HOSTED: "true"
DOCKER_HOST: unix:///var/run/docker.sock
npm_config_store_dir: /home/vscode/.local/share/pnpm/store
FLUXER_PUBLIC_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_PUBLIC_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
FLUXER_API_CLIENT_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
FLUXER_APP_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_GATEWAY_ENDPOINT: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/gateway"
FLUXER_MEDIA_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_ADMIN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_S3_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_LIVEKIT_URL: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/livekit"
FLUXER_LIVEKIT_INTERNAL_URL: "http://livekit:7880"
FLUXER_LIVEKIT_WEBHOOK_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api/webhooks/livekit"
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_ADMIN_OAUTH_REDIRECT_URI: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin/oauth2_callback"
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: "http://localhost,http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
volumes:
- ..:/workspaces/fluxer:cached
- type: volume
@@ -229,6 +241,7 @@ services:
volume:
nocopy: true
- pnpm-store:/home/vscode/.local/share/pnpm/store
- docs-venv:/workspaces/fluxer/fluxer_docs/.venv
- cargo-registry:/home/vscode/.cargo/registry
- cargo-git:/home/vscode/.cargo/git
- rust-target:/workspaces/fluxer/target
@@ -236,45 +249,31 @@ services:
source: ${FLUXER_DOCKER_SOCKET:-/var/run/docker.sock}
target: /var/run/docker.sock
ports:
- "${FLUXER_DEV_DOCS_PORT:-8000}:8000"
- "${FLUXER_DEV_RSPACK_PORT:-3000}:3000"
- "${FLUXER_DEV_PROXY_PORT:-8088}:8088"
- "${FLUXER_DEV_APP_PROXY_PORT:-8080}:8080"
- "${FLUXER_DEV_API_PORT:-8771}:8771"
- "${FLUXER_DEV_GATEWAY_PORT:-8082}:8082"
- "${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
- "${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
- "${FLUXER_DEV_RUST_SERVICE_PORTS:-8100-8125}:8100-8125"
- "${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
- "${FLUXER_DEV_SEAWEEDFS_FILER_PORT:-8888}:8888"
- "${FLUXER_DEV_SEAWEEDFS_MASTER_PORT:-9333}:9333"
- "${FLUXER_DEV_SEAWEEDFS_VOLUME_PORT:-9340}:9340"
- "${FLUXER_DEV_SEAWEEDFS_ADMIN_PORT:-23646}:23646"
- "127.0.0.1:${FLUXER_DEV_RSPACK_PORT:-3000}:3000"
- "127.0.0.1:${FLUXER_DEV_PROXY_PORT:-8088}:8088"
- "127.0.0.1:${FLUXER_DEV_API_PORT:-8080}:8080"
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771"
- "127.0.0.1:${FLUXER_DEV_MEDIA_PROXY_PORT:-8082}:8082"
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773"
- "127.0.0.1:${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
- "127.0.0.1:${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
depends_on:
- postgres
- valkey
- nats
- livekit
- meilisearch
- mailpit
postgres:
condition: service_healthy
valkey:
condition: service_started
nats:
condition: service_started
livekit:
condition: service_started
meilisearch:
condition: service_healthy
mailpit:
condition: service_started
extra_hosts:
- "host.docker.internal:host-gateway"
cassandra:
image: cassandra:5.0.8
profiles:
- full
environment:
CASSANDRA_CLUSTER_NAME: fluxer-dev
CASSANDRA_DC: datacenter1
CASSANDRA_ENDPOINT_SNITCH: GossipingPropertyFileSnitch
HEAP_NEWSIZE: 128M
MAX_HEAP_SIZE: 768M
volumes:
- cassandra-data:/var/lib/cassandra
ports:
- "${FLUXER_DEV_CASSANDRA_PORT:-9042}:9042"
postgres:
image: postgres:16-alpine
environment:
@@ -284,13 +283,19 @@ services:
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "${FLUXER_DEV_POSTGRES_PORT:-5432}:5432"
- "127.0.0.1:${FLUXER_DEV_POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fluxer -d fluxer"]
interval: 2s
timeout: 5s
retries: 30
start_period: 5s
valkey:
image: valkey/valkey:8.1.7-alpine
command: ["valkey-server", "--save", "", "--appendonly", "no"]
ports:
- "${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
nats:
image: nats:2.14.2-alpine
@@ -298,33 +303,22 @@ services:
volumes:
- nats-data:/data
ports:
- "${FLUXER_DEV_NATS_PORT:-4222}:4222"
- "${FLUXER_DEV_NATS_MONITOR_PORT:-8222}:8222"
- "127.0.0.1:${FLUXER_DEV_NATS_PORT:-4222}:4222"
- "127.0.0.1:${FLUXER_DEV_NATS_MONITOR_PORT:-8222}:8222"
livekit:
image: livekit/livekit-server:v1.12.0
command: ["--config", "/etc/livekit.yaml", "--bind", "0.0.0.0"]
environment:
LIVEKIT_RTC_TCP_PORT: "${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}"
LIVEKIT_RTC_UDP_PORT_START: "${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}"
LIVEKIT_RTC_UDP_PORT_END: "${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}"
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
ports:
- "${FLUXER_DEV_LIVEKIT_PORT:-7880}:7880"
- "${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}:7881"
- "${FLUXER_DEV_LIVEKIT_UDP_PORTS:-7882-7892}:7882-7892/udp"
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:9.3.2
profiles:
- full
environment:
discovery.type: single-node
xpack.security.enabled: "true"
xpack.security.http.ssl.enabled: "false"
ELASTIC_PASSWORD: fluxer-dev-elasticsearch
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
volumes:
- elasticsearch-data:/usr/share/elasticsearch/data
ports:
- "${FLUXER_DEV_ELASTICSEARCH_PORT:-9200}:9200"
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_PORT:-7880}:7880"
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}:${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}"
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
meilisearch:
image: getmeili/meilisearch:v1.12
@@ -334,7 +328,13 @@ services:
volumes:
- meilisearch-data:/meili_data
ports:
- "${FLUXER_DEV_MEILISEARCH_PORT:-7700}:7700"
- "127.0.0.1:${FLUXER_DEV_MEILISEARCH_PORT:-7700}:7700"
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:7700/health"]
interval: 2s
timeout: 5s
retries: 30
start_period: 5s
mailpit:
image: axllent/mailpit:v1.30
@@ -349,6 +349,7 @@ services:
volumes:
pnpm-store:
docs-venv:
root-node-modules:
fluxer-api-node-modules:
fluxer-app-node-modules:
@@ -394,9 +395,7 @@ volumes:
cargo-registry:
cargo-git:
rust-target:
cassandra-data:
nats-data:
elasticsearch-data:
meilisearch-data:
mailpit-data:
postgres-data:
+6 -26
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -uo pipefail
set -euo pipefail
SOCKET="${DOCKER_SOCKET:-/var/run/docker.sock}"
USER_NAME="${USER:-vscode}"
USER_NAME="$(id -un)"
if [ ! -S "$SOCKET" ]; then
echo "fix-docker-socket: no socket at $SOCKET; skipping (Docker-in-devcontainer will not work)"
@@ -16,31 +16,11 @@ if docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then
exit 0
fi
socket_gid="$(stat -c '%g' "$SOCKET" 2>/dev/null || echo "")"
if [ -z "$socket_gid" ]; then
echo "fix-docker-socket: could not stat $SOCKET; skipping" >&2
exit 0
fi
sudo sh -c '
set -e
gid="$1"
user="$2"
socket="$3"
if ! getent group "$gid" >/dev/null 2>&1; then
groupadd --gid "$gid" docker-host
fi
group_name="$(getent group "$gid" | cut -d: -f1)"
usermod --append --groups "$group_name" "$user"
chgrp "$gid" "$socket"
chmod g+rw "$socket"
' sh "$socket_gid" "$USER_NAME" "$SOCKET" || {
echo "fix-docker-socket: could not adjust $SOCKET; run docker with sudo" >&2
exit 0
}
sudo setfacl --modify "user:${USER_NAME}:rw" "$SOCKET"
if docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then
echo "fix-docker-socket: $SOCKET is now usable as $USER_NAME (gid $socket_gid)"
echo "fix-docker-socket: $SOCKET is now usable as $USER_NAME"
else
echo "fix-docker-socket: $SOCKET still unreachable as $USER_NAME; run docker with sudo" >&2
echo "fix-docker-socket: $SOCKET is still unreachable as $USER_NAME" >&2
exit 1
fi
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -euo pipefail
owner="$(id -u):$(id -g)"
repair_tree() {
local path="$1"
local unwritable
if [ ! -d "$path" ]; then
echo "fix-workspace-permissions: expected mount is missing: $path" >&2
exit 1
fi
unwritable="$(find "$path" -xdev \( -type d -o -type f \) ! -writable -print -quit 2>/dev/null || true)"
if [ ! -w "$path" ] || [ -n "$unwritable" ]; then
sudo find "$path" -xdev \( -type d -o -type f \) -exec chown "$owner" {} +
fi
unwritable="$(find "$path" -xdev \( -type d -o -type f \) ! -writable -print -quit 2>/dev/null || true)"
if [ ! -w "$path" ] || [ -n "$unwritable" ]; then
echo "fix-workspace-permissions: $path is not writable as $(id -un)" >&2
exit 1
fi
}
for path in \
/workspaces/fluxer/target \
/home/vscode/.cargo/registry \
/home/vscode/.cargo/git \
/home/vscode/.local \
/home/vscode/.local/share/pnpm/store \
/workspaces/fluxer/fluxer_docs/.venv; do
repair_tree "$path"
done
while IFS= read -r -d '' path; do
if mountpoint -q "$path"; then
repair_tree "$path"
fi
done < <(find /workspaces/fluxer -maxdepth 4 -type d -name node_modules -prune -print0)
+1 -2
View File
@@ -1,11 +1,10 @@
port: 7880
keys:
devkey: secret
devkey: fluxer-livekit-development-secret
rtc:
tcp_port: 7881
udp_port: 7882-7892
node_ip: 127.0.0.1
use_mdns: true
stun_servers:
+42 -1
View File
@@ -524,6 +524,7 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
PUBLIC_DL_BASE: https://api.fluxer.app/dl
@@ -553,11 +554,29 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_payload
- name: Prepare GitHub release assets
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_release_assets
- name: Publish GitHub release descriptor
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_descriptor
- name: Upload payload to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_payload
- name: Upload GitHub release asset handoff
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_release_assets
- name: Build summary
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
@@ -577,9 +596,17 @@ jobs:
- upload
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 10
timeout-minutes: 60
permissions:
contents: write
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -590,6 +617,12 @@ jobs:
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: Download GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_release_assets
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
@@ -614,8 +647,16 @@ jobs:
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
--asset-dir release_assets
)
if [[ "${CHANNEL}" == "canary" ]]; then
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
- name: Publish GitHub release readiness marker
env:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
+2 -7
View File
@@ -30,12 +30,6 @@ FLUXER_POSTGRES_PASSWORD=fluxer
FLUXER_POSTGRES_SSL=false
FLUXER_POSTGRES_MAX_CONNECTIONS=20
FLUXER_POSTGRES_KV_TABLE=fluxer_kv
FLUXER_CASSANDRA_HOSTS=cassandra
FLUXER_CASSANDRA_PORT=9042
FLUXER_CASSANDRA_KEYSPACE=fluxer
FLUXER_CASSANDRA_LOCAL_DC=datacenter1
FLUXER_CASSANDRA_USERNAME=fluxer
FLUXER_CASSANDRA_PASSWORD=fluxer
FLUXER_KV_URL=redis://valkey:6379/0
FLUXER_NATS_URL=nats://nats:4222
FLUXER_NATS_JETSTREAM_URL=nats://nats:4222
@@ -66,8 +60,9 @@ FLUXER_S3_BUCKET_STATIC=fluxer-static
FLUXER_LIVEKIT_ENABLED=true
FLUXER_LIVEKIT_URL=ws://localhost:8088/livekit
FLUXER_LIVEKIT_INTERNAL_URL=http://localhost:7880
FLUXER_LIVEKIT_API_KEY=devkey
FLUXER_LIVEKIT_API_SECRET=secret
FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
+1
View File
@@ -55,6 +55,7 @@ FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
FLUXER_EMAIL_FROM_EMAIL=[email protected]
FLUXER_EMAIL_FROM_NAME=Fluxer
FLUXER_EMAIL_APP_BASE_URL=
FLUXER_EMAIL_SMTP_HOST=
FLUXER_EMAIL_SMTP_PORT=587
FLUXER_EMAIL_SMTP_USERNAME=
+2
View File
@@ -47,6 +47,7 @@ x-fluxer-env: &fluxer-env
FLUXER_LIVEKIT_ENABLED: "true"
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
@@ -56,6 +57,7 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-none}
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-Fluxer}
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-587}
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
-2
View File
@@ -9896,8 +9896,6 @@
"GLOBAL_IP_BANNED",
"GLOBAL_IP_TEMPORARILY_BANNED",
"IP_BANNED",
"RESIDENTIAL_PROXY_BLOCKED",
"TOR_BLOCKED",
"MAX_ANIMATED_EMOJIS",
"MAX_APPLICATIONS",
"MAX_BOOKMARKS",
+31 -5
View File
@@ -23,6 +23,7 @@ type CacheEntry = {
};
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 10_000;
const geoipCache = new Map<string, CacheEntry>();
let maxmindReader: Reader<CityResponse> | null = null;
@@ -85,6 +86,32 @@ function isAsciiUpperAlpha2(value: string): boolean {
);
}
function getCachedGeoipResult(cacheKey: string, normalizedIp: string): GeoipResult | null {
const cached = geoipCache.get(cacheKey);
if (!cached) {
return null;
}
if (Date.now() >= cached.expiresAt) {
geoipCache.delete(cacheKey);
return null;
}
geoipCache.delete(cacheKey);
geoipCache.set(cacheKey, cached);
return {...cached.result, normalizedIp};
}
function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
geoipCache.delete(cacheKey);
if (geoipCache.size >= CACHE_MAX_ENTRIES) {
const oldestKey = geoipCache.keys().next().value;
if (oldestKey === undefined) {
throw new Error('GeoIP cache reached capacity without an entry to evict');
}
geoipCache.delete(oldestKey);
}
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
}
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
try {
const reader = await ensureReader(dbPath);
@@ -108,14 +135,13 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
}
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
const now = Date.now();
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
const cached = geoipCache.get(cacheKey);
if (cached && now < cached.expiresAt) {
return {...cached.result, normalizedIp: clean};
const cached = getCachedGeoipResult(cacheKey, clean);
if (cached) {
return cached;
}
const result = await lookupMaxmind(clean, dbPath);
geoipCache.set(cacheKey, {result, expiresAt: now + CACHE_TTL_MS});
setCachedGeoipResult(cacheKey, result);
return result;
}
+1 -1
View File
@@ -39,7 +39,7 @@ function AbuseAwareAppErrorHandler(err: Error, ctx: Context<HonoEnv>): Response
export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIAppResult> {
const {config, logger} = options;
const shutdownApiLifecycle = createShutdown(logger);
const shutdownApiLifecycle = createShutdown(config, logger);
setIsDevelopment(config.nodeEnv === 'development');
const routes = new Hono<HonoEnv>({strict: true});
configureMiddleware(routes, {
+38
View File
@@ -19,6 +19,26 @@ function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/u, '');
}
function resolveEmailAppBaseUrl(master: MasterConfig): string {
const configuredAppBaseUrl = master.integrations.email.app_base_url.trim();
if (!configuredAppBaseUrl) return trimTrailingSlash(master.endpoints.app);
try {
const appBaseUrl = new URL(configuredAppBaseUrl);
if (
(appBaseUrl.protocol !== 'http:' && appBaseUrl.protocol !== 'https:') ||
appBaseUrl.username ||
appBaseUrl.password ||
appBaseUrl.search ||
appBaseUrl.hash
) {
throw new Error(`Invalid email app base URL: ${configuredAppBaseUrl}`);
}
return trimTrailingSlash(appBaseUrl.toString());
} catch {
throw new Error(`Invalid email app base URL: ${configuredAppBaseUrl}`);
}
}
function resolveGatewayInternalUrl(master: MasterConfig): string {
const configuredInternalGateway = (
master.internal as {
@@ -65,6 +85,18 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
return Array.from(normalized);
}
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
const normalized = new Set<string>();
for (const value of values) {
const countryCode = value.trim().toUpperCase();
if (!/^[A-Z]{2}$/u.test(countryCode)) {
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
}
normalized.add(countryCode);
}
return normalized;
}
function mapPushProviderApps(
apps:
| Array<{
@@ -122,6 +154,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
nodeEnv: master.env === 'test' ? 'development' : master.env,
port: master.services.api.port,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
desktopGitHubRedirectCountries: normalizeCountryCodes(
master.services.api.desktop_github_redirect_countries,
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
port: cassandraSource?.port ?? 9042,
@@ -254,6 +290,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
fromEmail: master.integrations.email.from_email,
fromName: master.integrations.email.from_name,
appBaseUrl: resolveEmailAppBaseUrl(master),
smtp: master.integrations.email.smtp
? {
host: master.integrations.email.smtp.host,
@@ -306,6 +343,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
apiSecret: master.integrations.voice.api_secret,
webhookUrl: master.integrations.voice.webhook_url,
url: master.integrations.voice.url,
internalUrl: master.integrations.voice.internal_url,
defaultRegion: master.integrations.voice.default_region,
},
stripe: {
+28 -15
View File
@@ -36,6 +36,11 @@ import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
import {WorkerService} from '../worker/WorkerService';
let jsConnectionManager: JetStreamConnectionManager | null = null;
function unsupportedDatabaseBackend(backend: never): never {
throw new Error(`Unsupported database backend during shutdown: ${String(backend)}`);
}
export function createInitializer(config: APIConfig, logger: ILogger): () => Promise<void> {
return async (): Promise<void> => {
try {
@@ -131,7 +136,7 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
try {
const kvDeletionQueue = getKVAccountDeletionQueue();
if (await kvDeletionQueue.needsRebuild()) {
logger.warn('KV deletion queue needs rebuild, rebuilding...');
logger.info('KV deletion queue needs rebuild, rebuilding...');
await kvDeletionQueue.rebuildState();
} else {
logger.info('KV deletion queue state is healthy');
@@ -204,13 +209,13 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
logger.info('API service initialization complete');
} catch (error) {
logger.error({error}, 'API service initialization failed');
await createShutdown(logger)();
await createShutdown(config, logger)();
throw error;
}
};
}
export function createShutdown(logger: ILogger): () => Promise<void> {
export function createShutdown(config: APIConfig, logger: ILogger): () => Promise<void> {
return async (): Promise<void> => {
logger.info('Shutting down API service...');
if (jsConnectionManager) {
@@ -258,18 +263,26 @@ export function createShutdown(logger: ILogger): () => Promise<void> {
} catch (error) {
logger.error({error}, 'Error shutting down report service');
}
try {
setDatabaseQueryExecutor(null);
await shutdownPostgres();
logger.info('Postgres client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Postgres client');
}
try {
await shutdownCassandra();
logger.info('Cassandra client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Cassandra client');
setDatabaseQueryExecutor(null);
switch (config.database.backend) {
case 'postgres':
try {
await shutdownPostgres();
logger.info('Postgres client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Postgres client');
}
break;
case 'cassandra':
try {
await shutdownCassandra();
logger.info('Cassandra client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Cassandra client');
}
break;
default:
unsupportedDatabaseBackend(config.database.backend);
}
logger.info('API service shutdown complete');
};
+3 -1
View File
@@ -66,7 +66,9 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
TestHarnessController(routes);
}
UserController(routes);
registerInboundSmsWebhook(routes);
if (config.sms.enabled) {
registerInboundSmsWebhook(routes);
}
WebhookController(routes);
OAuth2Controller(routes);
OAuth2ApplicationsController(routes);
+32 -19
View File
@@ -359,13 +359,36 @@ export async function login(
const MFA_TICKET_MAX_ATTEMPTS = 5;
const MFA_USER_MAX_ATTEMPTS = 10;
const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes');
async function consumeMfaAttempt(
ctx: ApiContext,
{userId, ticket, field}: {userId: string; ticket: string; field: string},
): Promise<void> {
const {cache, rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `mfa:user:${userId}`,
maxAttempts: MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
const ticketLimit = await rateLimit.checkLimit({
identifier: `mfa:ticket:${ticket}`,
maxAttempts: MFA_TICKET_MAX_ATTEMPTS,
windowMs: ms('5 minutes'),
});
if (!ticketLimit.allowed) {
await cache.delete(`mfa-ticket:${ticket}`);
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
}
export async function loginMfaTotp(
ctx: ApiContext,
{code, ticket, request}: LoginMfaTotpParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -378,32 +401,19 @@ export async function loginMfaTotp(
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
}
const userAttemptsKey = `mfa-user-attempts:${user.id}`;
const userAttempts = (await cache.get<number>(userAttemptsKey)) ?? 0;
if (userAttempts >= MFA_USER_MAX_ATTEMPTS) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
const isValid = await AuthMfa.verifyMfaCode(ctx, {
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
const attemptsKey = `mfa-ticket-attempts:${ticket}`;
if (!isValid) {
await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW);
const attempts = ((await cache.get<number>(attemptsKey)) ?? 0) + 1;
if (attempts >= MFA_TICKET_MAX_ATTEMPTS) {
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
} else {
await cache.set(attemptsKey, attempts, seconds('5 minutes'));
}
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
await cache.delete(userAttemptsKey);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
@@ -415,7 +425,7 @@ export async function loginMfaWebAuthn(
ctx: ApiContext,
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -425,8 +435,11 @@ export async function loginMfaWebAuthn(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
await cache.delete(`mfa-ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
+1 -1
View File
@@ -267,7 +267,7 @@ export async function resetPassword(
},
user.toRow(),
);
await users.deleteAllAuthSessions(user.id);
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.deletePasswordResetToken(data.token);
const hasMfa =
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
@@ -311,7 +311,7 @@ describe('Auth registration', () => {
date_of_birth: '2000-01-01',
consent: true,
})
.expect(403, 'TOR_BLOCKED')
.expect(403, 'GLOBAL_IP_BANNED')
.execute();
} finally {
torExitListCache.clearForTesting();
@@ -13,6 +13,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
import {SYSTEM_USER_ID} from '../../constants/Core';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {Channel} from '../../models/Channel';
import type {GuildMember} from '../../models/GuildMember';
@@ -176,9 +177,15 @@ export abstract class BaseChannelAuthService {
const hasPermission = async (permission: bigint): Promise<boolean> => {
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
};
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData: guildDataResult!,
userId,
});
const checkPermission = async (permission: bigint): Promise<void> => {
const allowed = await hasPermission(permission);
if (!allowed) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
await checkPermission(Permissions.VIEW_CHANNEL);
const parentCategory = await this.getParentCategoryContentWarningView({
@@ -186,12 +186,7 @@ export class ChannelOperationsService {
let permissionOverwrites = channel.permissionOverwrites;
if (data.permission_overwrites !== undefined) {
const guildId = createGuildID(BigInt(guild.id));
const canManageRoles = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
await checkPermission(Permissions.MANAGE_ROLES);
const isOwner = guild.owner_id === userId.toString();
const channelPermissions = await this.gatewayService.getUserPermissions({
guildId,
@@ -205,6 +200,17 @@ export class ChannelOperationsService {
throw new MissingPermissionsError();
}
}
const nextDeny = new Map<RoleID | UserID, bigint>();
for (const overwrite of data.permission_overwrites ?? []) {
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
}
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
if ((removedDeny & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
}
permissionOverwrites = new Map();
for (const overwrite of data.permission_overwrites ?? []) {
@@ -590,6 +596,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -615,6 +622,8 @@ export class ChannelOperationsService {
const sanitizedDeny = protectedBits.deny;
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.set(
@@ -697,6 +706,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -705,6 +715,15 @@ export class ChannelOperationsService {
const removedRole = overwrites.get(createRoleID(params.overwriteId));
const removedUser = overwrites.get(createUserID(params.overwriteId));
const removed = removedRole ?? removedUser;
if (removed) {
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
});
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
overwrites.delete(createRoleID(params.overwriteId));
overwrites.delete(createUserID(params.overwriteId));
const updated = await this.channelRepository.channelData.upsert({
@@ -1191,7 +1191,7 @@ export class MessageSendService {
await this.deps.mentionService.handleMentionTasks({
guildId: channel.guildId,
message,
authorId: createUserID(0n),
authorId: createUserID(BigInt(webhook.id)),
mentionHere: mentionData?.mentionHere ?? false,
});
await this.deps.dispatchService.dispatchMessageCreate({
+3
View File
@@ -35,6 +35,7 @@ export interface APIConfig {
nodeEnv: 'development' | 'production';
port: number;
ipBanExemptIps: Array<string>;
desktopGitHubRedirectCountries: ReadonlySet<string>;
cassandra: {
hosts: string;
port: number;
@@ -158,6 +159,7 @@ export interface APIConfig {
webhookSecret?: string;
fromEmail: string;
fromName: string;
appBaseUrl: string;
smtp?: {
host: string;
port: number;
@@ -204,6 +206,7 @@ export interface APIConfig {
apiSecret?: string;
webhookUrl?: string;
url?: string;
internalUrl?: string;
defaultRegion?: {
id: string;
name: string;
@@ -19,6 +19,8 @@ interface PageState {
const VALUE_SEPARATOR = '\u001f';
const ENCODED_TYPE_KEY = '__fluxer_type';
const POSTGRES_KV_SCHEMA_LOCK_NAMESPACE = 0x46584b56;
const POSTGRES_KV_SCHEMA_LOCK_TIMEOUT = '120s';
function normalizeCql(cql: string): string {
return cql.replace(/\s+/g, ' ').trim();
@@ -354,8 +356,13 @@ function parseEqWhere(whereSql: string, cql: string): ReadonlyArray<EqWhereExpr>
}
export async function ensurePostgresKvSchema(client: IPostgresClient): Promise<void> {
const table = quoteIdentifier(client.kvTable());
await client.query(`
const kvTable = client.kvTable();
const table = quoteIdentifier(kvTable);
await client.transaction(async (db) => {
await db.query("SELECT set_config('statement_timeout', $1, true)", [POSTGRES_KV_SCHEMA_LOCK_TIMEOUT]);
await db.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [POSTGRES_KV_SCHEMA_LOCK_NAMESPACE, kvTable]);
await db.query("SELECT set_config('statement_timeout', '0', true)");
await db.query(`
CREATE TABLE IF NOT EXISTS ${table} (
table_name text NOT NULL,
partition_key text NOT NULL,
@@ -365,28 +372,29 @@ CREATE TABLE IF NOT EXISTS ${table} (
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (table_name, row_key)
)`);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_partition_row_idx`)} ON ${table} (table_name, partition_key, row_key)`,
);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_row_key_c_idx`)} ON ${table} (table_name, row_key COLLATE "C")`,
);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_expires_idx`)} ON ${table} (expires_at) WHERE expires_at IS NOT NULL`,
);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_messages_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'messages'`,
);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
);
await client.query(`
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_partition_row_idx`)} ON ${table} (table_name, partition_key, row_key)`,
);
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_row_key_c_idx`)} ON ${table} (table_name, row_key COLLATE "C")`,
);
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_expires_idx`)} ON ${table} (expires_at) WHERE expires_at IS NOT NULL`,
);
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_messages_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'messages'`,
);
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
);
await db.query(`
UPDATE ${table}
SET partition_key = split_part(row_key, chr(31), 1) || chr(31) || split_part(row_key, chr(31), 2)
WHERE table_name = 'messages'
AND partition_key = row_key
AND split_part(row_key, chr(31), 3) <> ''`);
await client.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${client.kvTable()}_partition_idx`)}`);
await db.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${kvTable}_partition_idx`)}`);
});
}
export async function pruneExpiredPostgresKvRows(client: IPostgresClient, batchSize = 5000): Promise<number> {
@@ -17,6 +17,7 @@ export interface GiftCodeRow {
stripe_payment_intent_id: Nullish<string>;
visionary_sequence_number: Nullish<number>;
checkout_session_id: Nullish<string>;
revoked_at?: Nullish<Date>;
version: number;
}
@@ -103,6 +104,7 @@ export const GIFT_CODE_COLUMNS = [
'stripe_payment_intent_id',
'visionary_sequence_number',
'checkout_session_id',
'revoked_at',
'version',
] as const;
export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const;
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {DesktopArch, DesktopChannel, DesktopPlatform} from '@fluxer/schema/src/domains/download/DownloadSchemas';
import {isJsonRecord} from '../utils/JsonBoundaryUtils';
const DESKTOP_BUCKET_PREFIX = 'desktop';
const MIN_RELEASE_ROUTE_COUNT = 28;
const MAX_RELEASE_ROUTE_COUNT = 128;
const MIN_RELEASE_ASSET_COUNT = 24;
interface DesktopReleaseAsset {
storage_key: string;
release_asset: string;
sha256: string;
size: number;
}
interface DesktopReleaseDescriptor {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
assets: Array<DesktopReleaseAsset>;
}
interface DesktopReleaseReadiness {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
descriptor_sha256: string;
}
interface DesktopArtifactScope {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
}
export function parseDesktopArtifactScope(key: string): DesktopArtifactScope | null {
const segments = key.split('/');
if (segments.length !== 5 || segments[0] !== DESKTOP_BUCKET_PREFIX || segments[4].length === 0) {
return null;
}
const [, channel, plat, arch] = segments;
if (
(channel !== 'stable' && channel !== 'canary') ||
(plat !== 'win32' && plat !== 'darwin' && plat !== 'linux') ||
(arch !== 'x64' && arch !== 'arm64')
) {
return null;
}
return {channel, plat, arch};
}
function parseDesktopReleaseAsset(value: unknown): DesktopReleaseAsset | null {
if (
!isJsonRecord(value) ||
typeof value.storage_key !== 'string' ||
typeof value.release_asset !== 'string' ||
typeof value.sha256 !== 'string' ||
typeof value.size !== 'number'
) {
return null;
}
if (
!/^desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64)\/[A-Za-z0-9._-]+$/u.test(value.storage_key) ||
!/^[A-Za-z0-9._-]+$/u.test(value.release_asset) ||
!/^[a-f0-9]{64}$/u.test(value.sha256) ||
!Number.isSafeInteger(value.size) ||
value.size <= 0
) {
return null;
}
return {
storage_key: value.storage_key,
release_asset: value.release_asset,
sha256: value.sha256,
size: value.size,
};
}
export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDescriptor | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
!Array.isArray(value.assets) ||
value.assets.length < MIN_RELEASE_ROUTE_COUNT ||
value.assets.length > MAX_RELEASE_ROUTE_COUNT
) {
return null;
}
const expectedTag = `fluxer-desktop-${value.channel}@${value.version}`;
const expectedStoragePrefix = `desktop/${value.channel}/`;
const expectedReleasePrefix = `${value.channel === 'canary' ? 'Fluxer-Canary' : 'Fluxer'}-${value.version}-`;
const descriptorName = `${expectedReleasePrefix}release-manifest.json`;
if (value.release_tag !== expectedTag) {
return null;
}
const storageKeys = new Set<string>();
const routeCounts = new Map<string, number>();
const releaseAssets = new Map<string, {sha256: string; size: number}>();
const releaseAssetNames = new Map<string, string>([[descriptorName.toLowerCase(), descriptorName]]);
const assets: Array<DesktopReleaseAsset> = [];
for (const rawAsset of value.assets) {
const asset = parseDesktopReleaseAsset(rawAsset);
if (
!asset ||
!asset.storage_key.startsWith(expectedStoragePrefix) ||
!asset.release_asset.startsWith(expectedReleasePrefix) ||
storageKeys.has(asset.storage_key)
) {
return null;
}
storageKeys.add(asset.storage_key);
const [, , platform, arch, filename] = asset.storage_key.split('/');
const platformToken = platform === 'win32' ? 'win' : platform === 'darwin' ? 'mac' : 'linux';
const releaseFilename =
platform === 'darwin' && filename.toLowerCase() === 'releases.json' ? 'releases.json' : filename;
const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix)
? filename
: `${expectedReleasePrefix}${platformToken}-${arch}-${releaseFilename}`;
if (
asset.release_asset !== expectedReleaseAsset ||
asset.release_asset.toLowerCase() === descriptorName.toLowerCase()
) {
return null;
}
const caseFoldedReleaseAsset = asset.release_asset.toLowerCase();
const existingReleaseAssetName = releaseAssetNames.get(caseFoldedReleaseAsset);
if (existingReleaseAssetName && existingReleaseAssetName !== asset.release_asset) {
return null;
}
releaseAssetNames.set(caseFoldedReleaseAsset, asset.release_asset);
const scope = `${platform}/${arch}`;
routeCounts.set(scope, (routeCounts.get(scope) ?? 0) + 1);
const existing = releaseAssets.get(asset.release_asset);
if (existing && (existing.sha256 !== asset.sha256 || existing.size !== asset.size)) {
return null;
}
releaseAssets.set(asset.release_asset, {sha256: asset.sha256, size: asset.size});
assets.push(asset);
}
if (releaseAssets.size < MIN_RELEASE_ASSET_COUNT || releaseAssets.size > MAX_RELEASE_ROUTE_COUNT) {
return null;
}
const expectedRouteCounts = new Map([
['darwin/arm64', 4],
['darwin/x64', 4],
['linux/arm64', 4],
['linux/x64', 4],
['win32/arm64', 6],
['win32/x64', 6],
]);
if (
routeCounts.size !== expectedRouteCounts.size ||
Array.from(expectedRouteCounts).some(([scope, count]) => (routeCounts.get(scope) ?? 0) < count)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
assets,
};
}
export function parseDesktopReleaseReadiness(value: unknown): DesktopReleaseReadiness | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
typeof value.descriptor_sha256 !== 'string' ||
!/^[a-f0-9]{64}$/u.test(value.descriptor_sha256)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
descriptor_sha256: value.descriptor_sha256,
};
}
@@ -17,6 +17,7 @@ import {Config} from '../Config';
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import type {HonoEnv} from '../types/HonoEnv';
import {Validator} from '../Validator';
import {resolveArtifactRoute} from './DownloadRouting';
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
import {
DESKTOP_REDIRECT_PREFIX,
@@ -29,6 +30,17 @@ function artifactFilename(key: string, filenameOverride?: string): string {
return filenameOverride ?? key.split('/').pop() ?? 'download';
}
function artifactRedirectResponse(location: string, cacheControl = 'no-store'): Response {
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': cacheControl,
'Accept-Ranges': 'bytes',
}),
});
}
function setCommonArtifactHeaders(
headers: Headers,
key: string,
@@ -87,8 +99,12 @@ async function streamArtifactResponse(
cacheControl: string,
filenameOverride?: string,
): Promise<Response> {
const route = await resolveArtifactRoute({request: ctx.req.raw, downloadService, key, cacheControl});
if (route.kind === 'redirect') {
return artifactRedirectResponse(route.location, route.cacheControl);
}
if (ctx.req.method === 'HEAD') {
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
return headArtifactResponse(ctx, downloadService, key, route.cacheControl, filenameOverride);
}
if (downloadService.isPresignedDownloadEnabled()) {
const location = await downloadService.getPresignedDownloadRedirect({
@@ -99,14 +115,7 @@ async function streamArtifactResponse(
if (!location) {
return ctx.text('Not Found', 404);
}
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': 'no-store',
'Accept-Ranges': 'bytes',
}),
});
return artifactRedirectResponse(location);
}
const range = ctx.req.header('range') ?? undefined;
let result: DownloadStreamResult | null;
@@ -117,7 +126,7 @@ async function streamArtifactResponse(
const headers = new Headers();
headers.set('Accept-Ranges', 'bytes');
headers.set('Content-Range', `bytes */${error.totalSize}`);
headers.set('Cache-Control', cacheControl);
headers.set('Cache-Control', route.cacheControl);
return new Response(null, {status: 416, headers});
}
throw error;
@@ -129,7 +138,7 @@ async function streamArtifactResponse(
setCommonArtifactHeaders(
headers,
key,
cacheControl,
route.cacheControl,
filenameOverride,
result.contentType,
result.contentDisposition,
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../Config';
import {Logger} from '../Logger';
import {lookupGeoip} from '../utils/IpUtils';
import {parseDesktopArtifactScope} from './DesktopReleaseContract';
import type {DownloadService, GitHubDesktopReleaseResolution} from './DownloadService';
const COUNTRY_DEPENDENT_CACHE_CONTROL = 'private, no-store';
type ArtifactRoute =
| {kind: 'storage'; cacheControl: string}
| {kind: 'redirect'; cacheControl: string; location: string};
export async function resolveArtifactRoute(params: {
request: Request;
downloadService: DownloadService;
key: string;
cacheControl: string;
}): Promise<ArtifactRoute> {
if (Config.instance.selfHosted) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
if (Config.desktopGitHubRedirectCountries.size === 0) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
if (!parseDesktopArtifactScope(params.key)) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
const geoip = await lookupGeoip(params.request);
const countryCode = geoip.countryCode?.trim().toUpperCase();
if (!countryCode || !Config.desktopGitHubRedirectCountries.has(countryCode)) {
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
let release: GitHubDesktopReleaseResolution;
try {
release = await params.downloadService.resolveGitHubDesktopRelease(params.key);
} catch (error) {
Logger.error({error, key: params.key}, 'Failed to resolve GitHub desktop download route');
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
if (release.kind === 'not_current') {
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
if (release.kind === 'ready') {
return {
kind: 'redirect',
cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL,
location: release.location,
};
}
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
+115 -27
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {posix} from 'node:path';
import {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
@@ -12,6 +13,11 @@ import type {
import {Config} from '../Config';
import type {IStorageService} from '../infrastructure/IStorageService';
import {isJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
import {
parseDesktopArtifactScope,
parseDesktopReleaseDescriptor,
parseDesktopReleaseReadiness,
} from './DesktopReleaseContract';
export const DOWNLOAD_PREFIX = '/dl';
export const DESKTOP_REDIRECT_PREFIX = `${DOWNLOAD_PREFIX}/desktop`;
@@ -46,6 +52,8 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app';
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download';
const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases';
function desktopBucketPrefix(test?: boolean): string {
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
@@ -176,9 +184,70 @@ interface ManifestFilenameResolutionParams extends LatestFilenameLookupParams {
filename: string;
}
export type GitHubDesktopReleaseResolution =
| {kind: 'not_current'}
| {kind: 'awaiting_release'}
| {kind: 'ready'; location: string};
export class DownloadService {
constructor(private readonly storageService: IStorageService) {}
async resolveGitHubDesktopRelease(key: string): Promise<GitHubDesktopReleaseResolution> {
const scope = parseDesktopArtifactScope(key);
if (!scope) {
return {kind: 'not_current'};
}
const manifestKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${scope.plat}/${scope.arch}/manifest.json`;
const manifest = await this.readOptionalJsonObjectFromStorage(manifestKey);
if (
!isDesktopManifest(manifest) ||
manifest.channel !== scope.channel ||
manifest.platform !== scope.plat ||
manifest.arch !== scope.arch
) {
return {kind: 'not_current'};
}
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
if (descriptorText == null) {
return {kind: 'not_current'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
if (
!descriptor ||
descriptor.channel !== scope.channel ||
descriptor.version !== manifest.version ||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
) {
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
}
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
if (!releaseAsset) {
return {kind: 'not_current'};
}
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
if (marker == null) {
return {kind: 'awaiting_release'};
}
const readiness = parseDesktopReleaseReadiness(marker);
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
if (
!readiness ||
readiness.channel !== descriptor.channel ||
readiness.version !== descriptor.version ||
readiness.release_tag !== descriptor.release_tag ||
readiness.source_sha !== descriptor.source_sha ||
readiness.descriptor_sha256 !== descriptorSha256
) {
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
}
return {
kind: 'ready',
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
};
}
async resolveLatestDesktopKey(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
@@ -659,6 +728,11 @@ export class DownloadService {
}
private async readJsonObjectFromStorage(key: string): Promise<unknown | null> {
const text = await this.readTextFromStorage(key);
return text == null ? null : parseJsonUnknown(text);
}
private async readTextFromStorage(key: string): Promise<string | null> {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key,
@@ -667,8 +741,29 @@ export class DownloadService {
return null;
}
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return parseJsonUnknown(text);
return new Response(body as ReadableStream).text();
}
private async readOptionalJsonObjectFromStorage(key: string): Promise<unknown | null> {
try {
return await this.readJsonObjectFromStorage(key);
} catch (error) {
if (isStorageNotFoundError(error)) {
return null;
}
throw error;
}
}
private async readOptionalTextFromStorage(key: string): Promise<string | null> {
try {
return await this.readTextFromStorage(key);
} catch (error) {
if (isStorageNotFoundError(error)) {
return null;
}
throw error;
}
}
private isValidSha256(value: string): boolean {
@@ -774,9 +869,11 @@ export class DownloadService {
const {ext, arch: archMap} = mapping;
const filenames = new Set<string>();
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
const modernFilename = this.buildModernArtifactFilename(channel, version, plat, archSuffix, ext);
if (modernFilename) {
filenames.add(modernFilename);
for (const productName of this.getModernProductNames(channel)) {
filenames.add(`${productName}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
if (format === 'portable') {
filenames.add(`${productName}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
}
}
if (format === 'setup') {
filenames.add(`fluxer-${channel}-${version}-${archSuffix}-setup${ext}`);
@@ -784,9 +881,6 @@ export class DownloadService {
filenames.add(`fluxer-${version}-${archSuffix}-setup${ext}`);
filenames.add(`Fluxer-${version}-${archSuffix}-Setup${ext}`);
} else if (format === 'portable') {
filenames.add(
`${this.getModernProductName(channel)}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`,
);
filenames.add(`fluxer-${channel}-${version}-portable-${archSuffix}${ext}`);
filenames.add(`Fluxer-${version}-portable-${archSuffix}${ext}`);
} else {
@@ -816,7 +910,6 @@ export class DownloadService {
}
const {ext, arch: archMap} = mapping;
const escapedExt = this.escapeRegex(ext);
const escapedModernFilenamePrefix = this.escapeRegex(this.getModernProductName(channel));
const modernPlatformToken = MODERN_PLATFORM_TOKENS[plat];
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
const patterns = [
@@ -828,18 +921,23 @@ export class DownloadService {
`^[Ff]luxer-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(archSuffix)}(?:-[Ss]etup)?${escapedExt}$`,
'u',
),
new RegExp(
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
'iu',
),
];
if (format === 'portable') {
for (const productName of this.getModernProductNames(channel)) {
const escapedProductName = this.escapeRegex(productName);
patterns.push(
new RegExp(
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
'iu',
),
);
if (format === 'portable') {
patterns.push(
new RegExp(
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
'iu',
),
);
}
}
for (const pattern of patterns) {
const match = filename.match(pattern);
@@ -852,18 +950,8 @@ export class DownloadService {
return null;
}
private getModernProductName(channel: DesktopChannel): string {
return channel === 'canary' ? 'Fluxer Canary' : 'Fluxer';
}
private buildModernArtifactFilename(
channel: DesktopChannel,
version: string,
plat: DesktopPlatform,
archToken: string,
ext: string,
): string {
return `${this.getModernProductName(channel)}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archToken}${ext}`;
private getModernProductNames(channel: DesktopChannel): Array<string> {
return channel === 'canary' ? ['Fluxer-Canary', 'Fluxer Canary'] : ['Fluxer'];
}
private getArchTokens(archToken: string | Array<string>): Array<string> {
@@ -14,9 +14,11 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import type {LimitConfigService} from '../../limits/LimitConfigService';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {IUserRepository} from '../../user/IUserRepository';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {ChannelOperationsService} from './channel/ChannelOperationsService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
export class GuildChannelService {
private readonly channelOps: ChannelOperationsService;
@@ -30,6 +32,7 @@ export class GuildChannelService {
snowflakeService: ISnowflakeService,
guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {
this.channelOps = new ChannelOperationsService(
channelRepository,
@@ -131,5 +134,12 @@ export class GuildChannelService {
permission: params.permission,
});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData,
userId: params.userId,
});
enforceGuildMfa(params.permission);
}
}
@@ -51,7 +51,7 @@ export class GuildDataService {
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
) {
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService, this.userRepository);
this.operationsService = new GuildOperationsService(
this.guildRepository,
this.channelRepository,
@@ -50,7 +50,7 @@ export class GuildMemberService {
ipInfoService: IpInfoService,
) {
this.userRepository = userRepository;
this.authService = new GuildMemberAuthService(gatewayService);
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
this.auditService = new GuildMemberAuditService(guildAuditLogService);
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {UserID} from '../../BrandedTypes';
import type {IUserRepository} from '../../user/IUserRepository';
export const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export async function createGuildMfaEnforcer(params: {
userRepository: IUserRepository;
guildData: Pick<GuildResponse, 'mfa_level' | 'owner_id'>;
userId: UserID;
}): Promise<(permission: bigint) => void> {
const {userRepository, guildData, userId} = params;
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
return (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
}
@@ -27,6 +27,7 @@ import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildBansToResponse} from '../GuildModel';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
export class GuildModerationService {
@@ -44,6 +45,19 @@ export class GuildModerationService {
this.searchIndexService = new GuildMemberSearchIndexService();
}
private async checkModerationPermission(params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
}): Promise<void> {
const {guildId, userId, permission} = params;
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
enforceGuildMfa(permission);
}
async banMember(
params: {
userId: UserID;
@@ -57,12 +71,7 @@ export class GuildModerationService {
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
if (userId === targetId) throw new UnknownGuildMemberError();
const targetUser = await this.userRepository.findUnique(targetId);
if (!targetUser) {
@@ -145,12 +154,7 @@ export class GuildModerationService {
requestCache: RequestCache;
}): Promise<Array<GuildBanResponse>> {
const {userId, guildId, requestCache} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const bans = await this.guildRepository.listBans(guildId);
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
}
@@ -164,12 +168,7 @@ export class GuildModerationService {
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const ban = await this.guildRepository.getBan(guildId, targetId);
if (!ban) {
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
@@ -26,6 +26,7 @@ import type {LimitConfigService} from '../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
import {GuildRole} from '../../models/GuildRole';
import type {IUserRepository} from '../../user/IUserRepository';
import {applyProtectedRolePermissions} from '../../utils/featureUtils';
import {computePermissionsDiff} from '../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../GuildAuditLogService';
@@ -33,6 +34,7 @@ import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildRoleToResponse} from '../GuildModel';
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {}
@@ -62,6 +64,7 @@ export class GuildRoleService {
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {}
async systemCreateRole(params: {
@@ -446,9 +449,11 @@ export class GuildRoleService {
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
return {
@@ -2,9 +2,8 @@
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
@@ -44,6 +43,7 @@ import {GuildChannelService} from './GuildChannelService';
import {GuildContentService} from './GuildContentService';
import {GuildDataService} from './GuildDataService';
import {GuildMemberService} from './GuildMemberService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildModerationService} from './GuildModerationService';
import {GuildRoleService} from './GuildRoleService';
import {GuildSearchService} from './GuildSearchService';
@@ -95,17 +95,6 @@ interface GuildAuth {
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}
const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export class GuildService {
public readonly data: GuildDataService;
public readonly members: GuildMemberService;
@@ -182,6 +171,7 @@ export class GuildService {
gatewayService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.moderation = new GuildModerationService(
guildRepository,
@@ -211,6 +201,7 @@ export class GuildService {
snowflakeService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.search = new GuildSearchService(
channelRepository,
@@ -561,17 +552,7 @@ export class GuildService {
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await this.userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
const enforceGuildMfa = (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
@@ -355,12 +355,18 @@ export class ChannelOperationsService {
requestCache,
});
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent});
await this.syncPermissionsWithParent({
guildId,
userId: params.userId,
channelId: target.id,
parentId: desiredParent,
});
}
}
private async syncPermissionsWithParent(params: {
guildId: GuildID;
userId: UserID;
channelId: ChannelID;
parentId: ChannelID;
}): Promise<void> {
@@ -368,6 +374,22 @@ export class ChannelOperationsService {
if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return;
const child = await this.channelRepository.findUnique(params.channelId);
if (!child || child.guildId !== params.guildId) return;
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: params.guildId,
userId: params.userId,
channelId: child.id,
});
if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) {
throw new MissingPermissionsError();
}
for (const [targetId, existing] of child.permissionOverwrites) {
const incomingDeny = parent.permissionOverwrites.get(targetId)?.deny ?? 0n;
if ((existing.deny & ~incomingDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
for (const [targetId, incoming] of parent.permissionOverwrites) {
const existingAllow = child.permissionOverwrites.get(targetId)?.allow ?? 0n;
if ((incoming.allow & ~existingAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
await this.channelRepository.upsert({
...child.toRow(),
permission_overwrites: new Map(
@@ -8,12 +8,14 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '../..
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import {Logger} from '../../../Logger';
import type {Guild} from '../../../models/Guild';
import type {IUserRepository} from '../../../user/IUserRepository';
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils';
import {requirePermission} from '../../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../../GuildAuditLogService';
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
import {mapGuildToGuildResponse} from '../../GuildModel';
import {GuildRepository} from '../../repositories/GuildRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -24,6 +26,7 @@ export class GuildDataHelpers {
constructor(
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly userRepository: IUserRepository,
) {}
private readonly guildRepository = new GuildRepository();
@@ -33,7 +36,7 @@ export class GuildDataHelpers {
try {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new UnknownGuildError();
return this.createGuildAuth({guildData, guildId, userId});
return await this.createGuildAuth({guildData, guildId, userId});
} catch (error) {
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
throw new AccessDeniedError();
@@ -42,10 +45,16 @@ export class GuildDataHelpers {
}
}
private createGuildAuth(params: {guildData: GuildResponse; guildId: GuildID; userId: UserID}): GuildAuth {
private async createGuildAuth(params: {
guildData: GuildResponse;
guildId: GuildID;
userId: UserID;
}): Promise<GuildAuth> {
const {guildData, guildId, userId} = params;
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
await requirePermission(this.gatewayService, {guildId, userId, permission});
enforceGuildMfa(permission);
};
return {guildData, checkPermission};
}
@@ -5,6 +5,8 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IUserRepository} from '../../../user/IUserRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -16,14 +18,19 @@ interface GuildAuth {
}
export class GuildMemberAuthService {
constructor(private readonly gatewayService: IGatewayService) {}
constructor(
private readonly gatewayService: IGatewayService,
private readonly userRepository: IUserRepository,
) {}
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const checkTargetMember = async (targetUserId: UserID) => {
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
@@ -60,7 +60,7 @@ export class KVAccountDeletionQueueService {
}
async rebuildState(): Promise<void> {
Logger.info('Starting deletion queue rebuild from Cassandra');
Logger.info('Starting deletion queue rebuild from primary database');
try {
await this.kvClient.del(QUEUE_KEY);
await this.kvClient.del(STATE_VERSION_KEY);
@@ -109,6 +109,18 @@ function createRoomServiceClient(endpoint: string, apiKey: string, apiSecret: st
return client;
}
function resolveRoomServiceEndpoint(server: VoiceServerRecord): string {
const defaultRegion = Config.voice.defaultRegion;
const internalUrl = Config.voice.internalUrl;
if (!defaultRegion || !internalUrl) {
return server.endpoint;
}
if (server.regionId !== defaultRegion.id || server.serverId !== `${defaultRegion.id}-server-1`) {
return server.endpoint;
}
return internalUrl;
}
export class LiveKitService extends ILiveKitService {
private serverClients: Map<string, Map<string, ServerClientConfig>> = new Map();
private topology: VoiceTopology;
@@ -453,12 +465,13 @@ export class LiveKitService extends ILiveKitService {
const servers = this.topology.getServersForRegion(region.id);
const serverMap: Map<string, ServerClientConfig> = new Map();
for (const server of servers) {
const roomServiceEndpoint = resolveRoomServiceEndpoint(server);
serverMap.set(server.serverId, {
endpoint: server.endpoint,
apiKey: server.apiKey,
apiSecret: server.apiSecret,
isActive: server.isActive,
roomServiceClient: createRoomServiceClient(server.endpoint, server.apiKey, server.apiSecret),
roomServiceClient: createRoomServiceClient(roomServiceEndpoint, server.apiKey, server.apiSecret),
});
}
newMap.set(region.id, serverMap);
@@ -284,7 +284,7 @@ export function setInjectedAccountPolicyEvaluator(evaluator: IAccountPolicyEvalu
function getRegistrationRiskEvaluator(): IRegistrationRiskEvaluator {
if (_registrationRiskEvaluator) return _registrationRiskEvaluator;
if (!Config.risk.enabled) {
Logger.warn(
Logger.info(
{},
'[ServiceMiddleware] integrations.risk_integration.enabled is false — account risk scoring is disabled',
);
@@ -160,7 +160,7 @@ function createEmailServiceForConfig(
enabled: emailConfigSource.enabled,
fromEmail: emailConfigSource.fromEmail,
fromName: emailConfigSource.fromName,
appBaseUrl: Config.endpoints.webApp,
appBaseUrl: emailConfigSource.appBaseUrl,
marketingBaseUrl: Config.endpoints.marketing,
};
return new EmailService(emailConfig, emailI18n, createEmailProvider(emailConfigSource), bouncedEmailChecker);
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {TorBlockedError} from '@fluxer/errors/src/domains/moderation/TorBlockedError';
import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {createMiddleware} from 'hono/factory';
import {Config} from '../Config';
@@ -13,7 +13,10 @@ export const TorExitMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
clientIpHeaderName: Config.proxy.client_ip_header,
});
if (clientIp && torExitListCache.isTorExit(clientIp)) {
throw new TorBlockedError();
throw new IpBannedError({
ipAddress: clientIp,
kind: 'permanent',
});
}
await next();
});
+3
View File
@@ -113,6 +113,7 @@ export class GiftCode {
readonly stripePaymentIntentId: string | null;
readonly visionarySequenceNumber: number | null;
readonly checkoutSessionId: string | null;
readonly revokedAt: Date | null;
readonly version: number;
constructor(row: GiftCodeRow) {
@@ -128,6 +129,7 @@ export class GiftCode {
this.stripePaymentIntentId = row.stripe_payment_intent_id ?? null;
this.visionarySequenceNumber = row.visionary_sequence_number ?? null;
this.checkoutSessionId = row.checkout_session_id ?? null;
this.revokedAt = row.revoked_at ?? null;
this.version = row.version;
}
@@ -144,6 +146,7 @@ export class GiftCode {
stripe_payment_intent_id: this.stripePaymentIntentId,
visionary_sequence_number: this.visionarySequenceNumber,
checkout_session_id: this.checkoutSessionId,
revoked_at: this.revokedAt,
version: this.version,
};
}
-4
View File
@@ -25344,8 +25344,6 @@
"GLOBAL_IP_BANNED",
"GLOBAL_IP_TEMPORARILY_BANNED",
"IP_BANNED",
"RESIDENTIAL_PROXY_BLOCKED",
"TOR_BLOCKED",
"MAX_ANIMATED_EMOJIS",
"MAX_APPLICATIONS",
"MAX_BOOKMARKS",
@@ -25660,8 +25658,6 @@
"Your IP address {ipAddress} has been permanently blocked from the Fluxer API by platform administrators. If you believe this is a mistake, contact [email protected] to appeal. Include this IP address in your appeal.",
"Your IP address {ipAddress} has been temporarily blocked from the Fluxer API for 24 hours because of abusive or unusual access patterns. We usually do not provide appeals for temporary API bans. Change IP addresses or wait for the ban to expire, and review the Fluxer API access patterns coming from your client.",
"Your IP address {ipAddress} has been permanently blocked from the Fluxer API by platform administrators. If you believe this is a mistake, contact [email protected] to appeal. Include this IP address in your appeal.",
"This user's IP is banned from this community.",
"This user's IP is banned from this community.",
"You've reached the maximum of {count, plural, one {# animated emoji} other {# animated emojis}}.",
"You've reached the maximum of {limit, plural, one {# application} other {# applications}}.",
"You've reached the maximum of {count, plural, one {# bookmark} other {# bookmarks}}.",
@@ -123,6 +123,9 @@ export class StripeDisputeWebhookHandler {
reason: 'gift_refund',
chargeId: charge.id,
});
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug({giftCode: giftCode.code, chargeId: charge.id}, 'Revoked unredeemed gift code after refund');
}
return;
}
@@ -196,6 +199,12 @@ export class StripeDisputeWebhookHandler {
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
'Premium revoked due to gift chargeback',
);
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug(
{giftCode: giftCode.code, chargeId: extractId(dispute.charge)},
'Revoked unredeemed gift code after chargeback',
);
}
await this.paymentFraudService.enforceAccountFraudAction({
userId: giftCode.createdByUserId,
@@ -40,7 +40,7 @@ export class StripeGiftService {
async getGiftCode(code: string): Promise<GiftCode> {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
throw new UnknownGiftCodeError();
}
return giftCode;
@@ -62,7 +62,7 @@ export class StripeGiftService {
}
try {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption');
throw new UnknownGiftCodeError();
}
@@ -258,6 +258,7 @@ export class StripeGiftService {
const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - redeemedGracePeriodMs;
return gifts
.filter((gift) => gift.revokedAt === null)
.filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
@@ -252,11 +252,11 @@ export class StripeRefundService {
return;
}
const user = await this.userRepository.findUnique(userId);
if (!user || user.firstRefundAt) {
if (!user) {
return;
}
const subscriptionId = refund.metadata.subscription_id;
if (subscriptionId) {
if (subscriptionId && !user.firstRefundAt) {
try {
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
} catch (error) {
@@ -439,7 +439,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips zero-amount subscription_update invoice without granting an extra monthly cycle', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -481,7 +481,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips paid subscription_update invoices so interval switches do not grant extra time', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -47,6 +47,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow {
duration_type: durationType,
duration_quantity: durationQuantity,
duration_months: durationMonths,
revoked_at: data.revoked_at ?? null,
};
}
@@ -171,6 +172,10 @@ export class GiftCodeRepository {
await batch.execute();
}
async revokeGiftCode(code: string): Promise<void> {
await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())}));
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
const batch = new BatchBuilder();
const patch: Record<string, DbOp<unknown>> = {};
@@ -37,6 +37,7 @@ export interface IUserContentRepository {
findGiftCodesByRedeemer(userId: UserID): Promise<Array<GiftCode>>;
redeemGiftCode(code: string, userId: UserID): Promise<void>;
unredeemGiftCode(code: string, userId: UserID): Promise<void>;
revokeGiftCode(code: string): Promise<void>;
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
@@ -62,6 +62,10 @@ export class UserContentRepository implements IUserContentRepository {
return this.giftCodeRepository.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.giftCodeRepository.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.giftCodeRepository.updateGiftCode(code, data);
}
@@ -645,6 +645,10 @@ export class UserRepository implements IUserRepositoryAggregate {
return this.contentRepo.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.contentRepo.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.contentRepo.updateGiftCode(code, data);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
import {z} from 'zod';
import {
@@ -19,6 +20,7 @@ import {Logger} from '../../Logger';
import {getWorkerDependencies} from '../WorkerContext';
const MENTION_CHUNK_SIZE = 250;
const MENTION_CHUNK_KEY_DIGEST_LENGTH = 32;
const MENTION_CHUNK_ENQUEUE_CONCURRENCY = 16;
const MENTION_SOURCE_PAGE_SIZE = 5000;
const PayloadSchema = z.object({
@@ -100,6 +102,14 @@ function toMentionChunkEntry(entry: GatewayMentionSourceEntry): MentionChunkEntr
};
}
function mentionChunkJobKey(messageId: string, chunk: Array<MentionChunkEntry>): string {
const digest = createHash('sha256');
for (const entry of chunk) {
digest.update(`${entry.userId}:${entry.direct ? 1 : 0}:${entry.role ? 1 : 0}:${entry.everyone ? 1 : 0}\n`);
}
return `mention-chunk:${messageId}:${digest.digest('hex').slice(0, MENTION_CHUNK_KEY_DIGEST_LENGTH)}`;
}
async function enqueueMentionChunks({
chunks,
channelId,
@@ -122,7 +132,6 @@ async function enqueueMentionChunks({
await Promise.all(
chunkSlice.map((chunk, localIndex) => {
const index = firstChunkIndex + offset + localIndex;
const jobKeySuffix = chunkCount === undefined ? `${index}` : `${index}:${chunkCount}`;
return addJob(
'handleMentionChunk',
{
@@ -134,7 +143,7 @@ async function enqueueMentionChunks({
mentions: chunk,
},
{
jobKey: `mention-chunk:${messageId}:${jobKeySuffix}`,
jobKey: mentionChunkJobKey(messageId, chunk),
skipLedger: true,
},
);
@@ -675,7 +675,9 @@ async function createAndUploadArchive(params: ArchiveParams): Promise<ArchiveRes
output.on('close', resolve);
output.on('error', reject);
});
const storageKey = `exports/${userId}/${harvestId}/user-data.zip`;
const storageKey = isAdminArchive
? `archives/users/${userId}/${harvestId}/user-data.zip`
: `exports/${userId}/${harvestId}/user-data.zip`;
const expiresAt = new Date(Date.now() + (isAdminArchive ? ms('1 year') : ZIP_EXPIRY_MS));
const zipStat = await fs.promises.stat(zipPath);
const fileSize = BigInt(zipStat.size);
@@ -230,6 +230,7 @@ export const ChannelItem = observer(
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
const unreadCount = ReadStates.getUnreadCount(channel.id);
const hasUnread = ReadStates.hasUnread(channel.id);
const connectedVoiceGuildId = channelIsVoice ? MediaEngine.guildId : null;
const connectedVoiceChannelId = channelIsVoice ? MediaEngine.channelId : null;
const canManageChannels = Permission.can(Permissions.MANAGE_CHANNELS, channel);
@@ -265,6 +266,7 @@ export const ChannelItem = observer(
type: channel.type,
});
const unreadState = getChannelUnreadState({
hasUnread,
unreadCount,
mentionCount,
isMuted: isChannelDirectlyMuted,
@@ -335,6 +335,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
);
const hasVisibleUnreadInChannel = (channelId: string): boolean => {
const unreadCount = ReadStates.getUnreadCount(channelId);
const hasUnread = ReadStates.hasUnread(channelId);
const mentionCount = ReadStates.getMentionCount(channelId);
const isMuted =
UserGuildSettings.isParentCategoryMuted(guild.id, channelId) ||
@@ -349,6 +350,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
})
: null;
const unreadState = getChannelUnreadState({
hasUnread,
unreadCount,
mentionCount,
isMuted,
@@ -197,6 +197,7 @@ const FavoriteChannelResolvedItem = observer(
);
const refs = useMergeRefs([dragConnectorRef, dropConnectorRef, elementRef]);
const unreadCount = ReadStates.getUnreadCount(channel.id);
const hasUnread = ReadStates.hasUnread(channel.id);
const mentionCount = ReadStates.getMentionCount(channel.id);
const isGroupDM = channel.isGroupDM();
const isDM = channel.isDM();
@@ -220,6 +221,7 @@ const FavoriteChannelResolvedItem = observer(
})
: null;
const unreadState = getChannelUnreadState({
hasUnread,
unreadCount,
mentionCount,
isMuted,
@@ -663,6 +663,7 @@ function resolveGuildTargetBounds({
function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverity | null {
const mentionCount = ReadStates.getPrivateChannelMentionCount(channelId);
const unreadState = getChannelUnreadState({
hasUnread: ReadStates.hasUnreadPrivateChannel(channelId),
unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId),
mentionCount,
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
@@ -70,6 +70,7 @@ const ACTIVE_CALL_DESCRIPTOR = msg({
export function resolveDMListItemUnreadState(channelId: string): ChannelUnreadState {
return getChannelUnreadState({
hasUnread: ReadStates.hasUnread(channelId),
unreadCount: ReadStates.getUnreadCount(channelId),
mentionCount: ReadStates.getMentionCount(channelId),
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
@@ -7,6 +7,7 @@ import {getChannelUnreadState} from './ChannelUnreadState';
describe('getChannelUnreadState', () => {
it('shows a normal unread indicator for all-messages unread badges', () => {
const state = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 0,
isMuted: false,
@@ -19,6 +20,7 @@ describe('getChannelUnreadState', () => {
});
it('shows a muted unread indicator for only-mentions unread badges without highlighting the channel', () => {
const state = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 0,
isMuted: false,
@@ -31,6 +33,7 @@ describe('getChannelUnreadState', () => {
});
it('hides unread and mention surfaces when unread badges are disabled', () => {
const state = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 1,
isMuted: false,
@@ -43,6 +46,7 @@ describe('getChannelUnreadState', () => {
});
it('keeps legacy muted-channel fading for channels without an unread-badges level', () => {
const hiddenState = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 0,
isMuted: true,
@@ -50,6 +54,7 @@ describe('getChannelUnreadState', () => {
unreadBadgesLevel: null,
});
const fadedState = getChannelUnreadState({
hasUnread: true,
unreadCount: 3,
mentionCount: 0,
isMuted: true,
@@ -60,4 +65,27 @@ describe('getChannelUnreadState', () => {
expect(fadedState.shouldShowUnreadIndicator).toBe(true);
expect(fadedState.isUnreadIndicatorMuted).toBe(true);
});
it('shows the indicator from the unread flag rather than the message count', () => {
const state = getChannelUnreadState({
hasUnread: true,
unreadCount: 0,
mentionCount: 0,
isMuted: false,
showFadedUnreadOnMutedChannels: false,
unreadBadgesLevel: null,
});
expect(state.shouldShowUnreadIndicator).toBe(true);
});
it('hides the indicator for a read channel even if a stale count survives', () => {
const state = getChannelUnreadState({
hasUnread: false,
unreadCount: 7,
mentionCount: 0,
isMuted: false,
showFadedUnreadOnMutedChannels: false,
unreadBadgesLevel: null,
});
expect(state.shouldShowUnreadIndicator).toBe(false);
expect(state.hasVisibleUnread).toBe(false);
});
});
@@ -3,6 +3,7 @@
import {resolveChannelUnreadState} from './ChannelUnreadStateMachine';
export interface ChannelUnreadStateInput {
hasUnread: boolean;
unreadCount: number;
mentionCount: number;
isMuted: boolean;
@@ -20,6 +21,7 @@ export interface ChannelUnreadState {
}
export function getChannelUnreadState({
hasUnread,
unreadCount,
mentionCount,
isMuted,
@@ -27,6 +29,7 @@ export function getChannelUnreadState({
unreadBadgesLevel,
}: ChannelUnreadStateInput): ChannelUnreadState {
return resolveChannelUnreadState({
hasUnread,
unreadCount,
mentionCount,
isMuted,
@@ -12,6 +12,7 @@ import {
function input(overrides: Partial<ChannelUnreadStateInput> = {}): ChannelUnreadStateInput {
return {
hasUnread: false,
unreadCount: 0,
mentionCount: 0,
isMuted: false,
@@ -38,6 +39,7 @@ describe('channelUnreadStateMachine', () => {
const snapshot = createChannelUnreadSnapshot(
input({
unreadBadgesLevel: MessageNotifications.NO_MESSAGES,
hasUnread: true,
unreadCount: 1,
mentionCount: 1,
}),
@@ -55,6 +57,7 @@ describe('channelUnreadStateMachine', () => {
it('transitions without preserving stale policy output', () => {
const legacySnapshot = createChannelUnreadSnapshot(
input({
hasUnread: true,
unreadCount: 2,
isMuted: true,
showFadedUnreadOnMutedChannels: false,
@@ -66,6 +69,7 @@ describe('channelUnreadStateMachine', () => {
type: 'channelUnread.updated',
input: input({
unreadBadgesLevel: MessageNotifications.ALL_MESSAGES,
hasUnread: true,
unreadCount: 2,
isMuted: true,
}),
@@ -86,7 +86,7 @@ export function transitionChannelUnreadSnapshot(
export function selectChannelUnreadState(snapshot: ChannelUnreadSnapshot): ChannelUnreadState {
const context = snapshot.context;
const hasUnreadMessages = context.unreadCount > 0;
const hasUnreadMessages = context.hasUnread;
const rawHasMentions = context.mentionCount > 0;
switch (getUnreadStateValue(snapshot)) {
case 'disabled':
@@ -8,12 +8,11 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {Button} from '@app/features/ui/button/Button';
import {buildAppProtocolUrl} from '@app/features/ui/utils/AppProtocol';
import {isDesktop, openExternalUrl} from '@app/features/ui/utils/NativeUtils';
import {checkDesktopAvailable, navigateInDesktop} from '@app/features/voice/utils/DesktopRpcClient';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
import type React from 'react';
import {useEffect, useState} from 'react';
import {useState} from 'react';
interface DesktopDeepLinkPromptProps {
code: string;
@@ -36,25 +35,9 @@ const FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR = msg({
export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({code, kind, preferLogin = false}) => {
const {i18n} = useLingui();
const [isLoading, setIsLoading] = useState(false);
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
const [error, setError] = useState<string | null>(null);
const isMobileBrowser = Platform.isMobileBrowser;
const useProtocolLaunch = kind === 'invite';
const shouldProbeDesktopAvailability = !useProtocolLaunch;
useEffect(() => {
if (isDesktop() || !shouldProbeDesktopAvailability) return;
let cancelled = false;
checkDesktopAvailable().then(({available}) => {
if (!cancelled) {
setDesktopAvailable(available);
}
});
return () => {
cancelled = true;
};
}, [shouldProbeDesktopAvailability]);
if (isDesktop() || isMobileBrowser) return null;
if (shouldProbeDesktopAvailability && desktopAvailable !== true) return null;
const getPath = (): string => {
switch (kind) {
case 'invite':
@@ -69,20 +52,12 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
const handleOpen = async () => {
setIsLoading(true);
setError(null);
if (useProtocolLaunch) {
try {
await openExternalUrl(buildAppProtocolUrl(path));
} catch {
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
} finally {
setIsLoading(false);
}
return;
}
const result = await navigateInDesktop(path);
setIsLoading(false);
if (!result.success) {
try {
await openExternalUrl(buildAppProtocolUrl(path));
} catch {
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
} finally {
setIsLoading(false);
}
};
return (
@@ -23,6 +23,7 @@ interface BlockedMessageGroupsProps {
messageGroups: Array<ChannelStreamItem>;
onReveal: (messageId: string | null) => void;
revealed: boolean;
hasUnread?: boolean;
compact: boolean;
messageGroupSpacing: number;
variant: 'blocked' | 'spammer';
@@ -33,11 +34,13 @@ interface BlockedMessageGroupsProps {
messageActionsClassName?: string;
renderMessageActions?: (message: Message) => React.ReactNode;
renderMessageWrapper?: (props: MessageGroupRenderWrapperProps) => React.ReactNode;
suppressUnreadIndicator?: boolean;
}
const arePropsEqual = (prevProps: BlockedMessageGroupsProps, nextProps: BlockedMessageGroupsProps): boolean => {
if (prevProps.channel.id !== nextProps.channel.id) return false;
if (prevProps.revealed !== nextProps.revealed) return false;
if (prevProps.hasUnread !== nextProps.hasUnread) return false;
if (prevProps.compact !== nextProps.compact) return false;
if (prevProps.messageGroupSpacing !== nextProps.messageGroupSpacing) return false;
if (prevProps.variant !== nextProps.variant) return false;
@@ -49,6 +52,7 @@ const arePropsEqual = (prevProps: BlockedMessageGroupsProps, nextProps: BlockedM
if (prevProps.messageActionsClassName !== nextProps.messageActionsClassName) return false;
if (prevProps.renderMessageActions !== nextProps.renderMessageActions) return false;
if (prevProps.renderMessageWrapper !== nextProps.renderMessageWrapper) return false;
if (prevProps.suppressUnreadIndicator !== nextProps.suppressUnreadIndicator) return false;
if (prevProps.messageGroups.length !== nextProps.messageGroups.length) return false;
for (let i = 0; i < prevProps.messageGroups.length; i++) {
const prevGroup = prevProps.messageGroups[i];
@@ -69,6 +73,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
channel,
compact,
revealed,
hasUnread = false,
messageGroupSpacing,
onReveal,
variant,
@@ -79,6 +84,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
messageActionsClassName,
renderMessageActions,
renderMessageWrapper,
suppressUnreadIndicator,
} = props;
const containerRef = useRef<HTMLDivElement>(null);
const scrollToBottomFrameRef = useRef<number | null>(null);
@@ -169,6 +175,12 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
};
messageGroups.forEach((item, itemIndex) => {
if (item.type === ChannelStreamType.DIVIDER) {
if (item.unreadId && suppressUnreadIndicator) {
return;
}
if (itemIndex === 0 && item.unreadId) {
return;
}
flushGroup();
nodes.push(
<Divider
@@ -205,13 +217,23 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
messageActionsClassName,
renderMessageActions,
renderMessageWrapper,
suppressUnreadIndicator,
]);
const leadingUnreadDivider = messageGroups[0]?.type === ChannelStreamType.DIVIDER && !!messageGroups[0].unreadId;
return (
<div
ref={containerRef}
className={clsx(styles.container, className)}
data-flx="channel.blocked-message-groups.container"
>
{hasUnread && (!revealed || leadingUnreadDivider) && (
<Divider
spacing={messageGroupSpacing}
red={true}
id="new-messages-bar"
data-flx="channel.blocked-message-groups.collapsed-unread-divider"
/>
)}
<button
type="button"
className={styles.toggle}
@@ -189,11 +189,13 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array<Reac
key={item.key}
revealed={item.key === unblurredMessageId}
messageGroups={item.content as Array<ChannelStreamItem>}
hasUnread={item.hasUnread === true && !suppressUnreadIndicator}
onReveal={onReveal ?? (() => {})}
compact={messageDisplayCompact}
channel={channel}
messageGroupSpacing={messageGroupSpacing}
variant={variant}
suppressUnreadIndicator={suppressUnreadIndicator}
data-flx="channel.channel-message-stream.render-channel-stream.blocked-message-groups"
/>,
);
@@ -236,7 +236,7 @@ export const Messages = observer(function Messages({
messages: safeMessages,
channel,
compact: state.messageDisplayCompact,
hasPendingUnreads: state.unreadCount > 0,
hasPendingUnreads: state.visualUnreadMessageId != null,
focusAnchorId: null,
unloadedSpacerHeight: selectChannelMessagesSpacerHeight(windowStatus, placeholderSpecs.totalHeight),
allowHistoryFetch: true,
@@ -14,10 +14,11 @@ import {useVirtualRows} from '@app/features/channel/components/sticker_picker/ho
import {StickerPickerCategoryList} from '@app/features/channel/components/sticker_picker/StickerPickerCategoryList';
import {
buildStickerRowOffsets,
getStickerRowHeight,
getFixedStickerRowHeight,
getStickerGridColumns,
getStickerRowWindow,
STICKER_GRID_TRACK_WIDTH,
STICKER_SECTION_GAP,
STICKERS_PER_ROW,
type StickerRowKind,
} from '@app/features/channel/components/sticker_picker/StickerPickerConstants';
import {StickerPickerInspector} from '@app/features/channel/components/sticker_picker/StickerPickerInspector';
@@ -74,12 +75,12 @@ export const StickersPicker = observer(
const [selectedColumn, setSelectedColumn] = useState(-1);
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
const scrollerRef = useRef<ScrollerHandle>(null);
const {scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
const searchInputRef = useRef<HTMLInputElement>(null);
const stickerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
const rowListRef = useRef<HTMLDivElement>(null);
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0, gridWidth: 0});
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0});
const [stickerDataVersion, setStickerDataVersion] = useState(0);
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
const {shouldAnimate: shouldAnimateStickerPreview} = useStickerAnimation();
@@ -138,13 +139,26 @@ export const StickersPicker = observer(
allUpsell.accessibleItems,
renderedStickers,
);
const zoomLevel = Accessibility.zoomLevel;
const gridColumns = useMemo(() => getStickerGridColumns(viewportSize.width), [viewportSize.width, zoomLevel]);
const previousGridColumnsRef = useRef(gridColumns);
useLayoutEffect(() => {
if (previousGridColumnsRef.current === gridColumns) {
return;
}
previousGridColumnsRef.current = gridColumns;
setHoveredSticker(null);
setSelectedRow(-1);
setSelectedColumn(-1);
setShouldScrollOnSelection(false);
}, [gridColumns]);
const pickerRows = useVirtualRows(
searchTerm,
renderedStickers,
favoriteStickers,
frequentlyUsedStickers,
stickersByGuildId,
STICKERS_PER_ROW,
gridColumns,
);
const hasNoStickersAtAll = allItems.length === 0;
const isSearching = searchTerm.trim().length > 0;
@@ -198,12 +212,8 @@ export const StickersPicker = observer(
}
return {stickerRowIndexes: indexes, stickerRowStarts: starts, categoryRowIndexes: categories};
}, [pickerRows]);
const zoomLevel = Accessibility.zoomLevel;
const remScale = getAppRemScale();
const stickerRowHeight = useMemo(
() => getStickerRowHeight(listMetrics.gridWidth, STICKERS_PER_ROW, remScale),
[listMetrics.gridWidth, remScale, zoomLevel],
);
const stickerRowHeight = useMemo(() => getFixedStickerRowHeight(remScale), [remScale, zoomLevel]);
const rowOffsets = useMemo(() => {
const rowKinds = pickerRows.map((row): StickerRowKind => row.type);
return buildStickerRowOffsets(rowKinds, {remScale, stickerRowHeight, sectionGap: STICKER_SECTION_GAP});
@@ -219,11 +229,8 @@ export const StickersPicker = observer(
rowListNode.getBoundingClientRect().top - scrollerNode.getBoundingClientRect().top + scrollerNode.scrollTop,
);
const viewportHeight = scrollerNode.clientHeight;
const gridWidth = rowListNode.clientWidth;
setListMetrics((current) =>
current.origin === origin && current.viewportHeight === viewportHeight && current.gridWidth === gridWidth
? current
: {origin, viewportHeight, gridWidth},
current.origin === origin && current.viewportHeight === viewportHeight ? current : {origin, viewportHeight},
);
});
const rowWindow = useMemo(
@@ -394,7 +401,8 @@ export const StickersPicker = observer(
row={row}
handleHover={handleHover}
handleSelect={handleStickerSelect}
gridColumns={STICKERS_PER_ROW}
gridColumns={gridColumns}
cellTrackWidth={STICKER_GRID_TRACK_WIDTH}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
stickerRowIndex={stickerRowIndexes[rowIndex]!}
@@ -200,18 +200,15 @@ export const UserMessage = observer(() => {
finishEditing();
return;
}
MessageCommands.edit(
finishEditing();
void MessageCommands.edit(
channel.id,
message.id,
'',
undefined,
message._allowedMentions,
buildExistingAttachmentEditReferences(message),
).then((result) => {
if (result) {
finishEditing();
}
});
);
return;
}
handleDelete();
@@ -220,11 +217,8 @@ export const UserMessage = observer(() => {
if (checkCustomEmojiAvailability(content)) {
return;
}
MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions).then((result) => {
if (result) {
finishEditing();
}
});
finishEditing();
void MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions);
},
[
channel.id,
@@ -14,9 +14,9 @@ import {msg} from '@lingui/core/macro';
import type {IconProps} from '@phosphor-icons/react';
import {
ChatCenteredDotsIcon,
ChatCircleIcon,
ClockClockwiseIcon,
ClockCounterClockwiseIcon,
EnvelopeSimpleIcon,
GlobeIcon,
HashIcon,
SparkleIcon,
@@ -162,7 +162,7 @@ export const getSortModeOptions = (i18n: I18n): Array<{mode: ChannelSearchSortMo
];
const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
current: HashIcon,
all_dms: EnvelopeSimpleIcon,
all_dms: ChatCircleIcon,
open_dms: ChatCenteredDotsIcon,
all_guilds: GlobeIcon,
all: UsersIcon,
@@ -125,7 +125,7 @@ export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const [username, discriminator] = parseInput(input);
const [username, discriminator] = parseInput(input['trim']());
if (!username || !discriminator || !/^\d{4}$/.test(discriminator)) {
setResultStatus('error');
setErrorCode(APIErrorCodes.NO_USERS_WITH_FLUXERTAG_EXIST);
@@ -135,6 +135,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
const isMuted = UserGuildSettings.isChannelDirectlyMuted(null, channel.id);
const mentionCount = ReadStates.getMentionCount(channel.id);
const unreadState = getChannelUnreadState({
hasUnread: ReadStates.hasUnread(channel.id),
unreadCount: ReadStates.getUnreadCount(channel.id),
mentionCount,
isMuted,
@@ -208,9 +208,10 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
messageId: message?.id,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
});
},
[attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur],
[attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur, isPreview, snapshotIndex],
);
const openInBrowser = useOpenInBrowserOnMiddleClick(attachment.url ?? attachment.proxy_url ?? '', !shouldBlur);
const handleFavoriteClick = useCallback(
@@ -181,6 +181,7 @@ const useImagePreview = ({
message,
sourceChannel,
providerName,
allowAttachmentDelete = false,
}: {
proxyUrl: string;
embedUrl: string;
@@ -195,6 +196,7 @@ const useImagePreview = ({
message?: Message;
sourceChannel?: Channel | null;
providerName?: string;
allowAttachmentDelete?: boolean;
}): {viewerItem: MediaViewerItem; openPreview: (event: React.MouseEvent | React.KeyboardEvent) => void} => {
const viewerItem = useMemo<MediaViewerItem>(
() => ({
@@ -223,9 +225,10 @@ const useImagePreview = ({
messageId,
message,
sourceChannel,
allowAttachmentDelete,
});
},
[viewerItem, channelId, messageId, message, sourceChannel],
[viewerItem, channelId, messageId, message, sourceChannel, allowAttachmentDelete],
);
return {viewerItem, openPreview};
};
@@ -461,6 +464,7 @@ export const EmbedGifv: FC<
message,
sourceChannel: messageViewContext?.channel,
providerName,
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
});
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
const handleDownloadClick = useCallback(
@@ -795,6 +799,7 @@ export const EmbedGif: FC<
contentHash,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
});
const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerItem, {
allowAnimated: gifAutoPlay,
@@ -104,6 +104,7 @@ interface ImagePreviewHandlerProps {
message?: Message;
animated?: boolean;
mediaAttachments?: ReadonlyArray<MessageAttachment>;
allowAttachmentDelete?: boolean;
onViewerWarmEnter?: () => void;
onViewerWarmLeave?: () => void;
children: React.ReactNode;
@@ -143,6 +144,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
message,
animated,
mediaAttachments = NO_MEDIA_ATTACHMENTS,
allowAttachmentDelete = false,
onViewerWarmEnter,
onViewerWarmLeave,
children,
@@ -176,6 +178,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
messageId,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
});
} else {
MediaViewerCommands.openMediaViewer(
@@ -187,6 +190,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
naturalHeight,
type: 'image' as const,
contentHash,
attachmentId,
embedIndex,
expiresAt: undefined,
expired: undefined,
@@ -199,6 +203,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
messageId,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
},
);
}
@@ -217,6 +222,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
message,
messageViewContext?.channel,
mediaAttachments,
allowAttachmentDelete,
],
);
const openInBrowser = useOpenInBrowserOnMiddleClick(originalSrc || src);
@@ -381,6 +387,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
[originalSrc, src],
);
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
const [mediaSheetOpen, setMediaSheetOpen] = useState(false);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
@@ -524,6 +531,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
attachmentId={attachmentId}
message={message}
mediaAttachments={mediaAttachments}
allowAttachmentDelete={allowAttachmentDelete}
animated={animated}
onViewerWarmEnter={scheduleViewerWarm}
onViewerWarmLeave={cancelViewerWarm}
@@ -272,6 +272,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
[src],
);
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!message) return;
@@ -396,6 +397,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
});
} else {
MediaViewerCommands.openMediaViewer(
@@ -407,12 +409,13 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
naturalHeight: viewerVideoDimensions.height,
type: 'video' as const,
contentHash,
attachmentId,
embedIndex,
duration,
},
],
0,
{channelId, messageId, message, sourceChannel: messageViewContext?.channel},
{channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
);
}
}, [
@@ -420,6 +423,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId,
message,
messageViewContext?.channel,
allowAttachmentDelete,
mediaAttachments,
attachmentId,
effectiveSrc,
@@ -451,6 +455,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId,
message,
sourceChannel: messageViewContext?.channel,
allowAttachmentDelete,
});
} else {
MediaViewerCommands.openMediaViewer(
@@ -462,13 +467,14 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
naturalHeight: viewerVideoDimensions.height,
type: 'video' as const,
contentHash,
attachmentId,
embedIndex,
duration,
initialTime: currentTime,
},
],
0,
{channelId, messageId, message, sourceChannel: messageViewContext?.channel},
{channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
);
}
setIsPlayingInline(false);
@@ -477,6 +483,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
messageId,
message,
messageViewContext?.channel,
allowAttachmentDelete,
mediaAttachments,
attachmentId,
effectiveSrc,
@@ -2,9 +2,9 @@
import {AutocompleteOption} from '@app/features/channel/components/message_search_bar/AutocompleteOption';
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import {resolveChannelSuggestionDisplayName} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
import type {Channel} from '@app/features/channel/models/Channel';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
@@ -72,7 +72,7 @@ export const ChannelsSection: React.FC<ChannelsSectionProps> = observer(
className={styles.channelName}
data-flx="channel.message-search-bar.channels-section.channel-name"
>
{resolveSearchChannelDisplayName(channelOption) || 'Unnamed Channel'}
{resolveChannelSuggestionDisplayName(channelOption) || 'Unnamed Channel'}
</span>
</div>
</div>
@@ -5,25 +5,34 @@ import type {
HistoryFilterRow,
} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import type {Channel} from '@app/features/channel/models/Channel';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import type {Guild} from '@app/features/guild/models/Guild';
import Guilds from '@app/features/guild/state/Guilds';
import type {GuildMember} from '@app/features/member/models/GuildMember';
import GuildMembers from '@app/features/member/state/GuildMembers';
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {IconProps} from '@phosphor-icons/react';
import {ChatCenteredDotsIcon, EnvelopeSimpleIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
import {ChatCenteredDotsIcon, ChatCircleIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
export const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
current: HashIcon,
all_dms: EnvelopeSimpleIcon,
all_dms: ChatCircleIcon,
open_dms: ChatCenteredDotsIcon,
all_guilds: GlobeIcon,
all: UsersIcon,
open_dms_and_all_guilds: UsersIcon,
};
export function resolveChannelSuggestionDisplayName(channel: Channel): string {
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return ChannelUtils.getDMDisplayName(channel);
}
return resolveSearchChannelDisplayName(channel);
}
export function filterRequiresValue(filter: SearchFilterOption): boolean {
return Boolean(filter.requiresValue) || (filter.values?.length ?? 0) > 0;
}
@@ -274,6 +283,41 @@ export function isUserFilterKey(filterKey: string): boolean {
}
}
export type DmChannelSuggestionMode = 'none' | 'current' | 'open' | 'all';
export type GuildChannelSuggestionMode = 'none' | 'current_guild' | 'all_guilds';
export interface ChannelSuggestionSearchPlan {
dmMode: DmChannelSuggestionMode;
guildMode: GuildChannelSuggestionMode;
currentGuildId?: string;
}
export function getChannelSuggestionSearchPlan(
scope: MessageSearchScope,
currentGuildId: string | undefined,
): ChannelSuggestionSearchPlan {
switch (scope) {
case 'current':
return currentGuildId
? {dmMode: 'none', guildMode: 'current_guild', currentGuildId}
: {dmMode: 'current', guildMode: 'none'};
case 'all_dms':
return {dmMode: 'all', guildMode: 'none'};
case 'open_dms':
return {dmMode: 'open', guildMode: 'none'};
case 'all':
return {dmMode: 'all', guildMode: 'all_guilds'};
case 'open_dms_and_all_guilds':
return {dmMode: 'open', guildMode: 'all_guilds'};
case 'all_guilds':
return {dmMode: 'none', guildMode: 'all_guilds'};
default: {
const exhaustiveScope: never = scope;
throw new Error(`Unsupported message search scope: ${exhaustiveScope}`);
}
}
}
export type GuildSearchMode = 'none' | 'current_guild' | 'all_guilds';
export interface UserGuildSearchPlan {
@@ -4,8 +4,8 @@ import {AutocompleteOption} from '@app/features/channel/components/message_searc
import {FilterOption} from '@app/features/channel/components/message_search_bar/FilterOption';
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
import type {PlaintextAutocompleteRow} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
import {resolveChannelSuggestionDisplayName} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
@@ -143,7 +143,7 @@ export const PlaintextSection: React.FC<PlaintextSectionProps> = observer(
className={styles.channelName}
data-flx="channel.message-search-bar.plaintext-section.channel-name"
>
{resolveSearchChannelDisplayName(row.channel)}
{resolveChannelSuggestionDisplayName(row.channel)}
</span>
</span>
);
@@ -12,6 +12,7 @@ import {
buildHistoryFilterRows,
buildUserSearchBoosters,
filterRequiresValue,
getChannelSuggestionSearchPlan,
getUserGuildSearchPlan,
isDateFilterKey,
isSearchFilterOptionEligible,
@@ -22,6 +23,7 @@ import {
PLAINTEXT_SUGGESTION_FILTER_KEYS,
PLAINTEXT_SUGGESTIONS_PER_FILTER,
replaceSearchTokenAtCursor,
resolveChannelSuggestionDisplayName,
resolveMessageSearchCurrentWord,
type SearchTokenReplacementResult,
} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
@@ -32,6 +34,7 @@ import type {LexicalSearchInputHandle} from '@app/features/lexical/search/Lexica
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import Permission from '@app/features/permissions/state/Permission';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import SearchHistory, {
SEARCH_HISTORY_DISPLAY_LIMIT,
@@ -41,7 +44,7 @@ import {
buildSearchSegmentsFromHints,
formatSearchHistoryEntryForStreamerMode,
} from '@app/features/search/utils/SearchPrivacyUtils';
import {matchSearchDateWords, resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
import {matchSearchDateWords} from '@app/features/search/utils/SearchQueryParser';
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
import {getSearchFilterOptions} from '@app/features/search/utils/SearchUtils';
@@ -52,7 +55,7 @@ import Users from '@app/features/user/state/Users';
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {ME} from '@fluxer/constants/src/AppConstants';
import {GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {DateTime} from 'luxon';
@@ -63,6 +66,18 @@ const MAX_FILTER_VALUE_SUGGESTIONS = 12;
const MAX_MEMBER_SEARCH_RESULTS = 25;
const MAX_DATE_WORD_SUGGESTIONS = 10;
function isDmSearchChannel(channel: Channel): boolean {
return channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
}
function isGuildSearchChannel(channel: Channel): boolean {
return (
channel.guildId != null &&
GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) &&
Permission.can(Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY, channel)
);
}
function isHistoryFilterRow(option: AutocompleteOption): option is HistoryFilterRow {
if (typeof option !== 'object' || option === null || !('kind' in option)) {
return false;
@@ -396,27 +411,79 @@ export function useMessageSearchAutocomplete({
);
const resolveChannelSuggestions = useCallback(
(searchTerm: string, limit: number): Array<Channel> => {
const guildIdForChannels = channelGuildId ?? (routeGuildId === ME ? undefined : routeGuildId);
if (!guildIdForChannels) {
const privateChannels = Channels.getPrivateChannels().filter((c) => resolveSearchChannelDisplayName(c) !== '');
return matchSorter(privateChannels, searchTerm, {
keys: [(c: Channel) => resolveSearchChannelDisplayName(c)],
}).slice(0, limit);
const currentGuildId = channelGuildId ?? (routeGuildId === ME ? undefined : routeGuildId);
const plan = getChannelSuggestionSearchPlan(activeScope, currentGuildId);
const candidatesById = new Map<string, Channel>();
const addCandidate = (candidate: Channel | undefined) => {
if (candidate) {
candidatesById.set(candidate.id, candidate);
}
};
if (!hidePersonalInformation) {
switch (plan.dmMode) {
case 'none':
break;
case 'current':
if (channel && channel.guildId == null) {
addCandidate(channel);
}
break;
case 'open':
case 'all':
for (const privateChannel of Channels.getPrivateChannels()) {
addCandidate(privateChannel);
}
if (channel && isDmSearchChannel(channel)) {
addCandidate(channel);
}
break;
default: {
const exhaustiveDmMode: never = plan.dmMode;
throw new Error(`Unsupported DM channel suggestion mode: ${exhaustiveDmMode}`);
}
}
}
const channels = Channels.getGuildChannels(guildIdForChannels).filter((c) =>
GUILD_TEXT_BASED_CHANNEL_TYPES.has(c.type),
switch (plan.guildMode) {
case 'none':
break;
case 'current_guild':
if (!plan.currentGuildId) {
throw new Error('Current guild channel suggestion mode requires a guild ID');
}
for (const guildChannel of Channels.getGuildChannels(plan.currentGuildId)) {
if (isGuildSearchChannel(guildChannel)) {
addCandidate(guildChannel);
}
}
break;
case 'all_guilds':
for (const guildChannels of Channels.channelGroups.byGuild.values()) {
for (const guildChannel of guildChannels) {
if (isGuildSearchChannel(guildChannel)) {
addCandidate(guildChannel);
}
}
}
break;
default: {
const exhaustiveGuildMode: never = plan.guildMode;
throw new Error(`Unsupported guild channel suggestion mode: ${exhaustiveGuildMode}`);
}
}
const candidates = Array.from(candidatesById.values()).filter(
(candidate) => resolveChannelSuggestionDisplayName(candidate) !== '',
);
const recentVisitsForGuild = SelectedChannel.recentlyVisitedChannels
.filter((visit) => visit.guildId === guildIdForChannels)
.sort((a, b) => b.timestamp - a.timestamp);
const recencyRank = new Map<string, number>();
recentVisitsForGuild.forEach((visit, index) => {
SelectedChannel.sortedRecentVisits.forEach((visit, index) => {
if (!recencyRank.has(visit.channelId)) {
recencyRank.set(visit.channelId, index);
}
});
const currentChannelId = channel?.id;
const matches = matchSorter(channels, searchTerm, {keys: ['name']});
const matches = matchSorter(candidates, searchTerm, {
keys: [(candidate: Channel) => resolveChannelSuggestionDisplayName(candidate)],
});
const matchRank = new Map(matches.map((candidate, index) => [candidate.id, index]));
const orderedMatches = [...matches].sort((a, b) => {
const resolveRank = (ch: Channel) => {
if (ch.id === currentChannelId) return -1;
@@ -426,11 +493,11 @@ export function useMessageSearchAutocomplete({
if (rankDifference !== 0) {
return rankDifference;
}
return (a.name ?? '').localeCompare(b.name ?? '');
return (matchRank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (matchRank.get(b.id) ?? Number.MAX_SAFE_INTEGER);
});
return orderedMatches.slice(0, limit);
},
[channelGuildId, routeGuildId, channel],
[activeScope, channelGuildId, routeGuildId, channel, hidePersonalInformation],
);
const getPlaintextRows = useCallback((): Array<PlaintextAutocompleteRow> => {
const cursorPosition = resolveInputCursorPosition(inputRef, value);
@@ -462,7 +529,7 @@ export function useMessageSearchAutocomplete({
}
return rows;
}, [value, filterOptions, filterEligibility, resolveChannelSuggestions, resolveUserSuggestions]);
const getAutocompleteOptions = useCallback((): Array<AutocompleteOption> => {
const resolveAutocompleteOptions = useCallback((): Array<AutocompleteOption> => {
const cursorPos = resolveInputCursorPosition(inputRef, value);
const currentWord = resolveMessageSearchCurrentWord({value, cursorPosition: cursorPos});
switch (autocompleteType) {
@@ -548,13 +615,17 @@ export function useMessageSearchAutocomplete({
() => buildHistoryFilterRows(filterOptions, filterEligibility),
[filterOptions, filterEligibility],
);
const autocompleteOptions = resolveAutocompleteOptions();
const totalOptions = autocompleteType
? autocompleteOptions.length + (autocompleteType === 'history' ? historyFilterRows.length : 0)
: 0;
const getAutocompleteOptions = useCallback(
(): Array<AutocompleteOption> => autocompleteOptions,
[autocompleteOptions],
);
const getTotalOptions = useCallback((): number => {
if (!autocompleteType) return 0;
if (autocompleteType === 'history') {
return historyFilterRows.length + getAutocompleteOptions().length;
}
return getAutocompleteOptions().length;
}, [autocompleteType, getAutocompleteOptions, historyFilterRows]);
return totalOptions;
}, [totalOptions]);
const hasAnyOptions = useCallback((): boolean => {
return getTotalOptions() > 0;
}, [getTotalOptions]);
@@ -564,13 +635,11 @@ export function useMessageSearchAutocomplete({
if (selectedIndex < historyFilterRows.length) {
return historyFilterRows[selectedIndex] ?? null;
}
const historyOptions = getAutocompleteOptions();
const historyIndex = selectedIndex - historyFilterRows.length;
return historyOptions[historyIndex] ?? null;
return autocompleteOptions[historyIndex] ?? null;
}
const options = getAutocompleteOptions();
return options[selectedIndex] ?? null;
}, [selectedIndex, autocompleteType, getAutocompleteOptions, historyFilterRows]);
return autocompleteOptions[selectedIndex] ?? null;
}, [selectedIndex, autocompleteType, autocompleteOptions, historyFilterRows]);
useEffect(() => {
if (!isFocused || suppressAutoOpen) {
setAutocompleteType(null);
@@ -643,11 +712,10 @@ export function useMessageSearchAutocomplete({
setCurrentFilter(null);
}, [value, isFocused, isInGuildChannel, suppressAutoOpen, filterOptions, filterEligibility]);
useEffect(() => {
const totalOptions = getTotalOptions();
if (totalOptions > 0 && (selectedIndex >= totalOptions || selectedIndex < -1)) {
setSelectedIndex(-1);
}
}, [autocompleteType, selectedIndex, getTotalOptions]);
}, [selectedIndex, totalOptions]);
const handleOptionMouseEnter = (index: number) => {
setHoverIndex(index);
setHasInteracted(true);
@@ -729,7 +797,7 @@ export function useMessageSearchAutocomplete({
case 'channels': {
const ch = option as Channel;
const filter = requireCurrentFilter();
const displayName = resolveSearchChannelDisplayName(ch);
const displayName = resolveChannelSuggestionDisplayName(ch);
const name = displayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : displayName;
replacement = replaceSearchTokenAtCursor({
value,
@@ -815,7 +883,7 @@ export function useMessageSearchAutocomplete({
shouldSubmit = true;
break;
}
const rowDisplayName = resolveSearchChannelDisplayName(row.channel);
const rowDisplayName = resolveChannelSuggestionDisplayName(row.channel);
const channelName = rowDisplayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : rowDisplayName;
replacement = replaceSearchTokenAtCursor({
value,
@@ -1,9 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
export const STICKERS_PER_ROW = 4;
export const STICKERS_PER_ROW_MOBILE = 5;
export const STICKER_CATEGORY_HEADER_HEIGHT = 32;
export const STICKER_SECTION_GAP = 12;
export const STICKER_GRID_TRACK_WIDTH = 120;
const STICKER_CATEGORY_HEADER_GAP = 8;
const STICKER_GRID_GAP = 8;
@@ -23,6 +26,20 @@ export interface StickerRowWindow {
lastRow: number;
}
export function getStickerGridColumns(containerWidth: number): number {
if (containerWidth <= 0) {
return STICKERS_PER_ROW;
}
const remScale = getRemScaleForDocument(typeof document === 'undefined' ? null : document);
const trackWidth = STICKER_GRID_TRACK_WIDTH * remScale;
const gap = STICKER_GRID_GAP * remScale;
return Math.max(1, Math.floor((containerWidth + gap) / (trackWidth + gap)));
}
export function getFixedStickerRowHeight(remScale: number): number {
return (STICKER_GRID_TRACK_WIDTH + STICKER_GRID_BLOCK_END_PADDING) * remScale;
}
export function getStickerRowHeight(gridWidth: number, gridColumns: number, remScale: number): number {
if (!(gridWidth > 0) || gridColumns <= 0) {
return 0;
@@ -20,6 +20,11 @@
padding: 0 0 0.5rem;
}
.stickerGridFixedTrack {
grid-auto-flow: column;
justify-content: start;
}
.stickerButton {
position: relative;
display: flex;
@@ -27,6 +27,7 @@ interface VirtualRowRendererProps {
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
gridColumns?: number;
cellTrackWidth?: number;
selectedRow: number;
selectedColumn: number;
stickerRowIndex: number;
@@ -126,6 +127,7 @@ const VirtualRowRendererBase: React.FC<VirtualRowRendererProps> = React.memo(
handleHover,
handleSelect,
gridColumns = 4,
cellTrackWidth,
selectedRow,
selectedColumn,
stickerRowIndex,
@@ -220,11 +222,14 @@ const VirtualRowRendererBase: React.FC<VirtualRowRendererProps> = React.memo(
);
}
if (row.type === 'sticker-row') {
const hasFixedTrack = cellTrackWidth != null && cellTrackWidth > 0;
return (
<div
className={styles.stickerGrid}
className={hasFixedTrack ? `${styles.stickerGrid} ${styles.stickerGridFixedTrack}` : styles.stickerGrid}
style={{
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
gridTemplateColumns: hasFixedTrack
? `repeat(auto-fill, ${remFromPx(cellTrackWidth)})`
: `repeat(${gridColumns}, minmax(0, 1fr))`,
}}
data-flx="channel.sticker-picker.virtual-row.virtual-row-renderer-base.sticker-grid"
>
@@ -286,6 +291,7 @@ interface VirtualRowWrapperProps {
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
gridColumns?: number;
cellTrackWidth?: number;
selectedRow: number;
selectedColumn: number;
stickerRowIndex: number;
@@ -300,6 +306,7 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
handleHover,
handleSelect,
gridColumns,
cellTrackWidth,
selectedRow,
selectedColumn,
stickerRowIndex,
@@ -313,6 +320,7 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
handleHover={handleHover}
handleSelect={handleSelect}
gridColumns={gridColumns}
cellTrackWidth={cellTrackWidth}
selectedRow={selectedRow}
selectedColumn={selectedColumn}
stickerRowIndex={stickerRowIndex}
@@ -19,6 +19,8 @@ import {
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
import {MessageDeleteFailedModal} from '@app/features/messaging/components/alerts/MessageDeleteFailedModal';
import {MessageDeleteTooQuickModal} from '@app/features/messaging/components/alerts/MessageDeleteTooQuickModal';
import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/MessageEditFailedModal';
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
import type {JumpOptions} from '@app/features/messaging/state/ChannelMessages';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
@@ -34,8 +36,10 @@ import {
import {
type ApiAttachmentMetadata,
type ApiMessageEditAttachmentMetadata,
buildMessageEditRequest,
normalizeMessageContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import * as IARCommands from '@app/features/moderation/commands/IARCommands';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import Permission from '@app/features/permissions/state/Permission';
@@ -80,6 +84,8 @@ const ALSO_REPORT_TO_SAFETY_TEAM_DESCRIPTOR = msg({
'Toggle-switch label in the moderator delete-message confirmation dialog. When enabled, the message is reported (category: other) before being deleted. {productName} is the product name (e.g., Fluxer).',
});
const logger = new Logger('MessageCommands');
const MESSAGE_EDIT_MAX_RETRIES = 5;
const MESSAGE_EDIT_TIMEOUT_MS = 30_000;
const pendingDeletePromises = new Map<string, Promise<void>>();
const pendingFetchPromises = new Map<string, Promise<Array<WireMessage>>>();
@@ -601,7 +607,42 @@ function showDeleteFailureModal(error: unknown, messageId: string): void {
);
}
export function edit(
function showEditFailureModal(error: unknown): void {
if (error instanceof HttpError) {
const errorCode = failureCode(error);
if (error.status === 429) {
const retryAfterMs = resolveRetryAfterMs(error);
ModalCommands.push(
modal(() => (
<MessageEditTooQuickModal
retryAfter={retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000)}
data-flx="messaging.message-commands.message-edit-too-quick-modal"
/>
)),
);
return;
}
if (error.status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
ModalCommands.push(
modal(() => (
<FeatureTemporarilyDisabledModal data-flx="messaging.message-commands.message-edit-feature-temporarily-disabled-modal" />
)),
);
return;
}
if (errorCode === APIErrorCodes.CONTENT_BLOCKED) {
void import('@app/features/auth/components/ContentBlockedHandler').then((module) =>
module.showContentBlockedModal(),
);
return;
}
}
ModalCommands.push(
modal(() => <MessageEditFailedModal data-flx="messaging.message-commands.message-edit-failed-modal" />),
);
}
export async function edit(
channelId: string,
messageId: string,
content?: string,
@@ -609,31 +650,22 @@ export function edit(
allowedMentions?: AllowedMentions,
attachments?: Array<ApiMessageEditAttachmentMetadata>,
): Promise<WireMessage | null> {
return new Promise<WireMessage | null>((resolve) => {
logger.debug(`Enqueueing edit for message ${messageId} in channel ${channelId}`);
MessageQueue.enqueue(
{
type: 'edit',
channelId,
messageId,
content,
allowedMentions,
flags,
attachments,
},
(result, error) => {
if (result?.body) {
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
resolve(result.body);
} else {
if (error) {
logger.debug(`Message edit failed: ${messageId} in channel ${channelId}`, error);
}
resolve(null);
}
},
);
});
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
try {
const response = await http.patch<WireMessage>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
body: buildMessageEditRequest({content, flags, allowedMentions, attachments}),
mode: 'auto-retry',
retries: MESSAGE_EDIT_MAX_RETRIES,
timeoutMs: MESSAGE_EDIT_TIMEOUT_MS,
suppressContentBlockedModal: true,
});
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
return response.body ?? null;
} catch (error) {
logger.error(`Message edit failed: ${messageId} in channel ${channelId}`, error);
showEditFailureModal(error);
return null;
}
}
export async function remove(channelId: string, messageId: string): Promise<void> {
@@ -95,6 +95,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
onOpenInBrowser,
onCopyLink,
onCopyMedia,
onDeleteAttachment,
onReply,
onForward,
children,
@@ -636,6 +637,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
onOpenInBrowser={onOpenInBrowser}
onCopyLink={onCopyLink}
onCopyMedia={onCopyMedia}
onDeleteAttachment={onDeleteAttachment}
onReset={handleResetMedia}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {useBottomSheetBackHandler} from '@app/features/app/hooks/useBottomSheetBackHandler';
import {deriveDefaultNameFromMessage} from '@app/features/channel/components/embeds/EmbedUtils';
import {useMessageActionMenuData} from '@app/features/channel/components/MessageActionMenu';
@@ -14,6 +15,7 @@ import type {ForwardModalSuccess} from '@app/features/messaging/components/modal
import {MediaModal} from '@app/features/messaging/components/modals/MediaModal';
import styles from '@app/features/messaging/components/modals/MediaViewerModal.module.css';
import {getMediaViewerPortalRoot} from '@app/features/messaging/components/modals/MediaViewerPortal';
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {formatAttachmentDate} from '@app/features/messaging/utils/AttachmentExpiryUtils';
@@ -273,7 +275,8 @@ const MobileMediaOptionsSheet: FC<MobileMediaOptionsSheetProps> = observer(funct
});
const MediaViewerModalComponent: FC = observer(() => {
const {i18n} = useLingui();
const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel} = MediaViewer;
const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel, allowAttachmentDelete} =
MediaViewer;
const {enabled: isMobile} = MobileLayout;
const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false);
const currentItem = items[currentIndex];
@@ -438,6 +441,22 @@ const MediaViewerModalComponent: FC = observer(() => {
() => (message ? untracked(() => getMessagePermissions(message, sourceChannel)) : null),
[message, sourceChannel],
);
const deletableAttachmentId =
Accessibility.showMediaDeleteButton &&
allowAttachmentDelete &&
permissions?.canDeleteAttachment &&
currentItem?.attachmentId &&
message?.attachments.some((attachment) => attachment.id === currentItem.attachmentId)
? currentItem.attachmentId
: undefined;
const handleAttachmentDeleted = useCallback(() => {
if (MediaViewer.isOpen && MediaViewer.items === items) {
MediaViewerCommands.closeMediaViewer();
}
}, [items]);
const handleDeleteAttachment = useDeleteAttachment(message, deletableAttachmentId, {
onDeleted: handleAttachmentDeleted,
});
const forwardMediaSelection = useMemo<MessageCommands.ForwardMediaSelection | undefined>(() => {
if (!currentItem) return undefined;
if (currentItem.attachmentId) {
@@ -672,6 +691,7 @@ const MediaViewerModalComponent: FC = observer(() => {
onOpenInBrowser={handleOpenInBrowser}
onCopyLink={handleCopyLink}
onCopyMedia={handleCopyMedia}
onDeleteAttachment={deletableAttachmentId ? handleDeleteAttachment : undefined}
onReply={permissions?.canSendMessages ? handleReply : undefined}
onForward={canForwardCurrentMedia ? handleForward : undefined}
enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'}
@@ -3,6 +3,7 @@
import {
ADD_TO_FAVORITES_DESCRIPTOR,
COPY_LINK_DESCRIPTOR,
DELETE_ATTACHMENT_DESCRIPTOR,
REMOVE_FROM_FAVORITES_DESCRIPTOR,
ZOOM_IN_DESCRIPTOR,
ZOOM_OUT_DESCRIPTOR,
@@ -36,16 +37,17 @@ import {
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
StarIcon,
TrashIcon,
XIcon,
} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {type FC, forwardRef, type ReactNode, type Ref} from 'react';
import {type FC, forwardRef, type MouseEventHandler, type ReactNode, type Ref} from 'react';
interface ControlButtonProps {
icon: ReactNode;
label: string;
onClick: () => void;
onClick: MouseEventHandler<HTMLButtonElement>;
variant?: 'default' | 'primary' | 'danger';
active?: boolean;
disabled?: boolean;
@@ -119,6 +121,7 @@ interface MediaOverlayActionsProps {
onOpenInBrowser?: () => void;
onCopyLink?: () => void;
onCopyMedia?: () => void;
onDeleteAttachment?: MouseEventHandler<HTMLButtonElement>;
onReset?: () => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
@@ -144,6 +147,7 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
onOpenInBrowser,
onCopyLink,
onCopyMedia,
onDeleteAttachment,
onReset,
onZoomIn,
onZoomOut,
@@ -250,6 +254,21 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.download"
/>
)}
{onDeleteAttachment && (
<OverlayTooltipButton
icon={
<TrashIcon
size={20}
weight="bold"
data-flx="messaging.media-modal.media-overlay-actions.delete-attachment-icon"
/>
}
label={i18n._(DELETE_ATTACHMENT_DESCRIPTOR)}
onClick={onDeleteAttachment}
variant="danger"
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.delete-attachment"
/>
)}
<div
className={styles.overlayActionGap}
aria-hidden="true"
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {msg} from '@lingui/core/macro';
import type {ReactNode} from 'react';
import type {MouseEventHandler, ReactNode} from 'react';
export type ZoomState = 'fit' | 'zoomed';
@@ -23,6 +23,7 @@ export interface MediaModalProps {
onOpenInBrowser?: () => void;
onCopyLink?: () => void;
onCopyMedia?: () => void;
onDeleteAttachment?: MouseEventHandler<HTMLButtonElement>;
onReply?: () => void;
onForward?: () => void;
onInfo?: () => void;
@@ -9,14 +9,26 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import {Trans, useLingui} from '@lingui/react/macro';
import {useCallback} from 'react';
export function useDeleteAttachment(message: Message | null | undefined, attachmentId: string | null | undefined) {
interface DeleteAttachmentOptions {
onDeleted?: () => void;
}
export function useDeleteAttachment(
message: Message | null | undefined,
attachmentId: string | null | undefined,
options?: DeleteAttachmentOptions,
) {
const {i18n} = useLingui();
const onDeleted = options?.onDeleted;
return useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!message || !attachmentId) return;
const deleteAttachment = () => MessageCommands.deleteAttachment(message.channelId, message.id, attachmentId);
const deleteAttachment = async () => {
await MessageCommands.deleteAttachment(message.channelId, message.id, attachmentId);
onDeleted?.();
};
if (e.shiftKey) {
void deleteAttachment();
return;
@@ -39,6 +51,6 @@ export function useDeleteAttachment(message: Message | null | undefined, attachm
)),
);
},
[message, attachmentId, i18n],
[message, attachmentId, i18n, onDeleted],
);
}
@@ -481,18 +481,15 @@ export const useTextareaSubmit = ({
finishMobileEdit();
return;
}
MessageCommands.edit(
finishMobileEdit();
void MessageCommands.edit(
channelId,
editingMessage.id,
'',
undefined,
editingMessage._allowedMentions,
buildExistingAttachmentEditReferences(editingMessage),
).then((result) => {
if (result) {
finishMobileEdit();
}
});
);
return;
}
MessageCommands.showDeleteConfirmation(i18n, {
@@ -506,17 +503,14 @@ export const useTextareaSubmit = ({
if (checkCustomEmojiAvailability(resolvedContent)) {
return;
}
MessageCommands.edit(
finishMobileEdit();
void MessageCommands.edit(
channelId,
editingMessage.id,
resolvedContent,
undefined,
editingMessage._allowedMentions,
).then((result) => {
if (result) {
finishMobileEdit();
}
});
);
return;
}
if (!hasVisibleMessageContent(resolvedContent) && uploadAttachmentsLength === 0 && !hasPendingSticker) {
@@ -10,15 +10,11 @@ import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {AttachmentUploadConnectivityModal} from '@app/features/messaging/components/alerts/AttachmentUploadConnectivityModal';
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/MessageEditFailedModal';
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
import {MessageSendFailedModal} from '@app/features/messaging/components/alerts/MessageSendFailedModal';
import {MessageSendTooQuickModal} from '@app/features/messaging/components/alerts/MessageSendTooQuickModal';
import {
type MessageLocalSendRateLimitState,
type MessageQueueRequestOutcomeStatus,
resolveMessageLocalSendRateLimitDecision,
resolveMessageQueuePayloadRouteDecision,
resolveMessageQueueRequestOutcomeDecision,
resolveMessageQueueSendExecutionDecision,
} from '@app/features/messaging/state/MessageQueueStateMachine';
@@ -33,11 +29,8 @@ import {exceedsMultipartFallbackRequestSize} from '@app/features/messaging/utils
import {prepareAttachmentsForNonce} from '@app/features/messaging/utils/MessageAttachmentUtils';
import {
type ApiAttachmentMetadata,
type ApiMessageEditAttachmentMetadata,
buildMessageCreateRequest,
type MessageCreateRequest,
type MessageEditRequest,
normalizeMessageEditContent,
} from '@app/features/messaging/utils/MessageRequestUtils';
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
@@ -120,17 +113,7 @@ interface SendMessagePayload extends BaseMessagePayload {
tts?: boolean;
}
interface EditMessagePayload extends BaseMessagePayload {
type: 'edit';
messageId: string;
rateLimitRetryCount?: number;
content?: string;
allowedMentions?: AllowedMentions;
flags?: number;
attachments?: Array<ApiMessageEditAttachmentMetadata>;
}
export type MessageQueuePayload = SendMessagePayload | EditMessagePayload;
export type MessageQueuePayload = SendMessagePayload;
type MessageQueueCompletion<TResult> = {
retry: RetryError | null;
result?: TResult;
@@ -260,11 +243,6 @@ function isRateLimitError(error: HttpError): boolean {
return !isSlowmodeError(error);
}
function getRequestErrorOutcomeStatus(error: HttpError): MessageQueueRequestOutcomeStatus {
if (isRateLimitError(error)) return 'rateLimit';
return 'failure';
}
function isSlowmodeError(error: HttpError): boolean {
if (error.status !== 400 && error.status !== 429) return false;
const body = getApiErrorBody(error);
@@ -351,13 +329,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
}
const error = new Error(`Message queue capacity of ${this.maxSize} entries was reached`);
logger.error('Rejected message queue entry because capacity was reached', error);
if (message.type === 'send') {
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
} else {
ModalCommands.push(
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal--capacity" />),
);
}
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
try {
success(undefined, error);
} catch (callbackError) {
@@ -407,19 +379,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
message: MessageQueuePayload,
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
): Promise<unknown> | undefined {
const route = resolveMessageQueuePayloadRouteDecision({
payloadType: (message as {type?: string}).type,
});
switch (route.type) {
case 'send':
return this.handleSend(message as SendMessagePayload, completed);
case 'edit':
return this.handleEdit(message as EditMessagePayload, completed);
case 'unknown':
logger.error('Unknown message type, completing with null');
completed(null, undefined, new Error('Unknown message queue payload'));
return undefined;
}
return this.handleSend(message, completed);
}
private consumeLocalSendAllowance(channelId: string): boolean {
@@ -1429,127 +1389,6 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
}),
);
}
private async handleEdit(
payload: EditMessagePayload,
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
): Promise<void> {
const {channelId, messageId, content, allowedMentions, flags, attachments} = payload;
const abortController = new AbortController();
this.abortControllers.set(messageId, abortController);
try {
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
const body = this.buildEditRequestBody(content, allowedMentions, flags, attachments);
const response = await http.patch<Message>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
body,
signal: abortController.signal,
suppressContentBlockedModal: true,
});
logger.debug(`Successfully edited message ${messageId} in channel ${channelId}`);
completed(null, response);
} catch (error) {
logger.error(`Failed to edit message ${messageId} in channel ${channelId}:`, error);
if (!(error instanceof HttpError)) {
ModalCommands.push(
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal--request" />),
);
completed(null, undefined, error);
return;
}
const responseErr = error;
const outcomeDecision = resolveMessageQueueRequestOutcomeDecision({
status: getRequestErrorOutcomeStatus(responseErr),
});
switch (outcomeDecision.type) {
case 'retryRateLimit':
this.handleEditRateLimit(payload, responseErr, completed);
break;
case 'completeFailure':
case 'completeSuccess':
this.showEditErrorModal(responseErr);
completed(null, undefined, responseErr);
break;
}
} finally {
this.abortControllers.delete(messageId);
}
}
private buildEditRequestBody(
content?: string,
allowedMentions?: AllowedMentions,
flags?: number,
attachments?: Array<ApiMessageEditAttachmentMetadata>,
): MessageEditRequest {
const body: MessageEditRequest = {};
if (content !== undefined) {
body.content = normalizeMessageEditContent(content);
}
if (allowedMentions !== undefined) {
body.allowed_mentions = allowedMentions;
}
if (flags !== undefined) {
body.flags = flags;
}
if (attachments !== undefined) {
body.attachments = attachments;
}
return body;
}
private handleEditRateLimit(
payload: EditMessagePayload,
error: HttpError,
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
): void {
const retry = resolveMessageRateLimitRetry(error);
const retryCount = payload.rateLimitRetryCount === undefined ? 0 : payload.rateLimitRetryCount;
if (retry.automaticRetryDelayMs !== null && retryCount < MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_RETRIES) {
payload.rateLimitRetryCount = retryCount + 1;
completed({retryAfter: retry.automaticRetryDelayMs}, undefined, error);
return;
}
completed(null, undefined, error);
this.handleEditRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
}
private showEditErrorModal(error: HttpError): void {
if (isFeatureDisabledError(error)) {
ModalCommands.push(
modal(() => (
<FeatureTemporarilyDisabledModal data-flx="messaging.message-queue.feature-temporarily-disabled-modal--2" />
)),
);
} else if (getApiErrorBody(error)?.code === APIErrorCodes.CONTENT_BLOCKED) {
void import('@app/features/auth/components/ContentBlockedHandler').then((m) => m.showContentBlockedModal());
} else {
ModalCommands.push(
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal" />),
);
}
}
private handleEditRateLimitError(retryAfter: number | null, onRetry?: () => void): void {
ModalCommands.push(
modal(() => {
if (retryAfter === null) {
return (
<MessageEditTooQuickModal
onRetry={onRetry}
data-flx="messaging.message-queue.message-edit-too-quick-modal"
/>
);
}
return (
<MessageEditTooQuickModal
retryAfter={retryAfter}
onRetry={onRetry}
data-flx="messaging.message-queue.message-edit-too-quick-modal"
/>
);
}),
);
}
}
export default new MessageQueue();
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
import {ChannelStreamType, createChannelStream} from '@app/features/messaging/utils/MessageGroupingUtils';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}}));
vi.mock('@app/features/moderation/state/LocalUserSpamOverride', () => ({
default: {isUserMarkedAsSpammer: () => false},
}));
vi.mock('@app/features/user/utils/DateFormatting', () => ({
getFormattedFullDate: (date: Date) => date.toISOString().slice(0, 10),
}));
const AUTHOR = {id: 'author-1', flags: 0} as Message['author'];
const ID = {
first: '1519773906704011264',
second: '1519773906708205568',
third: '1519773906712399872',
fourth: '1519773906716594176',
} as const;
function message(id: string, minute: number, blocked = false): Message {
return {
id,
author: AUTHOR,
blocked,
ignored: false,
timestamp: new Date(Date.UTC(2026, 7, 27, 12, minute)),
type: 0,
webhookId: null,
mentions: [],
mentionRoles: [],
mentionEveryone: false,
isUserMessage: () => true,
hasFlag: () => false,
} as unknown as Message;
}
function channelMessages(messages: Array<Message>): ChannelMessages {
return {
forEach: (callback: (message: Message) => boolean | undefined) => {
for (const entry of messages) {
if (callback(entry) === false) return;
}
},
jumpDestinationId: null,
jumpHighlight: false,
jumpTicket: null,
} as unknown as ChannelMessages;
}
const channel = {id: 'channel-1', isPrivate: () => false, getGuildId: () => 'guild-1'} as unknown as Channel;
function buildStream(messages: Array<Message>, oldestUnreadMessageId: string | null) {
return createChannelStream({
channel,
messages: channelMessages(messages),
oldestUnreadMessageId,
treatSpam: false,
});
}
function countUnreadMarkers(stream: Array<ReturnType<typeof buildStream>[number]>): number {
let count = 0;
for (const item of stream) {
if (item.type === ChannelStreamType.DIVIDER && item.unreadId != null) count++;
if (item.type === ChannelStreamType.MESSAGE && item.showUnreadDividerBefore) count++;
if (Array.isArray(item.content)) {
count += countUnreadMarkers(item.content as Array<ReturnType<typeof buildStream>[number]>);
}
}
return count;
}
function findUnreadDivider(stream: Array<ReturnType<typeof buildStream>[number]>) {
for (const item of stream) {
if (item.type === ChannelStreamType.DIVIDER && item.unreadId != null) return item;
if (item.type === ChannelStreamType.MESSAGE && item.showUnreadDividerBefore) return item;
if (item.type === ChannelStreamType.MESSAGE_GROUP_BLOCKED) {
const nested = item.content as Array<ReturnType<typeof buildStream>[number]>;
const divider = nested.find((entry) => entry.type === ChannelStreamType.DIVIDER && entry.unreadId != null);
if (divider) return divider;
}
}
return null;
}
describe('createChannelStream unread divider', () => {
it('marks the unread boundary on a plain message', () => {
const messages = [message(ID.first, 0), message(ID.second, 1), message(ID.third, 2)];
expect(findUnreadDivider(buildStream(messages, ID.second))).not.toBeNull();
});
it('emits a divider inside the collapsed group when the boundary is a blocked message', () => {
const messages = [message(ID.first, 0), message(ID.second, 1, true), message(ID.third, 2, true)];
const stream = buildStream(messages, ID.second);
const group = stream.find((item) => item.type === ChannelStreamType.MESSAGE_GROUP_BLOCKED);
expect(group).toBeDefined();
const nested = group!.content as Array<(typeof stream)[number]>;
expect(nested.some((item) => item.type === ChannelStreamType.DIVIDER && item.unreadId === ID.second)).toBe(true);
expect(group!.hasUnread).toBe(true);
});
it('emits a divider inside the collapsed group when the stored boundary is no longer loaded', () => {
const messages = [message(ID.third, 0, true), message(ID.fourth, 1, true)];
const stream = buildStream(messages, ID.first);
const group = stream.find((item) => item.type === ChannelStreamType.MESSAGE_GROUP_BLOCKED);
expect(group).toBeDefined();
const nested = group!.content as Array<(typeof stream)[number]>;
expect(nested.some((item) => item.type === ChannelStreamType.DIVIDER && item.unreadId === ID.third)).toBe(true);
});
it('never leaves the boundary unrepresented when there is an unread message id', () => {
const messages = [message(ID.first, 0), message(ID.second, 1, true), message(ID.third, 2)];
expect(findUnreadDivider(buildStream(messages, ID.second))).not.toBeNull();
});
it('merges the boundary into the date divider when it is the first message of a day', () => {
const dayOne = message(ID.first, 0);
const dayTwo = message(ID.third, 1);
(dayTwo as {timestamp: Date}).timestamp = new Date(Date.UTC(2026, 7, 28, 9, 0));
const stream = buildStream([dayOne, dayTwo], ID.third);
const dateDivider = stream.find((item) => item.type === ChannelStreamType.DIVIDER && item.unreadId === ID.third);
expect(dateDivider).toBeDefined();
expect(countUnreadMarkers(stream)).toBe(1);
});
it('marks the boundary exactly once', () => {
const messages = [message(ID.first, 0), message(ID.second, 1, true), message(ID.third, 2)];
expect(countUnreadMarkers(buildStream(messages, ID.second))).toBe(1);
expect(countUnreadMarkers(buildStream([message(ID.first, 0), message(ID.second, 1)], ID.second))).toBe(1);
});
});
@@ -197,18 +197,22 @@ export function createChannelStream(props: {
}
}
let shouldShowUnreadDividerBefore = false;
let shouldOpenCollapsedGroupWithUnreadDivider = false;
if (oldestUnreadMessageId === message.id && unreadTimestamp != null) {
if (lastItem?.type === ChannelStreamType.DIVIDER) {
lastItem.unreadId = message.id;
} else if (collapsedGroupItem !== null) {
shouldOpenCollapsedGroupWithUnreadDivider = true;
} else {
shouldShowUnreadDividerBefore = true;
if (collapsedGroupItem !== null) {
collapsedGroupItem.hasUnread = true;
}
}
unreadTimestamp = null;
} else if (unreadTimestamp != null && extractTimestamp(message.id) > unreadTimestamp) {
shouldShowUnreadDividerBefore = true;
if (collapsedGroupItem !== null) {
shouldOpenCollapsedGroupWithUnreadDivider = true;
} else {
shouldShowUnreadDividerBefore = true;
}
unreadTimestamp = null;
}
let prevMessageForGrouping: Message | undefined;
@@ -242,7 +246,12 @@ export function createChannelStream(props: {
messageItem.jumpTarget = true;
}
if (collapsedGroupItem !== null) {
(collapsedGroupItem.content as Array<ChannelStreamItem>).push(messageItem);
const collapsedContentItems = collapsedGroupItem.content as Array<ChannelStreamItem>;
if (shouldOpenCollapsedGroupWithUnreadDivider) {
collapsedContentItems.push({type: ChannelStreamType.DIVIDER, content: '', unreadId: message.id});
collapsedGroupItem.hasUnread = true;
}
collapsedContentItems.push(messageItem);
if (messageItem.jumpTarget) {
collapsedGroupItem.hasJumpTarget = true;
}
@@ -57,6 +57,13 @@ export interface MessageEditRequest {
flags?: number;
}
export interface MessageEditPayload {
content?: string;
attachments?: Array<ApiMessageEditAttachmentMetadata>;
allowedMentions?: AllowedMentions;
flags?: number;
}
export interface MessageCreatePayload {
content?: string | null;
nonce?: string;
@@ -121,6 +128,24 @@ export function buildMessageCreateRequest(payload: MessageCreatePayload): Messag
return requestBody;
}
export function buildMessageEditRequest(payload: MessageEditPayload): MessageEditRequest {
const {content, attachments, allowedMentions, flags} = payload;
const requestBody: MessageEditRequest = {};
if (content !== undefined) {
requestBody.content = normalizeMessageEditContent(content);
}
if (attachments !== undefined) {
requestBody.attachments = attachments;
}
if (allowedMentions !== undefined) {
requestBody.allowed_mentions = allowedMentions;
}
if (flags !== undefined) {
requestBody.flags = flags;
}
return requestBody;
}
const isSilentMessage = (content: string): boolean => {
return content.startsWith('@silent ');
};
@@ -1,8 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {initializeEmojiParser} from '@app/features/messaging/utils/markdown/EmojiProviderSetup';
import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums';
import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm';
import {describe, expect, it} from 'vitest';
import {beforeAll, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/expressions/utils/EmojiUtils', () => ({
getEmojiURL: () => null,
}));
vi.mock('@lingui/core/macro', () => ({
msg: (descriptor: unknown) => descriptor,
}));
describe('markdown parser wasm blockquotes', () => {
it('preserves consecutive empty blockquote lines', () => {
@@ -62,3 +71,36 @@ describe('markdown parser wasm headings', () => {
});
});
});
describe('markdown parser wasm default-presentation emoji', () => {
beforeAll(() => {
initializeEmojiParser();
});
it.each([
['⌚', 'watch', '231a'],
['⌛', 'hourglass', '231b'],
['⏩', 'fast_forward', '23e9'],
['⏪', 'rewind', '23ea'],
['⏫', 'arrow_double_up', '23eb'],
['⏬', 'arrow_double_down', '23ec'],
['⏰', 'alarm_clock', '23f0'],
['⏳', 'hourglass_flowing_sand', '23f3'],
])('renders %s as an emoji without a variation selector', (raw, name, codepoints) => {
expect(parseMarkdownAstWithWasm(raw, 0)).toEqual({
nodes: [{type: 'Emoji', kind: {kind: 'Standard', raw, codepoints, name}}],
});
});
it('keeps text-presentation symbols as plain text when typed bare', () => {
expect(parseMarkdownAstWithWasm('❤', 0)).toEqual({
nodes: [{type: 'Text', content: '❤'}],
});
});
it('still renders text-presentation symbols carrying a variation selector', () => {
expect(parseMarkdownAstWithWasm('❤️', 0)).toEqual({
nodes: [{type: 'Emoji', kind: {kind: 'Standard', raw: '❤️', codepoints: '2764', name: 'heart'}}],
});
});
});
@@ -217,15 +217,6 @@ const SPECIAL_SHORTCODES: Record<string, string> = {
registered: '®',
};
function needsVariationSelector(codePoint: number): boolean {
return (
(codePoint >= 0x2190 && codePoint <= 0x21ff) ||
(codePoint >= 0x2300 && codePoint <= 0x23ff) ||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
(codePoint >= 0x2900 && codePoint <= 0x297f)
);
}
function appendContextLine(parts: Array<string>): string {
return `${parts.join('\t')}\n`;
}
@@ -244,10 +235,6 @@ function buildEmojiContext(input: string): string {
while ((match = emojiRegex.exec(input)) !== null) {
const candidate = match[0];
if (!candidate || PLAINTEXT_SYMBOLS.has(candidate)) continue;
const hasVariationSelector = candidate.includes('️');
const codePoint = candidate.codePointAt(0) || 0;
const isDingbat = codePoint >= 0x2600 && codePoint <= 0x27bf;
if (!isDingbat && needsVariationSelector(codePoint) && !hasVariationSelector) continue;
const name = provider.getSurrogateName(candidate);
if (!name) continue;
const candidateBytes = textEncoder.encode(candidate).byteLength;
@@ -180,10 +180,6 @@ export function handleDeepLinkUrl(rawUrl: string): boolean {
return true;
}
export function handleRpcNavigation(path: string): void {
RouterUtils.transitionTo(path);
}
let listenerStarted = false;
export async function startDeepLinkHandling(): Promise<void> {
@@ -206,17 +202,6 @@ export async function startDeepLinkHandling(): Promise<void> {
logger.error(' Failed to handle URL', url, error);
}
});
if (typeof electronApi.onRpcNavigate === 'function') {
electronApi.onRpcNavigate((path: string) => {
try {
handleRpcNavigation(path);
} catch (error) {
logger.error(' Failed to handle RPC navigation', path, error);
}
});
} else {
logger.warn(' onRpcNavigate not available on this host version');
}
return;
}
}
@@ -78,6 +78,63 @@ type AttemptDecision =
| {next: 'retry-after'; delayMs: number; mode: 'backoff' | 'fixed'}
| {next: 'fail'; error: unknown};
interface OnlineWaiter {
resolve: () => void;
signal: AbortSignal | undefined;
onAbort: () => void;
}
const onlineWaiters = new Set<OnlineWaiter>();
let onlineListenerActive = false;
function createRequestAbortError(): DOMException {
return new DOMException('Request aborted', 'AbortError');
}
function removeOnlineListener(): void {
if (!onlineListenerActive) return;
window.removeEventListener('online', resolveOnlineWaiters);
onlineListenerActive = false;
}
function releaseOnlineWaiter(waiter: OnlineWaiter): boolean {
if (!onlineWaiters.delete(waiter)) return false;
waiter.signal?.removeEventListener('abort', waiter.onAbort);
if (onlineWaiters.size === 0) removeOnlineListener();
return true;
}
function resolveOnlineWaiters(): void {
const pending = Array.from(onlineWaiters);
onlineWaiters.clear();
removeOnlineListener();
for (const waiter of pending) {
waiter.signal?.removeEventListener('abort', waiter.onAbort);
waiter.resolve();
}
}
function waitUntilOnline(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(createRequestAbortError());
if (navigator.onLine) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
const waiter: OnlineWaiter = {
resolve,
signal,
onAbort: () => {
if (releaseOnlineWaiter(waiter)) reject(createRequestAbortError());
},
};
onlineWaiters.add(waiter);
if (signal) signal.addEventListener('abort', waiter.onAbort, {once: true});
if (!onlineListenerActive) {
window.addEventListener('online', resolveOnlineWaiters);
onlineListenerActive = true;
}
if (navigator.onLine) queueMicrotask(resolveOnlineWaiters);
});
}
export class RestClient {
private readonly state: RuntimeState = {
baseUrl: '/api',
@@ -216,6 +273,7 @@ async function runRetryLoop<T>(
attempt: number,
): Promise<RestResponse<T>> {
const plan = composePlan(state, method, path, options, sudoApplied);
if (plan.retries > 0) await waitUntilOnline(plan.signal);
const pacingHit = consultPacing(state.pacing, plan.rateLimitKey);
if (pacingHit) {
if (plan.mode === 'auto-retry') {
@@ -240,7 +298,11 @@ async function runRetryLoop<T>(
return finalizeAfterRetriesExhausted<T>(state, plan, outcome);
}
const wait = decision.mode === 'backoff' ? computeBackoffMs(attempt) : decision.delayMs;
await delay(wait, plan.signal);
if (outcome.status === 'transport-error' && !navigator.onLine) {
await waitUntilOnline(plan.signal);
} else {
await delay(wait, plan.signal);
}
return runRetryLoop<T>(state, method, path, options, sudoApplied, attempt + 1);
}
case 'fail':

Some files were not shown because too many files have changed in this diff Show More