mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc230926a4 | ||
|
|
026ace6747 | ||
|
|
374db9ed2b | ||
|
|
5ee59c4675 | ||
|
|
33605171a8 | ||
|
|
89fac5b088 | ||
|
|
4a34b942b7 | ||
|
|
fc3065ebe4 | ||
|
|
23493b4ac2 | ||
|
|
13344096b7 | ||
|
|
a800430997 | ||
|
|
509562e6da | ||
|
|
88d85919f1 | ||
|
|
154e223284 | ||
|
|
58732f7770 | ||
|
|
cb4c847d41 | ||
|
|
d3976e33f8 | ||
|
|
8e17970632 | ||
|
|
c0048504db | ||
|
|
5015452280 | ||
|
|
c504b68354 | ||
|
|
5d2e5932a4 | ||
|
|
cf1a7d7a8a | ||
|
|
14a935db81 | ||
|
|
f98a40062a | ||
|
|
f7ebc1492c | ||
|
|
da3922586a | ||
|
|
28184f8d4d | ||
|
|
a4e7522ca0 | ||
|
|
59b3d30323 | ||
|
|
53cac0b614 |
No files matched your search
+10
-32
@@ -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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Vendored
+2
-7
@@ -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}
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
+46
-2
@@ -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>
|
||||
);
|
||||
|
||||
+101
-33
@@ -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;
|
||||
}
|
||||
Vendored
-1
@@ -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;
|
||||
|
||||
@@ -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:"];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in new issue
Block a user