Compare commits

...
Author SHA1 Message Date
Hampus dc230926a4 fix(desktop): skip glibc check for directory packs 2026-08-28 15:09:00 +02:00
Hampus 026ace6747 fix(ci): publish draft releases by ID (#2050) 2026-08-28 00:38:09 +02:00
Hampus 374db9ed2b fix(desktop): harden capture and release packaging (#2049) 2026-08-27 23:54:38 +02:00
fluxer-ci[bot]andJiralite 5ee59c4675 chore(i18n): update public marketing catalogs (#2047)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:52 +01:00
fluxer-ci[bot]andJiralite 33605171a8 chore(marketing): advance pointer 530c44e → 0c78170 (#2046)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:38 +01:00
Hampus 89fac5b088 fix(api): use webhook ID for mention author (#2045) 2026-08-27 17:01:26 +02:00
Hampus 4a34b942b7 fix(api): key mention chunks by content (#2044) 2026-08-27 16:02:24 +02:00
Hampus fc3065ebe4 fix(desktop): build with compatible PipeWire headers (#2043) 2026-08-27 15:18:15 +02:00
Hampus 23493b4ac2 fix(desktop): build libfido2 on Linux runners (#2042) 2026-08-27 14:44:44 +02:00
Hampus 13344096b7 fix(desktop): keep Linux builds compatible with glibc 2.35 (#2041) 2026-08-27 13:41:08 +02:00
Hampus a800430997 fix(app): sort interface languages by locale code (#2040) 2026-08-27 13:30:39 +02:00
Hampus 509562e6da feat(app): delete attachments from the media viewer (#2039) 2026-08-27 13:26:18 +02:00
Hampus 88d85919f1 fix(app): trim pasted friend tags (#2038) 2026-08-27 13:12:12 +02:00
Hampus 154e223284 fix(app): keep sticker sizes fixed while resizing the expression picker 2026-08-27 12:59:38 +02:00
Hampus 58732f7770 fix(api): configure email app base URL separately 2026-08-27 03:26:11 +02:00
Hampus cb4c847d41 fix(app): separate unread state from unread counts 2026-08-27 02:35:56 +02:00
Hampus d3976e33f8 fix(app): keep unread channel opens anchored to the divider 2026-08-27 02:02:45 +02:00
Hampus 8e17970632 fix(app): submit message edits in background 2026-08-26 23:54:28 +02:00
Hampus c0048504db fix(gifs): bound shard cache memory 2026-08-26 23:22:46 +02:00
Hampus 5015452280 fix(search): respect scope in channel suggestions 2026-08-26 23:06:07 +02:00
Hampus c504b68354 fix(api): store administrator user archives separately (#2025) 2026-08-26 21:40:57 +02:00
Hampus 5d2e5932a4 fix(workspace): stabilise the development stack (#2024) 2026-08-26 18:52:53 +02:00
Hampus cf1a7d7a8a fix(api): mask Tor blocks as administrator IP bans (#2023) 2026-08-26 16:07:19 +02:00
Hampus 14a935db81 feat(settings): add mobile camera upload preference 2026-08-26 15:31:53 +02:00
Hampus f98a40062a fix(markdown): render default-presentation emoji without variation selectors 2026-08-26 14:19:28 +02:00
Hampus f7ebc1492c fix(app): recover active session after Electron downgrade 2026-08-26 01:33:26 +02:00
Hampus da3922586a fix(desktop): restore Windows canary builds (#2017) 2026-08-25 23:54:33 +02:00
Hampus 28184f8d4d chore(desktop): downgrade Electron to 42.10.0 (#2016) 2026-08-25 23:21:20 +02:00
fluxer-ci[bot]andhampus-fluxer a4e7522ca0 chore(i18n): update public marketing catalogs (#2015)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-25 21:42:18 +02:00
fluxer-ci[bot]andhampus-fluxer 59b3d30323 chore(marketing): advance pointer 1915e59 → 530c44e (#2014)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-25 21:41:56 +02:00
Hampus 53cac0b614 style(markdown): format the escaped emoji branch (#2012) 2026-08-25 14:48:30 +02:00
196 changed files with 5671 additions and 5305 deletions

No files matched your search

+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:
+29 -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,23 @@ 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: 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 +590,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 +611,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,6 +641,7 @@ jobs:
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
--asset-dir release_assets
)
if [[ "${CHANNEL}" == "canary" ]]; then
release_args+=(--prerelease)
+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",
+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, {
+22
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 {
@@ -254,6 +274,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 +327,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);
@@ -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();
@@ -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({
+2
View File
@@ -158,6 +158,7 @@ export interface APIConfig {
webhookSecret?: string;
fromEmail: string;
fromName: string;
appBaseUrl: string;
smtp?: {
host: string;
port: number;
@@ -204,6 +205,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> {
@@ -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();
});
-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}}.",
@@ -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;
}
}
@@ -308,11 +308,14 @@ export class AuthSessionManager {
}
this.send({type: 'initialize.start'});
try {
await this.loadStoredAccounts();
const storedToken = parseStoredSessionValue(this.deps.appStorage.getItem(AuthSessionStorageKey.Token));
const storedUserId = parseStoredSessionValue(this.deps.appStorage.getItem(AuthSessionStorageKey.UserId));
await this.loadStoredAccounts();
logger.debug(`Loaded from storage: token=${storedToken ? 'present' : 'null'}, userId=${storedUserId ?? 'null'}`);
const storedAccount = storedUserId ? this._snapshot.context.accounts.get(storedUserId) : undefined;
let storedAccount = storedUserId ? this._snapshot.context.accounts.get(storedUserId) : undefined;
if (storedToken && storedUserId && storedAccount?.token !== storedToken) {
storedAccount = this.recoverStoredActiveAccount(storedToken, storedUserId, storedAccount);
}
if (storedToken && storedUserId && storedAccount?.token === storedToken) {
this.send({type: 'initialize.tokenLoaded', token: storedToken, userId: storedUserId});
this.deps.restoreLocalPresenceIntent(storedAccount.presenceIntent ?? null);
@@ -328,6 +331,25 @@ export class AuthSessionManager {
}
}
private recoverStoredActiveAccount(token: string, userId: string, existing?: Account): Account {
const instance = this.deps.getRuntimeSnapshot();
const account: Account = {
userId,
token,
userData: existing?.userData,
presenceIntent: existing?.presenceIntent ?? null,
lastActive: this.deps.now(),
instance,
isValid: true,
};
this.send({type: 'account.upsert', account});
void this.deps.accountStorage
.stashAccountData(userId, token, account.userData, instance, account.presenceIntent)
.catch((error) => logger.warn('Failed to persist recovered active account', error));
logger.info('Recovered stored active account after local account data was unavailable');
return account;
}
private async loadStoredAccounts(): Promise<void> {
try {
const stored = await this.deps.accountStorage.getAllAccounts();
@@ -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':
@@ -344,7 +344,6 @@ export interface ElectronAPI {
pasteFromClipboard: () => Promise<void>;
onDeepLink: (callback: (url: string) => void) => () => void;
getInitialDeepLink: () => Promise<string | null>;
onRpcNavigate: (callback: (path: string) => void) => () => void;
autostartEnable: () => Promise<void>;
autostartDisable: () => Promise<void>;
autostartIsEnabled: () => Promise<boolean>;
@@ -1,12 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {JumpTypes} from '@fluxer/constants/src/JumpConstants';
import {NEW_MESSAGES_BAR_BUFFER} from '@fluxer/constants/src/LimitConstants';
import {describe, expect, it} from 'vitest';
import {
CENTRE_ALIGNMENT_LIFT,
InitialScrollIntent,
MESSAGE_REVEAL_PADDING,
resolveContainerResizeShift,
resolveInitialScrollIntent,
type ScrollerState,
shouldAnimateMessageJump,
} from './shared';
@@ -83,3 +86,42 @@ describe('resolveContainerResizeShift', () => {
expect(resolve(10, state(1400, 400, 2000))).toEqual({kind: 'none'});
});
});
describe('resolveInitialScrollIntent', () => {
it('anchors to the unread boundary only when the divider is expected on screen', () => {
expect(
resolveInitialScrollIntent({
channelType: ChannelTypes.GUILD_TEXT,
rememberedScrollTop: 900,
hasPendingUnreads: true,
}),
).toBe(InitialScrollIntent.UNREAD_BOUNDARY);
expect(
resolveInitialScrollIntent({
channelType: ChannelTypes.GUILD_TEXT,
rememberedScrollTop: 900,
hasPendingUnreads: false,
}),
).toBe(InitialScrollIntent.SAVED_OFFSET);
});
it('falls back to the bottom rather than the top when there is no unread boundary and no saved offset', () => {
expect(
resolveInitialScrollIntent({
channelType: ChannelTypes.GUILD_TEXT,
rememberedScrollTop: null,
hasPendingUnreads: false,
}),
).toBe(InitialScrollIntent.BOTTOM);
});
it('never anchors to the unread boundary in voice channels', () => {
expect(
resolveInitialScrollIntent({
channelType: ChannelTypes.GUILD_VOICE,
rememberedScrollTop: null,
hasPendingUnreads: true,
}),
).toBe(InitialScrollIntent.BOTTOM);
});
});
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {beforeEach, describe, expect, it, vi} from 'vitest';
const makeChannel = (id: string) => ({
id,
type: 0,
guildId: 'guild-1',
isPrivate: () => false,
getGuildId: () => 'guild-1',
});
const loadedMessages: Array<{id: string; author: {id: string}}> = [];
let hasMoreBefore = false;
let hasNewestMessages = true;
vi.mock('@app/features/channel/state/Channels', () => ({
default: {getChannel: (id: string) => makeChannel(id), getBasicChannel: (id: string) => makeChannel(id)},
}));
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
default: {
getMessages: () => ({
get hasMoreBefore() {
return hasMoreBefore;
},
get length() {
return loadedMessages.length;
},
jumpDestinationId: null,
hasNewestMessages: () => hasNewestMessages,
has: (id: string) => loadedMessages.some((m) => m.id === id),
last: () => loadedMessages[loadedMessages.length - 1],
forEachBuffered: (cb: (m: unknown) => void) => {
for (const m of loadedMessages) cb(m);
},
}),
},
}));
vi.mock('@app/features/user/state/Users', () => ({default: {getCurrentUser: () => ({id: 'me'})}}));
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}}));
vi.mock('@app/features/user/state/UserGuildSettings', () => ({
default: {
isEveryoneMentionSuppressed: () => false,
isRoleMentionSuppressed: () => false,
isGuildOrChannelMuted: () => false,
},
}));
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => false}}));
vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({
default: {isAutomaticAckEnabled: () => false},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {post: vi.fn(), get: vi.fn()}}));
const {default: ReadStates} = await import('@app/features/read_state/state/ReadStates');
const ID = {
ack: '1519773906704011264',
newer: '1519773906708205568',
};
let nextChannelId = 0;
function seedReadChannel() {
const channelId = `channel-${++nextChannelId}`;
const state = ReadStates.get(channelId);
state.readStateKnown = true;
state.ackMessageId = ID.ack;
state.lastMessageId = ID.ack;
state.unreadCount = 0;
state.oldestUnreadMessageId = null;
return {channelId, state};
}
describe('ReadStates unread invariant', () => {
beforeEach(() => {
loadedMessages.length = 0;
hasMoreBefore = false;
hasNewestMessages = true;
});
it('never reports a positive unread count without an unread anchor after a passive update', () => {
const {channelId} = seedReadChannel();
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
const count = ReadStates.getUnreadCount(channelId);
const anchor = ReadStates.getVisualUnreadMessageId(channelId);
expect(count > 0).toBe(anchor != null);
});
it('keeps the channel unread for the sidebar even with no anchor to draw a divider at', () => {
const {channelId} = seedReadChannel();
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
expect(ReadStates.hasUnread(channelId)).toBe(true);
});
it('clears a stale unread once the server walks the last message id back', () => {
const {channelId} = seedReadChannel();
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
expect(ReadStates.hasUnread(channelId)).toBe(true);
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.ack}, 'guild-1');
expect(ReadStates.hasUnread(channelId)).toBe(false);
expect(ReadStates.getUnreadCount(channelId)).toBe(0);
expect(ReadStates.getVisualUnreadMessageId(channelId)).toBeNull();
});
it('anchors the divider when a window is loaded whose ack sits outside it', () => {
const {channelId, state} = seedReadChannel();
state.lastMessageId = ID.newer;
hasMoreBefore = true;
loadedMessages.push({id: ID.newer, author: {id: 'someone'}});
ReadStates.handleLoadMessages({channelId, messages: []});
expect(ReadStates.getVisualUnreadMessageId(channelId)).toBe(ID.newer);
expect(ReadStates.getUnreadCount(channelId) > 0).toBe(true);
});
});
@@ -141,16 +141,11 @@ class ReadStates {
this.refreshMentionChannel(state.channelId);
}
private refreshUnreadEstimate(state: ReadStateEntry): void {
private clearUnreadStateIfRead(state: ReadStateEntry): void {
if (!state.hasUnread()) {
state.estimated = false;
state.unreadCount = 0;
state.oldestUnreadMessageId = null;
return;
}
if (state.unreadCount === 0) {
state.estimated = true;
state.unreadCount = Math.max(1, state.mentionCount);
}
}
@@ -416,7 +411,7 @@ class ReadStates {
if (!channelsWithReadState.has(channel.id as ChannelId)) {
this.setMentionCount(state, 0);
}
this.refreshUnreadEstimate(state);
this.clearUnreadStateIfRead(state);
}
this.notifyChange(undefined, {global: true});
});
@@ -436,7 +431,7 @@ class ReadStates {
state.lastMessageId = channel.last_message_id ?? null;
state.lastPinTimestamp = parseTimestamp(channel.last_pin_timestamp);
state.storedGuildId = action.guild.id;
this.refreshUnreadEstimate(state);
this.clearUnreadStateIfRead(state);
this.refreshMentionChannel(channel.id);
}
}
@@ -452,11 +447,16 @@ class ReadStates {
if (newestMessage != null && isNewerMessageId(newestMessage.id, state.lastMessageId)) {
state.lastMessageId = newestMessage.id;
}
if (state.hasUnread()) {
const landedOnNewestWindow = messages.hasNewestMessages();
const landedOnAck = state.ackMessageId != null && messages.jumpDestinationId === state.ackMessageId;
if (state.hasUnread() || landedOnNewestWindow || landedOnAck) {
state.rebuild();
this.refreshUnreadEstimate(state);
this.clearUnreadStateIfRead(state);
} else if (action.isAfter && state.ackMessageId != null && messages.has(state.ackMessageId, true)) {
state.unreadCount += action.messages.length;
if (state.oldestUnreadMessageId == null) {
state.rebuild();
}
}
this.notifyChange(action.channelId);
}
@@ -533,6 +533,7 @@ class ReadStates {
}
handleMessageDelete(action: {channelId: string}): void {
this.getIfExists(action.channelId)?.rebuild();
this.notifyChange(action.channelId);
}
@@ -560,7 +561,7 @@ class ReadStates {
state.readStateKnown = true;
state.ackMessageId = action.channel.last_message_id;
} else if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(action.channel.type) && state.hasUnread()) {
this.refreshUnreadEstimate(state);
this.clearUnreadStateIfRead(state);
}
this.notifyChange(action.channel.id);
}
@@ -582,10 +583,10 @@ class ReadStates {
changed = state.guildId !== guildId;
state.storedGuildId = guildId;
}
if (isNewerMessageId(lastMessageId, state.lastMessageId)) {
if (lastMessageId !== state.lastMessageId) {
state.lastMessageId = lastMessageId;
changed = true;
this.refreshUnreadEstimate(state);
this.clearUnreadStateIfRead(state);
}
if (changed) {
changedChannels.push(channelId as ChannelId);
@@ -747,7 +748,7 @@ class ReadStates {
this.setMentionCount(state, mentionCount);
}
if (decision.shouldRefreshUnreadEstimate) {
this.refreshUnreadEstimate(state);
this.clearUnreadStateIfRead(state);
}
if (decision.shouldNotify) {
this.notifyChange(action.channelId);
@@ -1042,7 +1043,7 @@ class ReadStates {
state.serverVersion = readState.version ?? state.serverVersion;
this.setMentionCount(state, readState.mention_count ?? 0);
state.rebuild(null, {recomputeMentions: manual});
this.refreshUnreadEstimate(state);
this.clearUnreadStateIfRead(state);
this.notifyChange(readState.id);
continue;
}
@@ -274,13 +274,13 @@ export class ReadStateEntry {
});
const hasUnreadBoundary = foundAckMessage || loadedOlderMessages || !messages.hasMoreBefore;
const hasNewestMessages = messages.hasNewestMessages();
this.estimated = !hasNewestMessages || !hasUnreadBoundary;
this.estimated = !hasNewestMessages || (!hasUnreadBoundary && messages.length === loadedUnreadCount);
if (this.estimated) {
this.unreadCount = Math.max(previousUnreadCount, loadedUnreadCount);
} else {
this.unreadCount = loadedUnreadCount;
}
this.oldestUnreadMessageId = hasUnreadBoundary ? (this.storedOldestUnreadMessageId ?? oldestUnread) : null;
this.oldestUnreadMessageId = this.storedOldestUnreadMessageId ?? oldestUnread;
}
shouldMentionFor(message: MessageModel | WireMessage, userId: string, isPrivate: boolean): boolean {
@@ -9,14 +9,7 @@ import {Scroller} from '@app/features/ui/components/Scroller';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import type {IconProps} from '@phosphor-icons/react';
import {
ChatCenteredDotsIcon,
CheckIcon,
EnvelopeSimpleIcon,
GlobeIcon,
HashIcon,
UsersIcon,
} from '@phosphor-icons/react';
import {ChatCenteredDotsIcon, ChatCircleIcon, CheckIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import type React from 'react';
@@ -26,7 +19,7 @@ const SEARCH_IN_DESCRIPTOR = msg({
});
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,
@@ -61,10 +61,7 @@ function getChannelRecency(channel: {id: string; lastMessageId: string | null}):
}
function getChannelSortWeight(channelId: string, baseWeight: number): number {
const unreadCount = ReadStates.getUnreadCount(channelId);
const mentionCount = ReadStates.getMentionCount(channelId);
const hasUnread = unreadCount > 0 || mentionCount > 0;
return hasUnread ? baseWeight + UNREAD_SORT_WEIGHT_BOOST : baseWeight;
return ReadStates.isUnreadOrMentioned(channelId) ? baseWeight + UNREAD_SORT_WEIGHT_BOOST : baseWeight;
}
export function buildChannelCandidate(
@@ -182,7 +182,7 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions)
() => ({
handleMarkAsRead: () => {
const channelIds = channels
.filter((channel) => ReadStates.getUnreadCount(channel.id) > 0)
.filter((channel) => ReadStates.isUnreadOrMentioned(channel.id))
.map((channel) => channel.id);
if (channelIds.length > 0) {
void ReadStateCommands.bulkAckChannels(channelIds);
@@ -100,7 +100,7 @@ export const MarkAsReadMenuItem: React.FC<GuildMenuItemProps> = observer(({guild
}, [channels]);
const handleMarkAsRead = useCallback(() => {
const channelIds = channels
.filter((channel) => ReadStates.getUnreadCount(channel.id) > 0)
.filter((channel) => ReadStates.isUnreadOrMentioned(channel.id))
.map((channel) => channel.id);
if (channelIds.length > 0) {
void ReadStateCommands.bulkAckChannels(channelIds);
@@ -9,6 +9,7 @@ interface MediaViewerOpenOptions {
messageId?: string;
message?: Message;
sourceChannel?: Channel | null;
allowAttachmentDelete?: boolean;
}
type MediaViewerIntent =
@@ -26,6 +27,7 @@ function dispatchMediaViewerIntent(intent: MediaViewerIntent): void {
intent.options?.messageId,
intent.options?.message,
intent.options?.sourceChannel,
intent.options?.allowAttachmentDelete,
);
return;
case 'close':
@@ -36,6 +36,7 @@ class MediaViewer {
messageId?: string = undefined;
message?: Message = undefined;
sourceChannel?: Channel = undefined;
allowAttachmentDelete: boolean = false;
constructor() {
makeAutoObservable(
@@ -56,6 +57,7 @@ class MediaViewer {
messageId?: string,
message?: Message,
sourceChannel?: Channel | null,
allowAttachmentDelete: boolean = false,
): void {
this.isOpen = true;
this.items = copyMediaViewerItems(items);
@@ -64,6 +66,7 @@ class MediaViewer {
this.messageId = messageId;
this.message = message;
this.sourceChannel = sourceChannel ?? undefined;
this.allowAttachmentDelete = allowAttachmentDelete;
}
close(): void {
@@ -74,6 +77,7 @@ class MediaViewer {
this.messageId = undefined;
this.message = undefined;
this.sourceChannel = undefined;
this.allowAttachmentDelete = false;
}
navigate(index: number): void {
@@ -10,17 +10,15 @@ import {
selectSyncedFieldMachineModel,
transitionSyncedFieldMachineSnapshot,
} from '@app/features/user/state/SyncedFieldStateMachine';
import type {SyncedPreferences, SyncedPreferencesField} from '@app/features/user/state/SyncedPreferencesEngine';
import {create, equals, type Message, type MessageInitShape, toBinary} from '@bufbuild/protobuf';
import type {GenMessage} from '@bufbuild/protobuf/codegenv2';
import type {SyncedPreferences} from '@fluxer/schema/src/domains/user/SyncedPreferencesCodec';
import {comparer, reaction, runInAction} from 'mobx';
export {verifyRoundtripStability};
const logger = new Logger('SyncedField');
export type SyncedPreferencesField = Exclude<keyof SyncedPreferences, '$typeName' | '$unknown' | 'sanitizeUrls'>;
function isMessageSchema(value: unknown): value is GenMessage<Message> {
return (
value != null &&
@@ -10,8 +10,6 @@ export type SyncedPreferencesField = Exclude<keyof SyncedPreferences, '$typeName
export type {SyncedPreferences};
export type SyncedPreferencesSubField = Exclude<SyncedPreferencesField, 'sanitizeUrls'>;
interface TopLevelFieldChunk {
field: number;
bytes: Uint8Array;
@@ -225,6 +223,10 @@ export function changedSyncedPreferenceFields(
return toFieldNames(changedFieldNumbers(preferencesToBytes(left), preferencesToBytes(right)));
}
export function syncedPreferencesEqual(left: SyncedPreferences, right: SyncedPreferences): boolean {
return changedFieldNumbers(preferencesToBytes(left), preferencesToBytes(right)).length === 0;
}
export function copySyncedPreferenceField(
target: SyncedPreferences,
source: SyncedPreferences,
@@ -34,6 +34,7 @@ import {
SYNCED_PREFERENCES_FIELDS,
type SyncedPreferences,
type SyncedPreferencesField,
syncedPreferencesEqual,
} from '@app/features/user/state/SyncedPreferencesEngine';
import type {StatusType} from '@fluxer/constants/src/StatusConstants';
import {normalizeStatus, StatusTypes} from '@fluxer/constants/src/StatusConstants';
@@ -54,8 +55,6 @@ import isPlainObject from 'lodash/isPlainObject';
import snakeCase from 'lodash/snakeCase';
import {action, makeAutoObservable, reaction, runInAction} from 'mobx';
type SyncedPreferencesSubField = Exclude<SyncedPreferencesField, 'sanitizeUrls'>;
function restoreSettingValue<K extends keyof UserSettings>(target: UserSettings, source: UserSettings, key: K): void {
target[key] = source[key];
}
@@ -781,12 +780,12 @@ class UserSettingsState {
);
}
getSubPreference<F extends SyncedPreferencesSubField>(field: F): SyncedPreferences[F] | undefined {
getSubPreference<F extends SyncedPreferencesField>(field: F): SyncedPreferences[F] | undefined {
const value = this.syncedPreferences[field];
return value === undefined ? undefined : value;
}
async setSubPreference<F extends SyncedPreferencesSubField>(
async setSubPreference<F extends SyncedPreferencesField>(
field: F,
value: NonNullable<SyncedPreferences[F]>,
): Promise<void> {
@@ -876,8 +875,8 @@ class UserSettingsState {
for (const field of dirtyFields) {
this.markSyncedPreferenceFieldDirty(field);
}
const wireChanged = changedSyncedPreferenceFields(nextWire, this.wireSyncedPreferences).length > 0;
const localChanged = changedSyncedPreferenceFields(merged, this.syncedPreferences).length > 0;
const wireChanged = !syncedPreferencesEqual(nextWire, this.wireSyncedPreferences);
const localChanged = !syncedPreferencesEqual(merged, this.syncedPreferences);
if (!wireChanged && !localChanged) {
if (shouldSyncMigratedMessageGroupSpacing) {
this.markSyncedPreferenceFieldDirty('accessibility');
@@ -314,5 +314,5 @@ export function getSortedLocales(): Array<TranslatedLocaleInfo> {
...locale,
name: i18n._(locale.name),
}))
.sort((a, b) => a.nativeName.localeCompare(b.nativeName));
.sort((a, b) => a.code.localeCompare(b.code));
}
@@ -1,117 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Config from '@app/features/app/config/Config';
const RPC_PORT_STABLE = 21863;
const RPC_PORT_CANARY = 21864;
const RPC_PORTS =
Config.PUBLIC_RELEASE_CHANNEL === 'canary' ? [RPC_PORT_CANARY, RPC_PORT_STABLE] : [RPC_PORT_STABLE, RPC_PORT_CANARY];
interface RpcResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
}
interface HealthResponse {
status: string;
channel: string;
version: string;
platform: string;
}
interface NavigateResponse {
navigated: boolean;
path: string;
}
let cachedAvailablePort: number | null = null;
let lastHealthCheck = 0;
const HEALTH_CHECK_CACHE_MS = 5000;
async function rpcFetch<T>(port: number, endpoint: string, options?: RequestInit): Promise<RpcResponse<T> | null> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`http://127.0.0.1:${port}${endpoint}`, {
...options,
signal: controller.signal,
});
return (await response.json()) as RpcResponse<T>;
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
export async function checkDesktopAvailable(): Promise<{
available: boolean;
port: number | null;
info: HealthResponse | null;
}> {
const now = Date.now();
if (cachedAvailablePort !== null && now - lastHealthCheck < HEALTH_CHECK_CACHE_MS) {
const result = await rpcFetch<HealthResponse>(cachedAvailablePort, '/health');
if (result?.success && result.data) {
return {available: true, port: cachedAvailablePort, info: result.data};
}
cachedAvailablePort = null;
}
for (const port of RPC_PORTS) {
const result = await rpcFetch<HealthResponse>(port, '/health');
if (result?.success && result.data) {
cachedAvailablePort = port;
lastHealthCheck = now;
return {available: true, port, info: result.data};
}
}
cachedAvailablePort = null;
return {available: false, port: null, info: null};
}
export async function navigateInDesktop(path: string): Promise<{
success: boolean;
error?: string;
}> {
const {available, port} = await checkDesktopAvailable();
if (!available || port === null) {
return {success: false, error: 'desktop_unavailable'};
}
const result = await rpcFetch<NavigateResponse>(port, '/navigate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({method: 'navigate', params: {path}}),
});
if (!result) {
return {success: false, error: 'desktop_rpc_unreachable'};
}
if (!result.success) {
return {success: false, error: result.error ?? 'desktop_rpc_unknown'};
}
return {success: true};
}
export async function focusDesktop(): Promise<{
success: boolean;
error?: string;
}> {
const {available, port} = await checkDesktopAvailable();
if (!available || port === null) {
return {success: false, error: 'desktop_unavailable'};
}
const result = await rpcFetch(port, '/focus', {method: 'POST'});
if (!result) {
return {success: false, error: 'desktop_rpc_unreachable'};
}
if (!result.success) {
return {success: false, error: result.error ?? 'desktop_rpc_unknown'};
}
return {success: true};
}
export function resetDesktopRpcCache(): void {
cachedAvailablePort = null;
lastHealthCheck = 0;
}
-1
View File
@@ -483,7 +483,6 @@ export interface ElectronAPI {
passkeyIsSupported?(): Promise<boolean>;
passkeyRegister?(options: unknown, requestContext?: {pin?: string}): Promise<RegistrationResponseJSON>;
passkeyAuthenticate?(options: unknown, requestContext?: {pin?: string}): Promise<AuthenticationResponseJSON>;
onRpcNavigate?(callback: (path: string) => void): () => void;
getOpenH264Status?(): Promise<OpenH264Status>;
setOpenH264Enabled?(enabled: boolean): Promise<OpenH264Status>;
virtmic?: VirtmicApi;
-2
View File
@@ -68,8 +68,6 @@ const CONNECT_SOURCES: &[&str] = &[
"https://challenges.cloudflare.com",
"https://fluxerstatus.com",
"https://fluxer.media",
"http://127.0.0.1:21863",
"http://127.0.0.1:21864",
];
const WORKER_SOURCES: &[&str] = &["https://*.fluxer.app", "blob:"];
+1 -1
View File
@@ -148,7 +148,7 @@ pub async fn proxy_assets(
response
}
async fn serve_local_asset(
pub(super) async fn serve_local_asset(
static_dir: &str,
relative_path: &str,
request_headers: &HeaderMap,
+25
View File
@@ -19,6 +19,7 @@ use axum::{
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::assets_proxy::serve_local_asset;
use super::spa_static::{CORS_ALLOW_ANY_VALUE, guess_mime, is_font_mime};
const ACCEPT_CH_VALUE: &str = "DPR, Sec-CH-DPR, Sec-CH-Width, Save-Data, ECT, Downlink";
@@ -35,6 +36,14 @@ pub async fn spa_catch_all(
if let Some(cache_control) = static_root_file_cache_control(request_path) {
return serve_static_file(&state.config.static_dir, request_path, cache_control).await;
}
if is_static_asset_path(request_path) {
return serve_local_asset(
&state.config.static_dir,
request_path.trim_start_matches('/'),
&headers,
)
.await;
}
serve_spa_index(&state, &headers, request_path).await
}
@@ -42,6 +51,16 @@ pub async fn spa_catch_all(
const CRAWL_CONTROL_CACHE_CONTROL: &str = "public, max-age=300, must-revalidate";
const STATIC_ROOT_FILES: &[(&str, &str)] = &[("/robots.txt", CRAWL_CONTROL_CACHE_CONTROL)];
const STATIC_ASSET_PREFIXES: &[&str] = &[
"/avatars/",
"/badges/",
"/desktop/",
"/embeds/",
"/emoji/",
"/libs/",
"/marketing/",
"/web/",
];
fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> {
STATIC_ROOT_FILES
@@ -50,6 +69,12 @@ fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> {
.map(|(_, cache_control)| *cache_control)
}
fn is_static_asset_path(request_path: &str) -> bool {
STATIC_ASSET_PREFIXES
.iter()
.any(|prefix| request_path.starts_with(prefix))
}
async fn serve_static_file(
static_dir: &str,
request_path: &str,
+227 -39
View File
@@ -14,6 +14,7 @@ const packageName = isCanary ? 'fluxer_desktop_canary' : 'fluxer_desktop';
const linuxPackageName = isCanary ? 'fluxer-canary' : 'fluxer';
const linuxDesktopActionIds = ['open-settings', 'new-dm'];
const linuxDesktopActionList = `${linuxDesktopActionIds.join(';')};`;
const linuxGlibcBaseline = Object.freeze({major: 2, minor: 35, patch: 0, name: 'GLIBC_2.35'});
const rpmBuildIdFilePrefix = '/usr/lib/.build-id';
const rpmBuildIdLinkFpmArgs = [
'--rpm-rpmbuild-define',
@@ -46,6 +47,8 @@ if (targetNativeArch === 'universal' && targetPlatform !== 'darwin') {
const targetArchs = electronArch && electronArch !== 'universal' ? [electronArch] : supportedTargetArchs;
const macTargetArchs = targetNativeArch ? [targetNativeArch] : supportedTargetArchs;
const winGameCaptureTargetArchs =
targetPlatform === 'win32' && targetNativeArch ? [targetNativeArch] : supportedTargetArchs;
const winTargets = [
{
target: 'dir',
@@ -149,11 +152,9 @@ const nativeRuntimeFilePatterns = [
'node_modules/@fluxer/win-game-capture/package.json',
'node_modules/@fluxer/win-game-capture/index.js',
'node_modules/@fluxer/win-game-capture/loader-diagnostics.cjs',
'node_modules/@fluxer/win-game-capture/*.node',
'node_modules/@fluxer/win-game-capture/*.dll',
'node_modules/@fluxer/win-game-capture/*.exe',
'node_modules/@fluxer/win-game-capture/compatibility.json',
'node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json',
...winGameCaptureTargetArchs.map(
(arch) => `node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
),
'node_modules/@fluxer/win-clipboard/package.json',
'node_modules/@fluxer/win-clipboard/index.js',
'node_modules/@fluxer/win-clipboard/loader-diagnostics.cjs',
@@ -223,11 +224,10 @@ const nativeRuntimeFilePatterns = [
'node_modules/.pnpm/@fluxer+*/node_modules/@fluxer/*/loader-diagnostics.cjs',
'node_modules/.pnpm/@fluxer+*/node_modules/@fluxer/*/pure.cjs',
'node_modules/.pnpm/@fluxer+win-process-loopback@*/node_modules/@fluxer/win-process-loopback/*.node',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.node',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.exe',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/compatibility.json',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/fluxer-vulkan-layer.*.json',
...winGameCaptureTargetArchs.map(
(arch) =>
`node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
),
'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node',
'node_modules/.pnpm/@fluxer+win-shell@*/node_modules/@fluxer/win-shell/*.node',
'node_modules/.pnpm/@fluxer+win-toast@*/node_modules/@fluxer/win-toast/*.node',
@@ -351,6 +351,21 @@ function pnpmStoreDirName(packageName) {
return packageName.replace('/', '+');
}
function windowsGameCaptureArtifactExcludes(arch) {
const packageRoots = [
'node_modules/@fluxer/win-game-capture',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture',
];
const excludedNodeArchs = [...supportedTargetArchs, 'ia32'].filter((candidate) => candidate !== arch);
return packageRoots.flatMap((packageRoot) => [
`!${packageRoot}/compatibility.json`,
`!${packageRoot}/fluxer-game-hook.*`,
`!${packageRoot}/fluxer-inject-helper.*`,
`!${packageRoot}/fluxer-vulkan-layer.*`,
...excludedNodeArchs.map((excludedArch) => `!${packageRoot}/win-game-capture.win32-${excludedArch}-msvc.node`),
]);
}
function platformNativeExcludes(platform, arch) {
const keepFluxerPackages = new Set(fluxerNativePackagesByPlatform[platform] ?? []);
const fluxerPackageExcludes = fluxerNativePackages
@@ -370,6 +385,7 @@ function platformNativeExcludes(platform, arch) {
}
return [
...fluxerPackageExcludes,
...windowsGameCaptureArtifactExcludes(arch),
...velopackNativeFiles
.filter((fileName) => fileName !== keepVelopackNativeFile)
.map((fileName) => `!node_modules/velopack/lib/native/${fileName}`),
@@ -402,22 +418,11 @@ function platformTag(platform, arch) {
return null;
}
function addWindowsGameCaptureArtifacts(artifacts, tag, arch) {
const add = (relativePath) => {
artifacts.push({
packageName: '@fluxer/win-game-capture',
relativePath,
});
};
add(`win-game-capture.${tag}.node`);
add(`fluxer-game-hook.${tag}.dll`);
add(`fluxer-inject-helper.${tag}.exe`);
add(`fluxer-vulkan-layer.${tag}.dll`);
add(`fluxer-vulkan-layer.${tag}.json`);
if (arch === 'x64') {
add('fluxer-game-hook.win32-ia32-msvc.dll');
add('fluxer-inject-helper.win32-ia32-msvc.exe');
}
function addWindowsGameCaptureArtifacts(artifacts, tag) {
artifacts.push({
packageName: '@fluxer/win-game-capture',
relativePath: `win-game-capture.${tag}.node`,
});
}
function expectedNativeRuntimeArtifacts(platform, arch) {
@@ -476,7 +481,7 @@ function expectedNativeRuntimeArtifactsForArch(platform, arch) {
packageName: '@fluxer/win-process-loopback',
relativePath: `win-process-loopback.${tag}.node`,
});
addWindowsGameCaptureArtifacts(artifacts, tag, arch);
addWindowsGameCaptureArtifacts(artifacts, tag);
artifacts.push({
packageName: '@fluxer/win-clipboard',
relativePath: `win-clipboard.${tag}.node`,
@@ -814,11 +819,179 @@ async function addLinuxLegacyBinarySymlink(context) {
}
}
async function isElfFile(filePath) {
const handle = await fs.open(filePath, 'r');
try {
const magic = Buffer.alloc(4);
const {bytesRead} = await handle.read(magic, 0, magic.length, 0);
return bytesRead === magic.length && magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
} finally {
await handle.close();
}
}
async function findPackagedElfFiles(rootDir) {
const results = [];
async function visit(directory) {
const entries = await fs.readdir(directory, {withFileTypes: true});
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await visit(entryPath);
} else if (entry.isFile() && (await isElfFile(entryPath))) {
results.push(entryPath);
}
}
}
await visit(rootDir);
return results.sort();
}
function compareGlibcVersions(left, right) {
for (const key of ['major', 'minor', 'patch']) {
if (left[key] !== right[key]) return left[key] - right[key];
}
return 0;
}
async function inspectElfGlibcRequirements(elfPath) {
let stdout;
try {
({stdout} = await execFileAsync('readelf', ['--version-info', '--dynamic', '--wide', elfPath], {
env: {...process.env, LC_ALL: 'C'},
maxBuffer: 16 * 1024 * 1024,
}));
} catch (error) {
if (error && error.code === 'ENOENT') {
throw new Error(`Cannot verify Linux glibc compatibility: readelf executable is not available.`);
}
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
throw new Error(`Cannot inspect ELF file ${elfPath}: ${stderr || error?.message || String(error)}`);
}
const versions = new Map();
const unsupportedRequirements = new Set();
const hasGlibcRequirement = /\bGLIBC_[A-Za-z0-9_.]+\b/.test(stdout);
let readingVersionNeeds = false;
let foundVersionNeeds = false;
let hasVersionNeedsTag = false;
let needsGlibc = false;
let usesDtRelr = false;
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (/\(VERNEED\)/.test(trimmed)) {
hasVersionNeedsTag = true;
}
if (/^0x0*(?:23|24|25)\s+\(/i.test(trimmed) || /\((?:RELR|RELRSZ|RELRENT)\)/.test(trimmed)) {
usesDtRelr = true;
}
if (/\(NEEDED\)/.test(trimmed)) {
const neededMatch = /\(NEEDED\)\s+Shared library: \[([^\]]+)\]/.exec(trimmed);
if (!neededMatch) {
throw new Error(`Cannot parse a required library for ELF file ${elfPath}: ${trimmed}`);
}
if (neededMatch[1] === 'libc.so.6') needsGlibc = true;
}
if (trimmed.startsWith('Version needs section ')) {
readingVersionNeeds = true;
foundVersionNeeds = true;
continue;
}
if (trimmed.startsWith('Version symbols section ') || trimmed.startsWith('Version definition section ')) {
readingVersionNeeds = false;
continue;
}
if (!readingVersionNeeds) continue;
const requirement = /\bName:\s+(\S+)/.exec(trimmed)?.[1];
if (!requirement?.startsWith('GLIBC_')) continue;
const match = /^GLIBC_(\d+)\.(\d+)(?:\.(\d+))?$/.exec(requirement);
if (!match) {
unsupportedRequirements.add(requirement);
continue;
}
const version = {
major: Number.parseInt(match[1], 10),
minor: Number.parseInt(match[2], 10),
patch: Number.parseInt(match[3] ?? '0', 10),
name: requirement,
};
if (![version.major, version.minor, version.patch].every(Number.isSafeInteger)) {
throw new Error(`readelf returned an invalid glibc version for ${elfPath}: ${requirement}`);
}
versions.set(version.name, version);
}
if ((hasGlibcRequirement || hasVersionNeedsTag) && !foundVersionNeeds) {
throw new Error(`Cannot verify glibc requirements for ELF file ${elfPath}: readelf omitted version needs.`);
}
if (needsGlibc && (!foundVersionNeeds || (versions.size === 0 && unsupportedRequirements.size === 0))) {
throw new Error(`Cannot verify glibc requirements for ELF file ${elfPath}: readelf returned no version needs.`);
}
return {
versions: Array.from(versions.values()),
unsupportedRequirements: Array.from(unsupportedRequirements).sort(),
usesDtRelr,
};
}
async function findLinuxGlibcCompatibilityViolations(elfFiles) {
const violations = [];
for (const elfPath of elfFiles) {
const {versions, unsupportedRequirements, usesDtRelr} = await inspectElfGlibcRequirements(elfPath);
const maximum = versions.sort(compareGlibcVersions).at(-1);
const requirements = [...unsupportedRequirements];
if (maximum && compareGlibcVersions(maximum, linuxGlibcBaseline) > 0) {
requirements.push(maximum.name);
}
if (usesDtRelr) requirements.push('DT_RELR (glibc 2.36+)');
if (requirements.length > 0) violations.push({elfPath, requirements});
}
return violations;
}
function throwLinuxGlibcCompatibilityError(violations, formatPath) {
if (violations.length === 0) return;
const lines = [
`Linux package exceeds the supported ${linuxGlibcBaseline.name} ABI baseline.`,
'Build Linux artifacts on Ubuntu 22.04 and keep every shipped ELF at or below that glibc requirement.',
];
for (const {elfPath, requirements} of violations) {
lines.push(` - ${formatPath(elfPath)} requires ${requirements.join(', ')}`);
}
throw new Error(lines.join('\n'));
}
function linuxDistributableTargetNames(context) {
if (!Array.isArray(context.targets)) {
throw new Error('Cannot verify Linux glibc compatibility: electron-builder did not provide a target list.');
}
return context.targets.map((target) => target.name).filter((name) => name !== 'dir');
}
async function verifyLinuxGlibcCompatibility(context) {
if (context.electronPlatformName !== 'linux') return;
const distributableTargets = linuxDistributableTargetNames(context);
if (distributableTargets.length === 0) {
console.log(
`Skipped the ${linuxGlibcBaseline.name} ABI baseline check: this pack produces no distributable Linux artifact.`,
);
return;
}
const elfFiles = await findPackagedElfFiles(context.appOutDir);
if (elfFiles.length === 0) {
throw new Error(`Linux package output contains no ELF files: ${context.appOutDir}`);
}
const violations = await findLinuxGlibcCompatibilityViolations(elfFiles);
throwLinuxGlibcCompatibilityError(violations, (elfPath) => path.relative(context.appOutDir, elfPath));
console.log(
`Verified ${elfFiles.length} packaged Linux ELF files against the ${linuxGlibcBaseline.name} ABI baseline.`,
);
}
async function afterPack(context) {
await copyMissingPackagedNativeArtifacts(context);
await cleanupNativeBuildIntermediates(context);
await addLinuxLegacyBinarySymlink(context);
await verifyPackagedNativeArtifacts(context);
await verifyLinuxGlibcCompatibility(context);
}
async function listRpmPackageFiles(artifactPath) {
@@ -922,12 +1095,19 @@ async function verifyLinuxPackagesContainAppArmorProfile(buildResult) {
async function readElfNeededLibraries(artifactPath) {
try {
const {stdout} = await execFileAsync('readelf', ['-d', artifactPath], {
env: {...process.env, LC_ALL: 'C'},
maxBuffer: 8 * 1024 * 1024,
});
return stdout
.split(/\r?\n/)
.map((line) => line.match(/\(NEEDED\)\s+Shared library: \[([^\]]+)\]/)?.[1])
.filter(Boolean);
const neededLibraries = [];
for (const line of stdout.split(/\r?\n/)) {
if (!/\(NEEDED\)/.test(line)) continue;
const match = /\(NEEDED\).*\[([^\]]+)\]/.exec(line);
if (!match) {
throw new Error(`Cannot parse a required library for AppImage artifact ${artifactPath}: ${line.trim()}`);
}
neededLibraries.push(match[1]);
}
return neededLibraries;
} catch (error) {
if (error && error.code === 'ENOENT') {
throw new Error(`Cannot inspect AppImage artifact ${artifactPath}: readelf executable is not available.`);
@@ -937,6 +1117,14 @@ async function readElfNeededLibraries(artifactPath) {
}
}
async function verifyAppImageArtifactsGlibcCompatibility(buildResult) {
const appImageArtifacts = (buildResult.artifactPaths ?? []).filter(
(artifactPath) => path.extname(artifactPath) === '.AppImage',
);
const violations = await findLinuxGlibcCompatibilityViolations(appImageArtifacts);
throwLinuxGlibcCompatibilityError(violations, (artifactPath) => path.basename(artifactPath));
}
async function verifyAppImageArtifactsDoNotNeedFuse2(buildResult) {
const appImageArtifacts = (buildResult.artifactPaths ?? []).filter(
(artifactPath) => path.extname(artifactPath) === '.AppImage',
@@ -1072,6 +1260,7 @@ async function verifyAppImageArtifactsUseSandboxAwareLauncher(buildResult) {
async function verifyLinuxArtifactContracts(buildResult) {
await verifyRpmArtifactsDoNotOwnBuildIds(buildResult);
await verifyLinuxPackagesContainAppArmorProfile(buildResult);
await verifyAppImageArtifactsGlibcCompatibility(buildResult);
await verifyAppImageArtifactsDoNotNeedFuse2(buildResult);
await verifyAppImageArtifactsUseSandboxAwareLauncher(buildResult);
}
@@ -1134,10 +1323,9 @@ module.exports = {
asarUnpack: [
'**/*.node',
'node_modules/@fluxer/win-process-loopback/*.node',
'node_modules/@fluxer/win-game-capture/*.node',
'node_modules/@fluxer/win-game-capture/*.dll',
'node_modules/@fluxer/win-game-capture/*.exe',
'node_modules/@fluxer/win-game-capture/*.json',
...winGameCaptureTargetArchs.map(
(arch) => `node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
),
'node_modules/@fluxer/win-clipboard/*.node',
'node_modules/@fluxer/win-shell/*.node',
'node_modules/@fluxer/win-toast/*.node',
@@ -1160,10 +1348,10 @@ module.exports = {
'node_modules/@fluxer/webauthn/*.node',
'node_modules/@fluxer/webauthn/*.so*',
'node_modules/.pnpm/@fluxer+win-process-loopback@*/node_modules/@fluxer/win-process-loopback/*.node',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.node',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.dll',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.exe',
'node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/*.json',
...winGameCaptureTargetArchs.map(
(arch) =>
`node_modules/.pnpm/@fluxer+win-game-capture@*/node_modules/@fluxer/win-game-capture/win-game-capture.win32-${arch}-msvc.node`,
),
'node_modules/.pnpm/@fluxer+win-clipboard@*/node_modules/@fluxer/win-clipboard/*.node',
'node_modules/.pnpm/@fluxer+win-shell@*/node_modules/@fluxer/win-shell/*.node',
'node_modules/.pnpm/@fluxer+win-toast@*/node_modules/@fluxer/win-toast/*.node',
+3 -4
View File
@@ -42,10 +42,9 @@ const BACKEND_REASON: &str = "Windows WebAuthn broker backend (webauthn.dll). \
webauthn.dll is delay-loaded via /DELAYLOAD, so the addon still loads on \
older builds but `isSupported()` returns false there.";
#[cfg(target_os = "linux")]
const BACKEND_REASON: &str = "Linux WebAuthn backend (libfido2). Requires \
libfido2 >= 1.5.0 (HID transport, U2F fallback disabled) and a CTAP \
authenticator on USB-HID; `isSupported()` returns false when no \
authenticator is enumerated.";
const BACKEND_REASON: &str = "Linux WebAuthn backend (bundled libfido2, HID transport, U2F \
fallback disabled). Requires a CTAP authenticator on USB-HID; \
`isSupported()` returns false when no authenticator is enumerated.";
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
const BACKEND_REASON: &str = "no WebAuthn backend is compiled in for this platform";
@@ -8,6 +8,10 @@ publish = false
[workspace]
resolver = "2"
[features]
default = []
game-capture-hook = []
[lib]
crate-type = ["cdylib", "rlib"]
@@ -18,19 +18,7 @@
"index.d.ts",
"loader-diagnostics.cjs",
"win-game-capture.win32-x64-msvc.node",
"win-game-capture.win32-arm64-msvc.node",
"fluxer-game-hook.win32-x64-msvc.dll",
"fluxer-game-hook.win32-ia32-msvc.dll",
"fluxer-game-hook.win32-arm64-msvc.dll",
"fluxer-inject-helper.win32-x64-msvc.exe",
"fluxer-inject-helper.win32-ia32-msvc.exe",
"fluxer-inject-helper.win32-arm64-msvc.exe",
"fluxer-vulkan-layer.win32-x64-msvc.dll",
"fluxer-vulkan-layer.win32-x64-msvc.json",
"fluxer-vulkan-layer.win32-ia32-msvc.dll",
"fluxer-vulkan-layer.win32-ia32-msvc.json",
"fluxer-vulkan-layer.win32-arm64-msvc.dll",
"fluxer-vulkan-layer.win32-arm64-msvc.json"
"win-game-capture.win32-arm64-msvc.node"
],
"scripts": {
"build": "cargo run --locked --quiet --manifest-path ../../../tools/ci/Cargo.toml -- build-desktop-native-addon",
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::ptr::{null, null_mut};
use windows_sys::Win32::{
Foundation::{HWND, LPARAM, RECT},
Graphics::Gdi::{
EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO,
MonitorFromRect,
},
System::Threading::GetCurrentProcessId,
UI::WindowsAndMessaging::{
EnumWindows, GW_OWNER, GWL_STYLE, GetForegroundWindow, GetWindow, GetWindowLongPtrW,
GetWindowRect, GetWindowThreadProcessId, IsWindow, IsWindowVisible, WS_BORDER, WS_MAXIMIZE,
},
};
const MONITOR_LIMIT: usize = 16;
fn parse_hwnd_source_id(source_id: &str) -> Option<HWND> {
let token = source_id.strip_prefix("window:")?.split(':').next()?;
let value = if let Some(hex) = token
.strip_prefix("0x")
.or_else(|| token.strip_prefix("0X"))
{
isize::from_str_radix(hex, 16).ok()?
} else {
token.parse::<isize>().ok()?
};
let hwnd = value as HWND;
(unsafe { IsWindow(hwnd) } != 0).then_some(hwnd)
}
fn parse_screen_ordinal(source_id: &str) -> Option<usize> {
let token = source_id.strip_prefix("screen:")?.split(':').next()?;
token.parse::<usize>().ok()
}
pub(crate) fn resolve_game_capture_target(
source_id: &str,
source_kind: &str,
) -> Result<HWND, String> {
if let Some(hwnd) = parse_hwnd_source_id(source_id) {
return Ok(hwnd);
}
if source_kind != "game" && source_kind != "screen" {
return Err(format!(
"invalid game capture source: {source_kind}:{source_id}"
));
}
let monitor = monitor_for_screen_source(source_id)?;
find_fullscreen_window_on_monitor(monitor)
.or_else(find_foreground_fullscreen_window)
.or_else(find_fullscreen_window_on_any_monitor)
.ok_or_else(|| "no fullscreen game window found on selected display".to_string())
}
fn monitor_for_screen_source(source_id: &str) -> Result<HMONITOR, String> {
let ordinal = parse_screen_ordinal(source_id).unwrap_or(0);
let monitors = enumerate_monitors();
monitors
.get(ordinal)
.copied()
.or_else(|| monitors.first().copied())
.ok_or_else(|| "no monitors available for game capture".to_string())
}
fn enumerate_monitors() -> Vec<HMONITOR> {
unsafe extern "system" fn enum_monitor(
monitor: HMONITOR,
_hdc: windows_sys::Win32::Graphics::Gdi::HDC,
_rect: *mut RECT,
param: LPARAM,
) -> i32 {
let monitors = &mut *(param as *mut Vec<HMONITOR>);
if monitors.len() >= MONITOR_LIMIT {
return 0;
}
monitors.push(monitor);
1
}
let mut monitors = Vec::with_capacity(MONITOR_LIMIT);
unsafe {
EnumDisplayMonitors(
null_mut(),
null(),
Some(enum_monitor),
&mut monitors as *mut _ as LPARAM,
);
}
assert!(monitors.len() <= MONITOR_LIMIT, "monitor targets bounded");
monitors
}
pub(crate) fn monitor_rect(monitor: HMONITOR) -> Option<RECT> {
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
rcMonitor: RECT::default(),
rcWork: RECT::default(),
dwFlags: 0,
};
if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 {
return None;
}
Some(info.rcMonitor)
}
fn rect_matches_monitor(window_rect: &RECT, monitor_rect: &RECT) -> bool {
const TOLERANCE: i32 = 2;
(window_rect.left - monitor_rect.left).abs() <= TOLERANCE
&& (window_rect.top - monitor_rect.top).abs() <= TOLERANCE
&& (window_rect.right - monitor_rect.right).abs() <= TOLERANCE
&& (window_rect.bottom - monitor_rect.bottom).abs() <= TOLERANCE
}
fn is_regular_maximized_window(hwnd: HWND) -> bool {
let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32;
(style & WS_MAXIMIZE) != 0 && (style & WS_BORDER) != 0
}
fn is_fullscreen_window_on_monitor(hwnd: HWND, monitor: HMONITOR, monitor_rect: &RECT) -> bool {
if unsafe { IsWindowVisible(hwnd) } == 0 {
return false;
}
if !unsafe { GetWindow(hwnd, GW_OWNER) }.is_null() {
return false;
}
let mut pid = 0u32;
unsafe {
GetWindowThreadProcessId(hwnd, &mut pid);
}
if pid == 0 || pid == unsafe { GetCurrentProcessId() } {
return false;
}
if is_regular_maximized_window(hwnd) {
return false;
}
let mut rect = RECT::default();
if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 {
return false;
}
let window_monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) };
window_monitor == monitor && rect_matches_monitor(&rect, monitor_rect)
}
fn find_foreground_fullscreen_window() -> Option<HWND> {
let hwnd = unsafe { GetForegroundWindow() };
if hwnd.is_null() {
return None;
}
let mut rect = RECT::default();
if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 {
return None;
}
let monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) };
let monitor_rect = monitor_rect(monitor)?;
is_fullscreen_window_on_monitor(hwnd, monitor, &monitor_rect).then_some(hwnd)
}
fn find_fullscreen_window_on_any_monitor() -> Option<HWND> {
for monitor in enumerate_monitors() {
if let Some(hwnd) = find_fullscreen_window_on_monitor(monitor) {
return Some(hwnd);
}
}
None
}
fn find_fullscreen_window_on_monitor(monitor: HMONITOR) -> Option<HWND> {
struct Search {
monitor: HMONITOR,
monitor_rect: RECT,
result: HWND,
own_pid: u32,
}
unsafe extern "system" fn enum_window(hwnd: HWND, param: LPARAM) -> i32 {
let search = &mut *(param as *mut Search);
let mut pid = 0u32;
GetWindowThreadProcessId(hwnd, &mut pid);
if pid == 0 || pid == search.own_pid {
return 1;
}
if !is_fullscreen_window_on_monitor(hwnd, search.monitor, &search.monitor_rect) {
return 1;
}
search.result = hwnd;
0
}
let monitor_rect = monitor_rect(monitor)?;
let mut search = Search {
monitor,
monitor_rect,
result: null_mut(),
own_pid: unsafe { GetCurrentProcessId() },
};
unsafe {
let _ = EnumWindows(Some(enum_window), &mut search as *mut _ as LPARAM);
}
(!search.result.is_null()).then_some(search.result)
}
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use windows::Win32::{
Foundation::HMODULE,
Graphics::{
Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN},
Direct3D11::{
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device,
ID3D11DeviceContext,
},
Dxgi::{IDXGIAdapter, IDXGIDevice},
},
};
use windows::core::Interface;
pub(crate) fn create_shared_texture_device(
adapter: Option<&IDXGIAdapter>,
) -> Result<(ID3D11Device, ID3D11DeviceContext), String> {
let mut device = None;
let mut context = None;
let driver_type = if adapter.is_some() {
D3D_DRIVER_TYPE_UNKNOWN
} else {
D3D_DRIVER_TYPE_HARDWARE
};
unsafe {
D3D11CreateDevice(
adapter,
driver_type,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.map_err(|error| format!("D3D11CreateDevice for shared capture texture: {error}"))?;
}
let device = device.ok_or("D3D11 shared texture device was None")?;
let context = context.ok_or("D3D11 shared texture context was None")?;
if let Ok(dxgi_device) = device.cast::<IDXGIDevice>() {
let _ = unsafe { dxgi_device.SetGPUThreadPriority(7) };
}
Ok((device, context))
}
@@ -394,6 +394,7 @@ pub(crate) fn resolve_output_size(
}
#[cfg(target_os = "windows")]
#[cfg(feature = "game-capture-hook")]
pub(crate) fn wall_clock_us() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
@@ -5,7 +5,9 @@ use crate::game_capture_abi::{ENV_FORCE_CPU, ENV_VERBOSE, env_flag_enabled};
use crate::nv12_gpu::Nv12GpuConverter;
use crate::{
CaptureInner,
capture_target::{monitor_rect, resolve_game_capture_target},
compatibility::{InjectionPolicy, injection_policy_for_window},
d3d11_device::create_shared_texture_device,
dxgi_capture::{resolve_output_size, wall_clock_us},
emit_lifecycle, emit_shared_texture_frame,
game_capture_abi::{
@@ -26,26 +28,18 @@ use std::{
sync::{Arc, Mutex, atomic::Ordering},
};
use windows::Win32::{
Foundation::{HANDLE as WinHandle, HMODULE},
Foundation::HANDLE as WinHandle,
Graphics::{
Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN},
Direct3D11::{
D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11_TEXTURE2D_DESC,
D3D11CreateDevice, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D,
},
Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIDevice, IDXGIFactory1},
Direct3D11::{D3D11_TEXTURE2D_DESC, ID3D11Device, ID3D11DeviceContext, ID3D11Texture2D},
Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1},
},
};
use windows::core::Interface;
use windows_sys::Win32::{
Foundation::{
CloseHandle, FreeLibrary, HANDLE, HINSTANCE, HMODULE as WinSysHmodule, HWND,
INVALID_HANDLE_VALUE, LPARAM, RECT, WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT,
},
Graphics::Gdi::{
EnumDisplayMonitors, GetMonitorInfoW, HMONITOR, MONITOR_DEFAULTTONEAREST, MONITORINFO,
MonitorFromRect,
},
Graphics::Gdi::{MONITOR_DEFAULTTONEAREST, MonitorFromRect},
System::{
Diagnostics::Debug::WriteProcessMemory,
LibraryLoader::{GetModuleHandleW, GetProcAddress, LoadLibraryW},
@@ -67,10 +61,8 @@ use windows_sys::Win32::{
},
},
UI::WindowsAndMessaging::{
EnumWindows, GW_OWNER, GWL_STYLE, GetClientRect, GetForegroundWindow, GetWindow,
GetWindowLongPtrW, GetWindowRect, GetWindowThreadProcessId, HHOOK, IsWindow,
IsWindowVisible, PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, WH_GETMESSAGE,
WM_NULL, WS_BORDER, WS_MAXIMIZE,
GetClientRect, GetWindowRect, GetWindowThreadProcessId, HHOOK, IsWindow, IsWindowVisible,
PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, WH_GETMESSAGE, WM_NULL,
},
};
@@ -812,87 +804,6 @@ fn inject_via_set_windows_hook(
Ok(InstalledWindowsHook { hook, module })
}
fn parse_hwnd_source_id(source_id: &str) -> Option<HWND> {
let token = source_id.strip_prefix("window:")?.split(':').next()?;
let value = if let Some(hex) = token
.strip_prefix("0x")
.or_else(|| token.strip_prefix("0X"))
{
isize::from_str_radix(hex, 16).ok()?
} else {
token.parse::<isize>().ok()?
};
let hwnd = value as HWND;
if unsafe { IsWindow(hwnd) } != 0 {
Some(hwnd)
} else {
None
}
}
fn parse_screen_ordinal(source_id: &str) -> Option<usize> {
let token = source_id.strip_prefix("screen:")?.split(':').next()?;
token.parse::<usize>().ok()
}
pub(crate) fn resolve_game_capture_target(
source_id: &str,
source_kind: &str,
) -> Result<HWND, String> {
if let Some(hwnd) = parse_hwnd_source_id(source_id) {
return Ok(hwnd);
}
if source_kind == "game" || source_kind == "screen" {
let monitor = monitor_for_screen_source(source_id)?;
if let Some(hwnd) = find_fullscreen_window_on_monitor(monitor) {
return Ok(hwnd);
}
if let Some(hwnd) = find_foreground_fullscreen_window() {
return Ok(hwnd);
}
if let Some(hwnd) = find_fullscreen_window_on_any_monitor() {
return Ok(hwnd);
}
return Err("no fullscreen game window found on selected display".into());
}
Err(format!(
"invalid game capture source: {source_kind}:{source_id}"
))
}
fn monitor_for_screen_source(source_id: &str) -> Result<HMONITOR, String> {
let ordinal = parse_screen_ordinal(source_id).unwrap_or(0);
let monitors = enumerate_monitors();
monitors
.get(ordinal)
.copied()
.or_else(|| monitors.first().copied())
.ok_or_else(|| "no monitors available for game capture".to_string())
}
fn enumerate_monitors() -> Vec<HMONITOR> {
unsafe extern "system" fn enum_monitor(
monitor: HMONITOR,
_hdc: windows_sys::Win32::Graphics::Gdi::HDC,
_rect: *mut RECT,
param: LPARAM,
) -> i32 {
let monitors = &mut *(param as *mut Vec<HMONITOR>);
monitors.push(monitor);
1
}
let mut monitors = Vec::new();
unsafe {
EnumDisplayMonitors(
null_mut(),
null(),
Some(enum_monitor),
&mut monitors as *mut _ as LPARAM,
);
}
monitors
}
fn target_process_id(hwnd: HWND) -> Result<u32, String> {
let mut pid = 0u32;
unsafe {
@@ -1039,155 +950,6 @@ fn choose_shared_buffer_dimensions(
Some((buffer_width, buffer_height))
}
fn monitor_rect(monitor: HMONITOR) -> Option<RECT> {
let mut info = MONITORINFO {
cbSize: std::mem::size_of::<MONITORINFO>() as u32,
rcMonitor: RECT::default(),
rcWork: RECT::default(),
dwFlags: 0,
};
if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 {
return None;
}
Some(info.rcMonitor)
}
fn rect_matches_monitor(window_rect: &RECT, monitor_rect: &RECT) -> bool {
let tolerance = 2;
(window_rect.left - monitor_rect.left).abs() <= tolerance
&& (window_rect.top - monitor_rect.top).abs() <= tolerance
&& (window_rect.right - monitor_rect.right).abs() <= tolerance
&& (window_rect.bottom - monitor_rect.bottom).abs() <= tolerance
}
fn is_regular_maximized_window(hwnd: HWND) -> bool {
let style = unsafe { GetWindowLongPtrW(hwnd, GWL_STYLE) } as u32;
(style & WS_MAXIMIZE) != 0 && (style & WS_BORDER) != 0
}
fn is_fullscreen_window_on_monitor(hwnd: HWND, monitor: HMONITOR, monitor_rect: &RECT) -> bool {
if unsafe { IsWindowVisible(hwnd) } == 0 || !unsafe { GetWindow(hwnd, GW_OWNER) }.is_null() {
return false;
}
let mut pid = 0u32;
unsafe {
GetWindowThreadProcessId(hwnd, &mut pid);
}
if pid == 0 || pid == unsafe { GetCurrentProcessId() } {
return false;
}
if is_regular_maximized_window(hwnd) {
return false;
}
let mut rect = RECT::default();
if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 {
return false;
}
let window_monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) };
window_monitor == monitor && rect_matches_monitor(&rect, monitor_rect)
}
fn find_foreground_fullscreen_window() -> Option<HWND> {
let hwnd = unsafe { GetForegroundWindow() };
if hwnd.is_null() {
return None;
}
let mut rect = RECT::default();
if unsafe { GetWindowRect(hwnd, &mut rect) } == 0 {
return None;
}
let monitor = unsafe { MonitorFromRect(&rect, MONITOR_DEFAULTTONEAREST) };
let monitor_rect = monitor_rect(monitor)?;
if is_fullscreen_window_on_monitor(hwnd, monitor, &monitor_rect) {
Some(hwnd)
} else {
None
}
}
fn find_fullscreen_window_on_any_monitor() -> Option<HWND> {
for monitor in enumerate_monitors() {
if let Some(hwnd) = find_fullscreen_window_on_monitor(monitor) {
return Some(hwnd);
}
}
None
}
fn find_fullscreen_window_on_monitor(monitor: HMONITOR) -> Option<HWND> {
struct Search {
monitor: HMONITOR,
monitor_rect: RECT,
result: HWND,
own_pid: u32,
}
unsafe extern "system" fn enum_window(hwnd: HWND, param: LPARAM) -> i32 {
let search = &mut *(param as *mut Search);
let mut pid = 0u32;
GetWindowThreadProcessId(hwnd, &mut pid);
if pid != 0
&& pid != search.own_pid
&& is_fullscreen_window_on_monitor(hwnd, search.monitor, &search.monitor_rect)
{
search.result = hwnd;
return 0;
}
1
}
let monitor_rect = monitor_rect(monitor)?;
let mut search = Search {
monitor,
monitor_rect,
result: null_mut(),
own_pid: unsafe { GetCurrentProcessId() },
};
unsafe {
let _ = EnumWindows(Some(enum_window), &mut search as *mut _ as LPARAM);
}
if search.result.is_null() {
None
} else {
Some(search.result)
}
}
pub(crate) fn create_shared_texture_device(
adapter: Option<&IDXGIAdapter>,
) -> Result<(ID3D11Device, ID3D11DeviceContext), String> {
let mut device = None;
let mut context = None;
let driver_type = if adapter.is_some() {
D3D_DRIVER_TYPE_UNKNOWN
} else {
D3D_DRIVER_TYPE_HARDWARE
};
unsafe {
D3D11CreateDevice(
adapter,
driver_type,
HMODULE::default(),
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
Some(&mut context),
)
.map_err(|e| format!("D3D11CreateDevice for shared game texture: {e}"))?;
}
let device = device.ok_or("D3D11 shared texture device was None")?;
let context = context.ok_or("D3D11 shared texture context was None")?;
set_gpu_thread_priority(&device);
Ok((device, context))
}
fn set_gpu_thread_priority(device: &ID3D11Device) {
if let Ok(dxgi_device) = device.cast::<IDXGIDevice>() {
let _ = unsafe { dxgi_device.SetGPUThreadPriority(7) };
}
}
const SHARED_TEXTURE_ADAPTER_LIMIT: u32 = 16;
fn shared_texture_adapter_candidates() -> Vec<Option<IDXGIAdapter>> {
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more