mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5474be3efa | ||
|
|
9a54bbba2d | ||
|
|
5a0110ccc8 | ||
|
|
d032d577bf | ||
|
|
243954c9c5 | ||
|
|
ba1be73389 | ||
|
|
af3ad02962 | ||
|
|
53ddca725e | ||
|
|
094fb0d1c8 | ||
|
|
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 |
+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,29 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_payload
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step prepare_release_assets
|
||||
|
||||
- name: Publish GitHub release descriptor
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_descriptor
|
||||
|
||||
- name: Upload payload to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_payload
|
||||
|
||||
- name: Upload GitHub release asset handoff
|
||||
if: needs.meta.outputs.test_build != 'true'
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step upload_release_assets
|
||||
|
||||
- name: Build summary
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
@@ -577,9 +596,17 @@ jobs:
|
||||
- upload
|
||||
runs-on: ubuntu-24.04-arm
|
||||
environment: desktop-releases
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -590,6 +617,12 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
|
||||
- name: Download GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step download_release_assets
|
||||
|
||||
- name: Create token
|
||||
id: create-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
@@ -614,8 +647,16 @@ jobs:
|
||||
--build-version "${VERSION}"
|
||||
--source-sha "${SOURCE_SHA}"
|
||||
--previous-sha "${RELEASE_BASELINE_SHA}"
|
||||
--asset-dir release_assets
|
||||
)
|
||||
if [[ "${CHANNEL}" == "canary" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
|
||||
|
||||
- name: Publish GitHub release readiness marker
|
||||
env:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
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",
|
||||
|
||||
@@ -23,6 +23,7 @@ type CacheEntry = {
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const CACHE_MAX_ENTRIES = 10_000;
|
||||
const geoipCache = new Map<string, CacheEntry>();
|
||||
|
||||
let maxmindReader: Reader<CityResponse> | null = null;
|
||||
@@ -85,6 +86,32 @@ function isAsciiUpperAlpha2(value: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function getCachedGeoipResult(cacheKey: string, normalizedIp: string): GeoipResult | null {
|
||||
const cached = geoipCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() >= cached.expiresAt) {
|
||||
geoipCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
geoipCache.delete(cacheKey);
|
||||
geoipCache.set(cacheKey, cached);
|
||||
return {...cached.result, normalizedIp};
|
||||
}
|
||||
|
||||
function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
|
||||
geoipCache.delete(cacheKey);
|
||||
if (geoipCache.size >= CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = geoipCache.keys().next().value;
|
||||
if (oldestKey === undefined) {
|
||||
throw new Error('GeoIP cache reached capacity without an entry to evict');
|
||||
}
|
||||
geoipCache.delete(oldestKey);
|
||||
}
|
||||
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
|
||||
}
|
||||
|
||||
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
try {
|
||||
const reader = await ensureReader(dbPath);
|
||||
@@ -108,14 +135,13 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
|
||||
}
|
||||
|
||||
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
const now = Date.now();
|
||||
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
|
||||
const cached = geoipCache.get(cacheKey);
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return {...cached.result, normalizedIp: clean};
|
||||
const cached = getCachedGeoipResult(cacheKey, clean);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const result = await lookupMaxmind(clean, dbPath);
|
||||
geoipCache.set(cacheKey, {result, expiresAt: now + CACHE_TTL_MS});
|
||||
setCachedGeoipResult(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -65,6 +85,18 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
|
||||
return Array.from(normalized);
|
||||
}
|
||||
|
||||
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const value of values) {
|
||||
const countryCode = value.trim().toUpperCase();
|
||||
if (!/^[A-Z]{2}$/u.test(countryCode)) {
|
||||
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
|
||||
}
|
||||
normalized.add(countryCode);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapPushProviderApps(
|
||||
apps:
|
||||
| Array<{
|
||||
@@ -122,6 +154,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
nodeEnv: master.env === 'test' ? 'development' : master.env,
|
||||
port: master.services.api.port,
|
||||
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
|
||||
desktopGitHubRedirectCountries: normalizeCountryCodes(
|
||||
master.services.api.desktop_github_redirect_countries,
|
||||
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
|
||||
),
|
||||
cassandra: {
|
||||
hosts: cassandraSource?.hosts.join(',') ?? '',
|
||||
port: cassandraSource?.port ?? 9042,
|
||||
@@ -254,6 +290,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
|
||||
fromEmail: master.integrations.email.from_email,
|
||||
fromName: master.integrations.email.from_name,
|
||||
appBaseUrl: resolveEmailAppBaseUrl(master),
|
||||
smtp: master.integrations.email.smtp
|
||||
? {
|
||||
host: master.integrations.email.smtp.host,
|
||||
@@ -306,6 +343,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
apiSecret: master.integrations.voice.api_secret,
|
||||
webhookUrl: master.integrations.voice.webhook_url,
|
||||
url: master.integrations.voice.url,
|
||||
internalUrl: master.integrations.voice.internal_url,
|
||||
defaultRegion: master.integrations.voice.default_region,
|
||||
},
|
||||
stripe: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -359,13 +359,36 @@ export async function login(
|
||||
|
||||
const MFA_TICKET_MAX_ATTEMPTS = 5;
|
||||
const MFA_USER_MAX_ATTEMPTS = 10;
|
||||
const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes');
|
||||
|
||||
async function consumeMfaAttempt(
|
||||
ctx: ApiContext,
|
||||
{userId, ticket, field}: {userId: string; ticket: string; field: string},
|
||||
): Promise<void> {
|
||||
const {cache, rateLimit} = ctx.services;
|
||||
const userLimit = await rateLimit.checkLimit({
|
||||
identifier: `mfa:user:${userId}`,
|
||||
maxAttempts: MFA_USER_MAX_ATTEMPTS,
|
||||
windowMs: ms('15 minutes'),
|
||||
});
|
||||
if (!userLimit.allowed) {
|
||||
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
const ticketLimit = await rateLimit.checkLimit({
|
||||
identifier: `mfa:ticket:${ticket}`,
|
||||
maxAttempts: MFA_TICKET_MAX_ATTEMPTS,
|
||||
windowMs: ms('5 minutes'),
|
||||
});
|
||||
if (!ticketLimit.allowed) {
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginMfaTotp(
|
||||
ctx: ApiContext,
|
||||
{code, ticket, request}: LoginMfaTotpParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache} = ctx.services;
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -378,32 +401,19 @@ export async function loginMfaTotp(
|
||||
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
|
||||
}
|
||||
const userAttemptsKey = `mfa-user-attempts:${user.id}`;
|
||||
const userAttempts = (await cache.get<number>(userAttemptsKey)) ?? 0;
|
||||
if (userAttempts >= MFA_USER_MAX_ATTEMPTS) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
|
||||
const isValid = await AuthMfa.verifyMfaCode(ctx, {
|
||||
userId: user.id,
|
||||
mfaSecret: user.totpSecret,
|
||||
code,
|
||||
allowBackup: true,
|
||||
});
|
||||
const attemptsKey = `mfa-ticket-attempts:${ticket}`;
|
||||
if (!isValid) {
|
||||
await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW);
|
||||
const attempts = ((await cache.get<number>(attemptsKey)) ?? 0) + 1;
|
||||
if (attempts >= MFA_TICKET_MAX_ATTEMPTS) {
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await cache.delete(attemptsKey);
|
||||
} else {
|
||||
await cache.set(attemptsKey, attempts, seconds('5 minutes'));
|
||||
}
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
|
||||
}
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await cache.delete(attemptsKey);
|
||||
await cache.delete(userAttemptsKey);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
@@ -415,7 +425,7 @@ export async function loginMfaWebAuthn(
|
||||
ctx: ApiContext,
|
||||
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
|
||||
): Promise<LoginTokenResult> {
|
||||
const {users, cache} = ctx.services;
|
||||
const {users, cache, rateLimit} = ctx.services;
|
||||
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
|
||||
if (!userId) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
|
||||
@@ -425,8 +435,11 @@ export async function loginMfaWebAuthn(
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
|
||||
await rateLimit.resetLimit(`mfa:user:${user.id}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
|
||||
@@ -267,7 +267,7 @@ export async function resetPassword(
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await users.deleteAllAuthSessions(user.id);
|
||||
await AuthSession.terminateAllUserSessions(ctx, user.id);
|
||||
await users.deletePasswordResetToken(data.token);
|
||||
const hasMfa =
|
||||
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
|
||||
|
||||
@@ -311,7 +311,7 @@ describe('Auth registration', () => {
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
})
|
||||
.expect(403, 'TOR_BLOCKED')
|
||||
.expect(403, 'GLOBAL_IP_BANNED')
|
||||
.execute();
|
||||
} finally {
|
||||
torExitListCache.clearForTesting();
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
|
||||
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
|
||||
import {SYSTEM_USER_ID} from '../../constants/Core';
|
||||
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
||||
import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement';
|
||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {Channel} from '../../models/Channel';
|
||||
import type {GuildMember} from '../../models/GuildMember';
|
||||
@@ -176,9 +177,15 @@ export abstract class BaseChannelAuthService {
|
||||
const hasPermission = async (permission: bigint): Promise<boolean> => {
|
||||
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
|
||||
};
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({
|
||||
userRepository: this.userRepository,
|
||||
guildData: guildDataResult!,
|
||||
userId,
|
||||
});
|
||||
const checkPermission = async (permission: bigint): Promise<void> => {
|
||||
const allowed = await hasPermission(permission);
|
||||
if (!allowed) throw new MissingPermissionsError();
|
||||
enforceGuildMfa(permission);
|
||||
};
|
||||
await checkPermission(Permissions.VIEW_CHANNEL);
|
||||
const parentCategory = await this.getParentCategoryContentWarningView({
|
||||
|
||||
@@ -186,12 +186,7 @@ export class ChannelOperationsService {
|
||||
let permissionOverwrites = channel.permissionOverwrites;
|
||||
if (data.permission_overwrites !== undefined) {
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
await checkPermission(Permissions.MANAGE_ROLES);
|
||||
const isOwner = guild.owner_id === userId.toString();
|
||||
const channelPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId,
|
||||
@@ -205,6 +200,17 @@ export class ChannelOperationsService {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
const nextDeny = new Map<RoleID | UserID, bigint>();
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
|
||||
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
|
||||
}
|
||||
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
|
||||
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
|
||||
if ((removedDeny & ~channelPermissions) !== 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
}
|
||||
}
|
||||
permissionOverwrites = new Map();
|
||||
for (const overwrite of data.permission_overwrites ?? []) {
|
||||
@@ -590,6 +596,7 @@ export class ChannelOperationsService {
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
@@ -615,6 +622,8 @@ export class ChannelOperationsService {
|
||||
const sanitizedDeny = protectedBits.deny;
|
||||
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
||||
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
|
||||
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
const previousPermissionOverwrites = channel.permissionOverwrites;
|
||||
const overwrites = new Map(channel.permissionOverwrites ?? []);
|
||||
overwrites.set(
|
||||
@@ -697,6 +706,7 @@ export class ChannelOperationsService {
|
||||
const canManageRoles = await this.gatewayService.checkPermission({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.MANAGE_ROLES,
|
||||
});
|
||||
if (!canManageRoles) throw new MissingPermissionsError();
|
||||
@@ -705,6 +715,15 @@ export class ChannelOperationsService {
|
||||
const removedRole = overwrites.get(createRoleID(params.overwriteId));
|
||||
const removedUser = overwrites.get(createUserID(params.overwriteId));
|
||||
const removed = removedRole ?? removedUser;
|
||||
if (removed) {
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: channel.guildId,
|
||||
userId: params.userId,
|
||||
channelId: channel.id,
|
||||
});
|
||||
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
|
||||
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
}
|
||||
overwrites.delete(createRoleID(params.overwriteId));
|
||||
overwrites.delete(createUserID(params.overwriteId));
|
||||
const updated = await this.channelRepository.channelData.upsert({
|
||||
|
||||
@@ -1191,7 +1191,7 @@ export class MessageSendService {
|
||||
await this.deps.mentionService.handleMentionTasks({
|
||||
guildId: channel.guildId,
|
||||
message,
|
||||
authorId: createUserID(0n),
|
||||
authorId: createUserID(BigInt(webhook.id)),
|
||||
mentionHere: mentionData?.mentionHere ?? false,
|
||||
});
|
||||
await this.deps.dispatchService.dispatchMessageCreate({
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface APIConfig {
|
||||
nodeEnv: 'development' | 'production';
|
||||
port: number;
|
||||
ipBanExemptIps: Array<string>;
|
||||
desktopGitHubRedirectCountries: ReadonlySet<string>;
|
||||
cassandra: {
|
||||
hosts: string;
|
||||
port: number;
|
||||
@@ -158,6 +159,7 @@ export interface APIConfig {
|
||||
webhookSecret?: string;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
appBaseUrl: string;
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -204,6 +206,7 @@ export interface APIConfig {
|
||||
apiSecret?: string;
|
||||
webhookUrl?: string;
|
||||
url?: string;
|
||||
internalUrl?: string;
|
||||
defaultRegion?: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -19,6 +19,8 @@ interface PageState {
|
||||
|
||||
const VALUE_SEPARATOR = '\u001f';
|
||||
const ENCODED_TYPE_KEY = '__fluxer_type';
|
||||
const POSTGRES_KV_SCHEMA_LOCK_NAMESPACE = 0x46584b56;
|
||||
const POSTGRES_KV_SCHEMA_LOCK_TIMEOUT = '120s';
|
||||
|
||||
function normalizeCql(cql: string): string {
|
||||
return cql.replace(/\s+/g, ' ').trim();
|
||||
@@ -354,8 +356,13 @@ function parseEqWhere(whereSql: string, cql: string): ReadonlyArray<EqWhereExpr>
|
||||
}
|
||||
|
||||
export async function ensurePostgresKvSchema(client: IPostgresClient): Promise<void> {
|
||||
const table = quoteIdentifier(client.kvTable());
|
||||
await client.query(`
|
||||
const kvTable = client.kvTable();
|
||||
const table = quoteIdentifier(kvTable);
|
||||
await client.transaction(async (db) => {
|
||||
await db.query("SELECT set_config('statement_timeout', $1, true)", [POSTGRES_KV_SCHEMA_LOCK_TIMEOUT]);
|
||||
await db.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [POSTGRES_KV_SCHEMA_LOCK_NAMESPACE, kvTable]);
|
||||
await db.query("SELECT set_config('statement_timeout', '0', true)");
|
||||
await db.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${table} (
|
||||
table_name text NOT NULL,
|
||||
partition_key text NOT NULL,
|
||||
@@ -365,28 +372,29 @@ CREATE TABLE IF NOT EXISTS ${table} (
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (table_name, row_key)
|
||||
)`);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_partition_row_idx`)} ON ${table} (table_name, partition_key, row_key)`,
|
||||
);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_row_key_c_idx`)} ON ${table} (table_name, row_key COLLATE "C")`,
|
||||
);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_expires_idx`)} ON ${table} (expires_at) WHERE expires_at IS NOT NULL`,
|
||||
);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_messages_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'messages'`,
|
||||
);
|
||||
await client.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
|
||||
);
|
||||
await client.query(`
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_partition_row_idx`)} ON ${table} (table_name, partition_key, row_key)`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_row_key_c_idx`)} ON ${table} (table_name, row_key COLLATE "C")`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_expires_idx`)} ON ${table} (expires_at) WHERE expires_at IS NOT NULL`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_messages_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'messages'`,
|
||||
);
|
||||
await db.query(
|
||||
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
|
||||
);
|
||||
await db.query(`
|
||||
UPDATE ${table}
|
||||
SET partition_key = split_part(row_key, chr(31), 1) || chr(31) || split_part(row_key, chr(31), 2)
|
||||
WHERE table_name = 'messages'
|
||||
AND partition_key = row_key
|
||||
AND split_part(row_key, chr(31), 3) <> ''`);
|
||||
await client.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${client.kvTable()}_partition_idx`)}`);
|
||||
await db.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${kvTable}_partition_idx`)}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function pruneExpiredPostgresKvRows(client: IPostgresClient, batchSize = 5000): Promise<number> {
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface GiftCodeRow {
|
||||
stripe_payment_intent_id: Nullish<string>;
|
||||
visionary_sequence_number: Nullish<number>;
|
||||
checkout_session_id: Nullish<string>;
|
||||
revoked_at?: Nullish<Date>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
@@ -103,6 +104,7 @@ export const GIFT_CODE_COLUMNS = [
|
||||
'stripe_payment_intent_id',
|
||||
'visionary_sequence_number',
|
||||
'checkout_session_id',
|
||||
'revoked_at',
|
||||
'version',
|
||||
] as const;
|
||||
export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {DesktopArch, DesktopChannel, DesktopPlatform} from '@fluxer/schema/src/domains/download/DownloadSchemas';
|
||||
import {isJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const MIN_RELEASE_ROUTE_COUNT = 28;
|
||||
const MAX_RELEASE_ROUTE_COUNT = 128;
|
||||
const MIN_RELEASE_ASSET_COUNT = 24;
|
||||
|
||||
interface DesktopReleaseAsset {
|
||||
storage_key: string;
|
||||
release_asset: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface DesktopReleaseDescriptor {
|
||||
schema_version: 1;
|
||||
channel: DesktopChannel;
|
||||
version: string;
|
||||
release_tag: string;
|
||||
source_sha: string;
|
||||
assets: Array<DesktopReleaseAsset>;
|
||||
}
|
||||
|
||||
interface DesktopReleaseReadiness {
|
||||
schema_version: 1;
|
||||
channel: DesktopChannel;
|
||||
version: string;
|
||||
release_tag: string;
|
||||
source_sha: string;
|
||||
descriptor_sha256: string;
|
||||
}
|
||||
|
||||
interface DesktopArtifactScope {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
}
|
||||
|
||||
export function parseDesktopArtifactScope(key: string): DesktopArtifactScope | null {
|
||||
const segments = key.split('/');
|
||||
if (segments.length !== 5 || segments[0] !== DESKTOP_BUCKET_PREFIX || segments[4].length === 0) {
|
||||
return null;
|
||||
}
|
||||
const [, channel, plat, arch] = segments;
|
||||
if (
|
||||
(channel !== 'stable' && channel !== 'canary') ||
|
||||
(plat !== 'win32' && plat !== 'darwin' && plat !== 'linux') ||
|
||||
(arch !== 'x64' && arch !== 'arm64')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {channel, plat, arch};
|
||||
}
|
||||
|
||||
function parseDesktopReleaseAsset(value: unknown): DesktopReleaseAsset | null {
|
||||
if (
|
||||
!isJsonRecord(value) ||
|
||||
typeof value.storage_key !== 'string' ||
|
||||
typeof value.release_asset !== 'string' ||
|
||||
typeof value.sha256 !== 'string' ||
|
||||
typeof value.size !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!/^desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64)\/[A-Za-z0-9._-]+$/u.test(value.storage_key) ||
|
||||
!/^[A-Za-z0-9._-]+$/u.test(value.release_asset) ||
|
||||
!/^[a-f0-9]{64}$/u.test(value.sha256) ||
|
||||
!Number.isSafeInteger(value.size) ||
|
||||
value.size <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
storage_key: value.storage_key,
|
||||
release_asset: value.release_asset,
|
||||
sha256: value.sha256,
|
||||
size: value.size,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDescriptor | null {
|
||||
if (
|
||||
!isJsonRecord(value) ||
|
||||
value.schema_version !== 1 ||
|
||||
(value.channel !== 'stable' && value.channel !== 'canary') ||
|
||||
typeof value.version !== 'string' ||
|
||||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
|
||||
typeof value.release_tag !== 'string' ||
|
||||
typeof value.source_sha !== 'string' ||
|
||||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
|
||||
!Array.isArray(value.assets) ||
|
||||
value.assets.length < MIN_RELEASE_ROUTE_COUNT ||
|
||||
value.assets.length > MAX_RELEASE_ROUTE_COUNT
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expectedTag = `fluxer-desktop-${value.channel}@${value.version}`;
|
||||
const expectedStoragePrefix = `desktop/${value.channel}/`;
|
||||
const expectedReleasePrefix = `${value.channel === 'canary' ? 'Fluxer-Canary' : 'Fluxer'}-${value.version}-`;
|
||||
const descriptorName = `${expectedReleasePrefix}release-manifest.json`;
|
||||
if (value.release_tag !== expectedTag) {
|
||||
return null;
|
||||
}
|
||||
const storageKeys = new Set<string>();
|
||||
const routeCounts = new Map<string, number>();
|
||||
const releaseAssets = new Map<string, {sha256: string; size: number}>();
|
||||
const releaseAssetNames = new Map<string, string>([[descriptorName.toLowerCase(), descriptorName]]);
|
||||
const assets: Array<DesktopReleaseAsset> = [];
|
||||
for (const rawAsset of value.assets) {
|
||||
const asset = parseDesktopReleaseAsset(rawAsset);
|
||||
if (
|
||||
!asset ||
|
||||
!asset.storage_key.startsWith(expectedStoragePrefix) ||
|
||||
!asset.release_asset.startsWith(expectedReleasePrefix) ||
|
||||
storageKeys.has(asset.storage_key)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
storageKeys.add(asset.storage_key);
|
||||
const [, , platform, arch, filename] = asset.storage_key.split('/');
|
||||
const platformToken = platform === 'win32' ? 'win' : platform === 'darwin' ? 'mac' : 'linux';
|
||||
const releaseFilename =
|
||||
platform === 'darwin' && filename.toLowerCase() === 'releases.json' ? 'releases.json' : filename;
|
||||
const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix)
|
||||
? filename
|
||||
: `${expectedReleasePrefix}${platformToken}-${arch}-${releaseFilename}`;
|
||||
if (
|
||||
asset.release_asset !== expectedReleaseAsset ||
|
||||
asset.release_asset.toLowerCase() === descriptorName.toLowerCase()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const caseFoldedReleaseAsset = asset.release_asset.toLowerCase();
|
||||
const existingReleaseAssetName = releaseAssetNames.get(caseFoldedReleaseAsset);
|
||||
if (existingReleaseAssetName && existingReleaseAssetName !== asset.release_asset) {
|
||||
return null;
|
||||
}
|
||||
releaseAssetNames.set(caseFoldedReleaseAsset, asset.release_asset);
|
||||
const scope = `${platform}/${arch}`;
|
||||
routeCounts.set(scope, (routeCounts.get(scope) ?? 0) + 1);
|
||||
const existing = releaseAssets.get(asset.release_asset);
|
||||
if (existing && (existing.sha256 !== asset.sha256 || existing.size !== asset.size)) {
|
||||
return null;
|
||||
}
|
||||
releaseAssets.set(asset.release_asset, {sha256: asset.sha256, size: asset.size});
|
||||
assets.push(asset);
|
||||
}
|
||||
if (releaseAssets.size < MIN_RELEASE_ASSET_COUNT || releaseAssets.size > MAX_RELEASE_ROUTE_COUNT) {
|
||||
return null;
|
||||
}
|
||||
const expectedRouteCounts = new Map([
|
||||
['darwin/arm64', 4],
|
||||
['darwin/x64', 4],
|
||||
['linux/arm64', 4],
|
||||
['linux/x64', 4],
|
||||
['win32/arm64', 6],
|
||||
['win32/x64', 6],
|
||||
]);
|
||||
if (
|
||||
routeCounts.size !== expectedRouteCounts.size ||
|
||||
Array.from(expectedRouteCounts).some(([scope, count]) => (routeCounts.get(scope) ?? 0) < count)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
release_tag: value.release_tag,
|
||||
source_sha: value.source_sha,
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDesktopReleaseReadiness(value: unknown): DesktopReleaseReadiness | null {
|
||||
if (
|
||||
!isJsonRecord(value) ||
|
||||
value.schema_version !== 1 ||
|
||||
(value.channel !== 'stable' && value.channel !== 'canary') ||
|
||||
typeof value.version !== 'string' ||
|
||||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
|
||||
typeof value.release_tag !== 'string' ||
|
||||
typeof value.source_sha !== 'string' ||
|
||||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
|
||||
typeof value.descriptor_sha256 !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/u.test(value.descriptor_sha256)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
schema_version: 1,
|
||||
channel: value.channel,
|
||||
version: value.version,
|
||||
release_tag: value.release_tag,
|
||||
source_sha: value.source_sha,
|
||||
descriptor_sha256: value.descriptor_sha256,
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {Config} from '../Config';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {Validator} from '../Validator';
|
||||
import {resolveArtifactRoute} from './DownloadRouting';
|
||||
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
|
||||
import {
|
||||
DESKTOP_REDIRECT_PREFIX,
|
||||
@@ -29,6 +30,17 @@ function artifactFilename(key: string, filenameOverride?: string): string {
|
||||
return filenameOverride ?? key.split('/').pop() ?? 'download';
|
||||
}
|
||||
|
||||
function artifactRedirectResponse(location: string, cacheControl = 'no-store'): Response {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
Location: location,
|
||||
'Cache-Control': cacheControl,
|
||||
'Accept-Ranges': 'bytes',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function setCommonArtifactHeaders(
|
||||
headers: Headers,
|
||||
key: string,
|
||||
@@ -87,8 +99,12 @@ async function streamArtifactResponse(
|
||||
cacheControl: string,
|
||||
filenameOverride?: string,
|
||||
): Promise<Response> {
|
||||
const route = await resolveArtifactRoute({request: ctx.req.raw, downloadService, key, cacheControl});
|
||||
if (route.kind === 'redirect') {
|
||||
return artifactRedirectResponse(route.location, route.cacheControl);
|
||||
}
|
||||
if (ctx.req.method === 'HEAD') {
|
||||
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
|
||||
return headArtifactResponse(ctx, downloadService, key, route.cacheControl, filenameOverride);
|
||||
}
|
||||
if (downloadService.isPresignedDownloadEnabled()) {
|
||||
const location = await downloadService.getPresignedDownloadRedirect({
|
||||
@@ -99,14 +115,7 @@ async function streamArtifactResponse(
|
||||
if (!location) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
Location: location,
|
||||
'Cache-Control': 'no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
}),
|
||||
});
|
||||
return artifactRedirectResponse(location);
|
||||
}
|
||||
const range = ctx.req.header('range') ?? undefined;
|
||||
let result: DownloadStreamResult | null;
|
||||
@@ -117,7 +126,7 @@ async function streamArtifactResponse(
|
||||
const headers = new Headers();
|
||||
headers.set('Accept-Ranges', 'bytes');
|
||||
headers.set('Content-Range', `bytes */${error.totalSize}`);
|
||||
headers.set('Cache-Control', cacheControl);
|
||||
headers.set('Cache-Control', route.cacheControl);
|
||||
return new Response(null, {status: 416, headers});
|
||||
}
|
||||
throw error;
|
||||
@@ -129,7 +138,7 @@ async function streamArtifactResponse(
|
||||
setCommonArtifactHeaders(
|
||||
headers,
|
||||
key,
|
||||
cacheControl,
|
||||
route.cacheControl,
|
||||
filenameOverride,
|
||||
result.contentType,
|
||||
result.contentDisposition,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {parseDesktopArtifactScope} from './DesktopReleaseContract';
|
||||
import type {DownloadService, GitHubDesktopReleaseResolution} from './DownloadService';
|
||||
|
||||
const COUNTRY_DEPENDENT_CACHE_CONTROL = 'private, no-store';
|
||||
|
||||
type ArtifactRoute =
|
||||
| {kind: 'storage'; cacheControl: string}
|
||||
| {kind: 'redirect'; cacheControl: string; location: string};
|
||||
|
||||
export async function resolveArtifactRoute(params: {
|
||||
request: Request;
|
||||
downloadService: DownloadService;
|
||||
key: string;
|
||||
cacheControl: string;
|
||||
}): Promise<ArtifactRoute> {
|
||||
if (Config.instance.selfHosted) {
|
||||
return {kind: 'storage', cacheControl: params.cacheControl};
|
||||
}
|
||||
if (Config.desktopGitHubRedirectCountries.size === 0) {
|
||||
return {kind: 'storage', cacheControl: params.cacheControl};
|
||||
}
|
||||
if (!parseDesktopArtifactScope(params.key)) {
|
||||
return {kind: 'storage', cacheControl: params.cacheControl};
|
||||
}
|
||||
const geoip = await lookupGeoip(params.request);
|
||||
const countryCode = geoip.countryCode?.trim().toUpperCase();
|
||||
if (!countryCode || !Config.desktopGitHubRedirectCountries.has(countryCode)) {
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
let release: GitHubDesktopReleaseResolution;
|
||||
try {
|
||||
release = await params.downloadService.resolveGitHubDesktopRelease(params.key);
|
||||
} catch (error) {
|
||||
Logger.error({error, key: params.key}, 'Failed to resolve GitHub desktop download route');
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
if (release.kind === 'not_current') {
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
if (release.kind === 'ready') {
|
||||
return {
|
||||
kind: 'redirect',
|
||||
cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL,
|
||||
location: release.location,
|
||||
};
|
||||
}
|
||||
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {posix} from 'node:path';
|
||||
import {Readable} from 'node:stream';
|
||||
import {S3ServiceException} from '@aws-sdk/client-s3';
|
||||
@@ -12,6 +13,11 @@ import type {
|
||||
import {Config} from '../Config';
|
||||
import type {IStorageService} from '../infrastructure/IStorageService';
|
||||
import {isJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
|
||||
import {
|
||||
parseDesktopArtifactScope,
|
||||
parseDesktopReleaseDescriptor,
|
||||
parseDesktopReleaseReadiness,
|
||||
} from './DesktopReleaseContract';
|
||||
|
||||
export const DOWNLOAD_PREFIX = '/dl';
|
||||
export const DESKTOP_REDIRECT_PREFIX = `${DOWNLOAD_PREFIX}/desktop`;
|
||||
@@ -46,6 +52,8 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
|
||||
const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app';
|
||||
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download';
|
||||
const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases';
|
||||
|
||||
function desktopBucketPrefix(test?: boolean): string {
|
||||
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
|
||||
@@ -176,9 +184,70 @@ interface ManifestFilenameResolutionParams extends LatestFilenameLookupParams {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export type GitHubDesktopReleaseResolution =
|
||||
| {kind: 'not_current'}
|
||||
| {kind: 'awaiting_release'}
|
||||
| {kind: 'ready'; location: string};
|
||||
|
||||
export class DownloadService {
|
||||
constructor(private readonly storageService: IStorageService) {}
|
||||
|
||||
async resolveGitHubDesktopRelease(key: string): Promise<GitHubDesktopReleaseResolution> {
|
||||
const scope = parseDesktopArtifactScope(key);
|
||||
if (!scope) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const manifestKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${scope.plat}/${scope.arch}/manifest.json`;
|
||||
const manifest = await this.readOptionalJsonObjectFromStorage(manifestKey);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
manifest.channel !== scope.channel ||
|
||||
manifest.platform !== scope.plat ||
|
||||
manifest.arch !== scope.arch
|
||||
) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
|
||||
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
|
||||
if (descriptorText == null) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
|
||||
if (
|
||||
!descriptor ||
|
||||
descriptor.channel !== scope.channel ||
|
||||
descriptor.version !== manifest.version ||
|
||||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
|
||||
}
|
||||
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
|
||||
if (!releaseAsset) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
|
||||
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
|
||||
if (marker == null) {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(marker);
|
||||
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== descriptor.channel ||
|
||||
readiness.version !== descriptor.version ||
|
||||
readiness.release_tag !== descriptor.release_tag ||
|
||||
readiness.source_sha !== descriptor.source_sha ||
|
||||
readiness.descriptor_sha256 !== descriptorSha256
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
|
||||
}
|
||||
return {
|
||||
kind: 'ready',
|
||||
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveLatestDesktopKey(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
@@ -659,6 +728,11 @@ export class DownloadService {
|
||||
}
|
||||
|
||||
private async readJsonObjectFromStorage(key: string): Promise<unknown | null> {
|
||||
const text = await this.readTextFromStorage(key);
|
||||
return text == null ? null : parseJsonUnknown(text);
|
||||
}
|
||||
|
||||
private async readTextFromStorage(key: string): Promise<string | null> {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key,
|
||||
@@ -667,8 +741,29 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return parseJsonUnknown(text);
|
||||
return new Response(body as ReadableStream).text();
|
||||
}
|
||||
|
||||
private async readOptionalJsonObjectFromStorage(key: string): Promise<unknown | null> {
|
||||
try {
|
||||
return await this.readJsonObjectFromStorage(key);
|
||||
} catch (error) {
|
||||
if (isStorageNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async readOptionalTextFromStorage(key: string): Promise<string | null> {
|
||||
try {
|
||||
return await this.readTextFromStorage(key);
|
||||
} catch (error) {
|
||||
if (isStorageNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isValidSha256(value: string): boolean {
|
||||
@@ -774,9 +869,11 @@ export class DownloadService {
|
||||
const {ext, arch: archMap} = mapping;
|
||||
const filenames = new Set<string>();
|
||||
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
|
||||
const modernFilename = this.buildModernArtifactFilename(channel, version, plat, archSuffix, ext);
|
||||
if (modernFilename) {
|
||||
filenames.add(modernFilename);
|
||||
for (const productName of this.getModernProductNames(channel)) {
|
||||
filenames.add(`${productName}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
|
||||
if (format === 'portable') {
|
||||
filenames.add(`${productName}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
|
||||
}
|
||||
}
|
||||
if (format === 'setup') {
|
||||
filenames.add(`fluxer-${channel}-${version}-${archSuffix}-setup${ext}`);
|
||||
@@ -784,9 +881,6 @@ export class DownloadService {
|
||||
filenames.add(`fluxer-${version}-${archSuffix}-setup${ext}`);
|
||||
filenames.add(`Fluxer-${version}-${archSuffix}-Setup${ext}`);
|
||||
} else if (format === 'portable') {
|
||||
filenames.add(
|
||||
`${this.getModernProductName(channel)}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`,
|
||||
);
|
||||
filenames.add(`fluxer-${channel}-${version}-portable-${archSuffix}${ext}`);
|
||||
filenames.add(`Fluxer-${version}-portable-${archSuffix}${ext}`);
|
||||
} else {
|
||||
@@ -816,7 +910,6 @@ export class DownloadService {
|
||||
}
|
||||
const {ext, arch: archMap} = mapping;
|
||||
const escapedExt = this.escapeRegex(ext);
|
||||
const escapedModernFilenamePrefix = this.escapeRegex(this.getModernProductName(channel));
|
||||
const modernPlatformToken = MODERN_PLATFORM_TOKENS[plat];
|
||||
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
|
||||
const patterns = [
|
||||
@@ -828,18 +921,23 @@ export class DownloadService {
|
||||
`^[Ff]luxer-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(archSuffix)}(?:-[Ss]etup)?${escapedExt}$`,
|
||||
'u',
|
||||
),
|
||||
new RegExp(
|
||||
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
'iu',
|
||||
),
|
||||
];
|
||||
if (format === 'portable') {
|
||||
for (const productName of this.getModernProductNames(channel)) {
|
||||
const escapedProductName = this.escapeRegex(productName);
|
||||
patterns.push(
|
||||
new RegExp(
|
||||
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
'iu',
|
||||
),
|
||||
);
|
||||
if (format === 'portable') {
|
||||
patterns.push(
|
||||
new RegExp(
|
||||
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
|
||||
'iu',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const pattern of patterns) {
|
||||
const match = filename.match(pattern);
|
||||
@@ -852,18 +950,8 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private getModernProductName(channel: DesktopChannel): string {
|
||||
return channel === 'canary' ? 'Fluxer Canary' : 'Fluxer';
|
||||
}
|
||||
|
||||
private buildModernArtifactFilename(
|
||||
channel: DesktopChannel,
|
||||
version: string,
|
||||
plat: DesktopPlatform,
|
||||
archToken: string,
|
||||
ext: string,
|
||||
): string {
|
||||
return `${this.getModernProductName(channel)}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archToken}${ext}`;
|
||||
private getModernProductNames(channel: DesktopChannel): Array<string> {
|
||||
return channel === 'canary' ? ['Fluxer-Canary', 'Fluxer Canary'] : ['Fluxer'];
|
||||
}
|
||||
|
||||
private getArchTokens(archToken: string | Array<string>): Array<string> {
|
||||
|
||||
@@ -14,9 +14,11 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
|
||||
import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
||||
import type {LimitConfigService} from '../../limits/LimitConfigService';
|
||||
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import type {GuildAuditLogService} from '../GuildAuditLogService';
|
||||
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
|
||||
import {ChannelOperationsService} from './channel/ChannelOperationsService';
|
||||
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||
|
||||
export class GuildChannelService {
|
||||
private readonly channelOps: ChannelOperationsService;
|
||||
@@ -30,6 +32,7 @@ export class GuildChannelService {
|
||||
snowflakeService: ISnowflakeService,
|
||||
guildAuditLogService: GuildAuditLogService,
|
||||
limitConfigService: LimitConfigService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {
|
||||
this.channelOps = new ChannelOperationsService(
|
||||
channelRepository,
|
||||
@@ -131,5 +134,12 @@ export class GuildChannelService {
|
||||
permission: params.permission,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({
|
||||
userRepository: this.userRepository,
|
||||
guildData,
|
||||
userId: params.userId,
|
||||
});
|
||||
enforceGuildMfa(params.permission);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class GuildDataService {
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
) {
|
||||
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
|
||||
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService, this.userRepository);
|
||||
this.operationsService = new GuildOperationsService(
|
||||
this.guildRepository,
|
||||
this.channelRepository,
|
||||
|
||||
@@ -50,7 +50,7 @@ export class GuildMemberService {
|
||||
ipInfoService: IpInfoService,
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.authService = new GuildMemberAuthService(gatewayService);
|
||||
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
|
||||
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
|
||||
this.auditService = new GuildMemberAuditService(guildAuditLogService);
|
||||
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {UserID} from '../../BrandedTypes';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
|
||||
export const ELEVATED_MFA_PERMISSIONS =
|
||||
Permissions.KICK_MEMBERS |
|
||||
Permissions.BAN_MEMBERS |
|
||||
Permissions.ADMINISTRATOR |
|
||||
Permissions.MANAGE_CHANNELS |
|
||||
Permissions.MANAGE_GUILD |
|
||||
Permissions.MANAGE_MESSAGES |
|
||||
Permissions.MANAGE_ROLES |
|
||||
Permissions.MANAGE_WEBHOOKS |
|
||||
Permissions.MODERATE_MEMBERS;
|
||||
|
||||
export async function createGuildMfaEnforcer(params: {
|
||||
userRepository: IUserRepository;
|
||||
guildData: Pick<GuildResponse, 'mfa_level' | 'owner_id'>;
|
||||
userId: UserID;
|
||||
}): Promise<(permission: bigint) => void> {
|
||||
const {userRepository, guildData, userId} = params;
|
||||
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
|
||||
let actorLacksMfa = false;
|
||||
if (requiresGuildMfa) {
|
||||
const actor = await userRepository.findUnique(userId);
|
||||
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
|
||||
}
|
||||
return (permission: bigint) => {
|
||||
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type {GuildAuditLogService} from '../GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
|
||||
import {mapGuildBansToResponse} from '../GuildModel';
|
||||
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
|
||||
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
|
||||
|
||||
export class GuildModerationService {
|
||||
@@ -44,6 +45,19 @@ export class GuildModerationService {
|
||||
this.searchIndexService = new GuildMemberSearchIndexService();
|
||||
}
|
||||
|
||||
private async checkModerationPermission(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
permission: bigint;
|
||||
}): Promise<void> {
|
||||
const {guildId, userId, permission} = params;
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
enforceGuildMfa(permission);
|
||||
}
|
||||
|
||||
async banMember(
|
||||
params: {
|
||||
userId: UserID;
|
||||
@@ -57,12 +71,7 @@ export class GuildModerationService {
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.BAN_MEMBERS,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
if (userId === targetId) throw new UnknownGuildMemberError();
|
||||
const targetUser = await this.userRepository.findUnique(targetId);
|
||||
if (!targetUser) {
|
||||
@@ -145,12 +154,7 @@ export class GuildModerationService {
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Array<GuildBanResponse>> {
|
||||
const {userId, guildId, requestCache} = params;
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.BAN_MEMBERS,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
const bans = await this.guildRepository.listBans(guildId);
|
||||
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
|
||||
}
|
||||
@@ -164,12 +168,7 @@ export class GuildModerationService {
|
||||
auditLogReason?: string | null,
|
||||
): Promise<void> {
|
||||
const {userId, guildId, targetId} = params;
|
||||
const hasPermission = await this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
permission: Permissions.BAN_MEMBERS,
|
||||
});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
|
||||
const ban = await this.guildRepository.getBan(guildId, targetId);
|
||||
if (!ban) {
|
||||
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {LimitConfigService} from '../../limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
|
||||
import {GuildRole} from '../../models/GuildRole';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {applyProtectedRolePermissions} from '../../utils/featureUtils';
|
||||
import {computePermissionsDiff} from '../../utils/PermissionUtils';
|
||||
import type {GuildAuditLogService} from '../GuildAuditLogService';
|
||||
@@ -33,6 +34,7 @@ import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
|
||||
import {mapGuildRoleToResponse} from '../GuildModel';
|
||||
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
|
||||
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
|
||||
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||
|
||||
interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {}
|
||||
|
||||
@@ -62,6 +64,7 @@ export class GuildRoleService {
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly limitConfigService: LimitConfigService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
async systemCreateRole(params: {
|
||||
@@ -446,9 +449,11 @@ export class GuildRoleService {
|
||||
|
||||
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
enforceGuildMfa(permission);
|
||||
};
|
||||
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
|
||||
return {
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
@@ -44,6 +43,7 @@ import {GuildChannelService} from './GuildChannelService';
|
||||
import {GuildContentService} from './GuildContentService';
|
||||
import {GuildDataService} from './GuildDataService';
|
||||
import {GuildMemberService} from './GuildMemberService';
|
||||
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
|
||||
import {GuildModerationService} from './GuildModerationService';
|
||||
import {GuildRoleService} from './GuildRoleService';
|
||||
import {GuildSearchService} from './GuildSearchService';
|
||||
@@ -95,17 +95,6 @@ interface GuildAuth {
|
||||
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ELEVATED_MFA_PERMISSIONS =
|
||||
Permissions.KICK_MEMBERS |
|
||||
Permissions.BAN_MEMBERS |
|
||||
Permissions.ADMINISTRATOR |
|
||||
Permissions.MANAGE_CHANNELS |
|
||||
Permissions.MANAGE_GUILD |
|
||||
Permissions.MANAGE_MESSAGES |
|
||||
Permissions.MANAGE_ROLES |
|
||||
Permissions.MANAGE_WEBHOOKS |
|
||||
Permissions.MODERATE_MEMBERS;
|
||||
|
||||
export class GuildService {
|
||||
public readonly data: GuildDataService;
|
||||
public readonly members: GuildMemberService;
|
||||
@@ -182,6 +171,7 @@ export class GuildService {
|
||||
gatewayService,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
userRepository,
|
||||
);
|
||||
this.moderation = new GuildModerationService(
|
||||
guildRepository,
|
||||
@@ -211,6 +201,7 @@ export class GuildService {
|
||||
snowflakeService,
|
||||
guildAuditLogService,
|
||||
limitConfigService,
|
||||
userRepository,
|
||||
);
|
||||
this.search = new GuildSearchService(
|
||||
channelRepository,
|
||||
@@ -561,17 +552,7 @@ export class GuildService {
|
||||
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (!guildData) throw new MissingAccessError();
|
||||
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
|
||||
let actorLacksMfa = false;
|
||||
if (requiresGuildMfa) {
|
||||
const actor = await this.userRepository.findUnique(userId);
|
||||
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
|
||||
}
|
||||
const enforceGuildMfa = (permission: bigint) => {
|
||||
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
|
||||
throw new MfaNotEnabledError();
|
||||
}
|
||||
};
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
|
||||
@@ -355,12 +355,18 @@ export class ChannelOperationsService {
|
||||
requestCache,
|
||||
});
|
||||
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
|
||||
await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent});
|
||||
await this.syncPermissionsWithParent({
|
||||
guildId,
|
||||
userId: params.userId,
|
||||
channelId: target.id,
|
||||
parentId: desiredParent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async syncPermissionsWithParent(params: {
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
parentId: ChannelID;
|
||||
}): Promise<void> {
|
||||
@@ -368,6 +374,22 @@ export class ChannelOperationsService {
|
||||
if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return;
|
||||
const child = await this.channelRepository.findUnique(params.channelId);
|
||||
if (!child || child.guildId !== params.guildId) return;
|
||||
const userPermissions = await this.gatewayService.getUserPermissions({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
channelId: child.id,
|
||||
});
|
||||
if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
for (const [targetId, existing] of child.permissionOverwrites) {
|
||||
const incomingDeny = parent.permissionOverwrites.get(targetId)?.deny ?? 0n;
|
||||
if ((existing.deny & ~incomingDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
}
|
||||
for (const [targetId, incoming] of parent.permissionOverwrites) {
|
||||
const existingAllow = child.permissionOverwrites.get(targetId)?.allow ?? 0n;
|
||||
if ((incoming.allow & ~existingAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
|
||||
}
|
||||
await this.channelRepository.upsert({
|
||||
...child.toRow(),
|
||||
permission_overwrites: new Map(
|
||||
|
||||
@@ -8,12 +8,14 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '../..
|
||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {Guild} from '../../../models/Guild';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils';
|
||||
import {requirePermission} from '../../../utils/PermissionUtils';
|
||||
import type {GuildAuditLogService} from '../../GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
|
||||
import {mapGuildToGuildResponse} from '../../GuildModel';
|
||||
import {GuildRepository} from '../../repositories/GuildRepository';
|
||||
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
|
||||
|
||||
interface GuildAuth {
|
||||
guildData: GuildResponse;
|
||||
@@ -24,6 +26,7 @@ export class GuildDataHelpers {
|
||||
constructor(
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly guildAuditLogService: GuildAuditLogService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
private readonly guildRepository = new GuildRepository();
|
||||
@@ -33,7 +36,7 @@ export class GuildDataHelpers {
|
||||
try {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (!guildData) throw new UnknownGuildError();
|
||||
return this.createGuildAuth({guildData, guildId, userId});
|
||||
return await this.createGuildAuth({guildData, guildId, userId});
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
|
||||
throw new AccessDeniedError();
|
||||
@@ -42,10 +45,16 @@ export class GuildDataHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
private createGuildAuth(params: {guildData: GuildResponse; guildId: GuildID; userId: UserID}): GuildAuth {
|
||||
private async createGuildAuth(params: {
|
||||
guildData: GuildResponse;
|
||||
guildId: GuildID;
|
||||
userId: UserID;
|
||||
}): Promise<GuildAuth> {
|
||||
const {guildData, guildId, userId} = params;
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
await requirePermission(this.gatewayService, {guildId, userId, permission});
|
||||
enforceGuildMfa(permission);
|
||||
};
|
||||
return {guildData, checkPermission};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
||||
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
|
||||
|
||||
interface GuildAuth {
|
||||
guildData: GuildResponse;
|
||||
@@ -16,14 +18,19 @@ interface GuildAuth {
|
||||
}
|
||||
|
||||
export class GuildMemberAuthService {
|
||||
constructor(private readonly gatewayService: IGatewayService) {}
|
||||
constructor(
|
||||
private readonly gatewayService: IGatewayService,
|
||||
private readonly userRepository: IUserRepository,
|
||||
) {}
|
||||
|
||||
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
|
||||
const guildData = await this.gatewayService.getGuildData({guildId, userId});
|
||||
if (!guildData) throw new MissingAccessError();
|
||||
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
|
||||
const checkPermission = async (permission: bigint) => {
|
||||
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
|
||||
if (!hasPermission) throw new MissingPermissionsError();
|
||||
enforceGuildMfa(permission);
|
||||
};
|
||||
const checkTargetMember = async (targetUserId: UserID) => {
|
||||
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
|
||||
|
||||
@@ -60,7 +60,7 @@ export class KVAccountDeletionQueueService {
|
||||
}
|
||||
|
||||
async rebuildState(): Promise<void> {
|
||||
Logger.info('Starting deletion queue rebuild from Cassandra');
|
||||
Logger.info('Starting deletion queue rebuild from primary database');
|
||||
try {
|
||||
await this.kvClient.del(QUEUE_KEY);
|
||||
await this.kvClient.del(STATE_VERSION_KEY);
|
||||
|
||||
@@ -109,6 +109,18 @@ function createRoomServiceClient(endpoint: string, apiKey: string, apiSecret: st
|
||||
return client;
|
||||
}
|
||||
|
||||
function resolveRoomServiceEndpoint(server: VoiceServerRecord): string {
|
||||
const defaultRegion = Config.voice.defaultRegion;
|
||||
const internalUrl = Config.voice.internalUrl;
|
||||
if (!defaultRegion || !internalUrl) {
|
||||
return server.endpoint;
|
||||
}
|
||||
if (server.regionId !== defaultRegion.id || server.serverId !== `${defaultRegion.id}-server-1`) {
|
||||
return server.endpoint;
|
||||
}
|
||||
return internalUrl;
|
||||
}
|
||||
|
||||
export class LiveKitService extends ILiveKitService {
|
||||
private serverClients: Map<string, Map<string, ServerClientConfig>> = new Map();
|
||||
private topology: VoiceTopology;
|
||||
@@ -453,12 +465,13 @@ export class LiveKitService extends ILiveKitService {
|
||||
const servers = this.topology.getServersForRegion(region.id);
|
||||
const serverMap: Map<string, ServerClientConfig> = new Map();
|
||||
for (const server of servers) {
|
||||
const roomServiceEndpoint = resolveRoomServiceEndpoint(server);
|
||||
serverMap.set(server.serverId, {
|
||||
endpoint: server.endpoint,
|
||||
apiKey: server.apiKey,
|
||||
apiSecret: server.apiSecret,
|
||||
isActive: server.isActive,
|
||||
roomServiceClient: createRoomServiceClient(server.endpoint, server.apiKey, server.apiSecret),
|
||||
roomServiceClient: createRoomServiceClient(roomServiceEndpoint, server.apiKey, server.apiSecret),
|
||||
});
|
||||
}
|
||||
newMap.set(region.id, serverMap);
|
||||
|
||||
@@ -284,7 +284,7 @@ export function setInjectedAccountPolicyEvaluator(evaluator: IAccountPolicyEvalu
|
||||
function getRegistrationRiskEvaluator(): IRegistrationRiskEvaluator {
|
||||
if (_registrationRiskEvaluator) return _registrationRiskEvaluator;
|
||||
if (!Config.risk.enabled) {
|
||||
Logger.warn(
|
||||
Logger.info(
|
||||
{},
|
||||
'[ServiceMiddleware] integrations.risk_integration.enabled is false — account risk scoring is disabled',
|
||||
);
|
||||
|
||||
@@ -160,7 +160,7 @@ function createEmailServiceForConfig(
|
||||
enabled: emailConfigSource.enabled,
|
||||
fromEmail: emailConfigSource.fromEmail,
|
||||
fromName: emailConfigSource.fromName,
|
||||
appBaseUrl: Config.endpoints.webApp,
|
||||
appBaseUrl: emailConfigSource.appBaseUrl,
|
||||
marketingBaseUrl: Config.endpoints.marketing,
|
||||
};
|
||||
return new EmailService(emailConfig, emailI18n, createEmailProvider(emailConfigSource), bouncedEmailChecker);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {TorBlockedError} from '@fluxer/errors/src/domains/moderation/TorBlockedError';
|
||||
import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError';
|
||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
import {Config} from '../Config';
|
||||
@@ -13,7 +13,10 @@ export const TorExitMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
if (clientIp && torExitListCache.isTorExit(clientIp)) {
|
||||
throw new TorBlockedError();
|
||||
throw new IpBannedError({
|
||||
ipAddress: clientIp,
|
||||
kind: 'permanent',
|
||||
});
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
@@ -113,6 +113,7 @@ export class GiftCode {
|
||||
readonly stripePaymentIntentId: string | null;
|
||||
readonly visionarySequenceNumber: number | null;
|
||||
readonly checkoutSessionId: string | null;
|
||||
readonly revokedAt: Date | null;
|
||||
readonly version: number;
|
||||
|
||||
constructor(row: GiftCodeRow) {
|
||||
@@ -128,6 +129,7 @@ export class GiftCode {
|
||||
this.stripePaymentIntentId = row.stripe_payment_intent_id ?? null;
|
||||
this.visionarySequenceNumber = row.visionary_sequence_number ?? null;
|
||||
this.checkoutSessionId = row.checkout_session_id ?? null;
|
||||
this.revokedAt = row.revoked_at ?? null;
|
||||
this.version = row.version;
|
||||
}
|
||||
|
||||
@@ -144,6 +146,7 @@ export class GiftCode {
|
||||
stripe_payment_intent_id: this.stripePaymentIntentId,
|
||||
visionary_sequence_number: this.visionarySequenceNumber,
|
||||
checkout_session_id: this.checkoutSessionId,
|
||||
revoked_at: this.revokedAt,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25344,8 +25344,6 @@
|
||||
"GLOBAL_IP_BANNED",
|
||||
"GLOBAL_IP_TEMPORARILY_BANNED",
|
||||
"IP_BANNED",
|
||||
"RESIDENTIAL_PROXY_BLOCKED",
|
||||
"TOR_BLOCKED",
|
||||
"MAX_ANIMATED_EMOJIS",
|
||||
"MAX_APPLICATIONS",
|
||||
"MAX_BOOKMARKS",
|
||||
@@ -25660,8 +25658,6 @@
|
||||
"Your IP address {ipAddress} has been permanently blocked from the Fluxer API by platform administrators. If you believe this is a mistake, contact [email protected] to appeal. Include this IP address in your appeal.",
|
||||
"Your IP address {ipAddress} has been temporarily blocked from the Fluxer API for 24 hours because of abusive or unusual access patterns. We usually do not provide appeals for temporary API bans. Change IP addresses or wait for the ban to expire, and review the Fluxer API access patterns coming from your client.",
|
||||
"Your IP address {ipAddress} has been permanently blocked from the Fluxer API by platform administrators. If you believe this is a mistake, contact [email protected] to appeal. Include this IP address in your appeal.",
|
||||
"This user's IP is banned from this community.",
|
||||
"This user's IP is banned from this community.",
|
||||
"You've reached the maximum of {count, plural, one {# animated emoji} other {# animated emojis}}.",
|
||||
"You've reached the maximum of {limit, plural, one {# application} other {# applications}}.",
|
||||
"You've reached the maximum of {count, plural, one {# bookmark} other {# bookmarks}}.",
|
||||
|
||||
@@ -123,6 +123,9 @@ export class StripeDisputeWebhookHandler {
|
||||
reason: 'gift_refund',
|
||||
chargeId: charge.id,
|
||||
});
|
||||
} else if (!giftCode.revokedAt) {
|
||||
await this.userRepository.revokeGiftCode(giftCode.code);
|
||||
Logger.debug({giftCode: giftCode.code, chargeId: charge.id}, 'Revoked unredeemed gift code after refund');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -196,6 +199,12 @@ export class StripeDisputeWebhookHandler {
|
||||
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
|
||||
'Premium revoked due to gift chargeback',
|
||||
);
|
||||
} else if (!giftCode.revokedAt) {
|
||||
await this.userRepository.revokeGiftCode(giftCode.code);
|
||||
Logger.debug(
|
||||
{giftCode: giftCode.code, chargeId: extractId(dispute.charge)},
|
||||
'Revoked unredeemed gift code after chargeback',
|
||||
);
|
||||
}
|
||||
await this.paymentFraudService.enforceAccountFraudAction({
|
||||
userId: giftCode.createdByUserId,
|
||||
|
||||
@@ -40,7 +40,7 @@ export class StripeGiftService {
|
||||
|
||||
async getGiftCode(code: string): Promise<GiftCode> {
|
||||
const giftCode = await this.userRepository.findGiftCode(code);
|
||||
if (!giftCode) {
|
||||
if (!giftCode || giftCode.revokedAt) {
|
||||
throw new UnknownGiftCodeError();
|
||||
}
|
||||
return giftCode;
|
||||
@@ -62,7 +62,7 @@ export class StripeGiftService {
|
||||
}
|
||||
try {
|
||||
const giftCode = await this.userRepository.findGiftCode(code);
|
||||
if (!giftCode) {
|
||||
if (!giftCode || giftCode.revokedAt) {
|
||||
Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption');
|
||||
throw new UnknownGiftCodeError();
|
||||
}
|
||||
@@ -258,6 +258,7 @@ export class StripeGiftService {
|
||||
const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000;
|
||||
const cutoff = Date.now() - redeemedGracePeriodMs;
|
||||
return gifts
|
||||
.filter((gift) => gift.revokedAt === null)
|
||||
.filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
@@ -252,11 +252,11 @@ export class StripeRefundService {
|
||||
return;
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user || user.firstRefundAt) {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const subscriptionId = refund.metadata.subscription_id;
|
||||
if (subscriptionId) {
|
||||
if (subscriptionId && !user.firstRefundAt) {
|
||||
try {
|
||||
await this.subscriptionService.cancelSubscriptionImmediately(user.id, 'self_serve_refund');
|
||||
} catch (error) {
|
||||
|
||||
@@ -439,7 +439,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
test('skips zero-amount subscription_update invoice without granting an extra monthly cycle', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = `sub_test_${Date.now()}`;
|
||||
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
|
||||
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
@@ -481,7 +481,7 @@ describe('Stripe Webhook - Invoice Events', () => {
|
||||
test('skips paid subscription_update invoices so interval switches do not grant extra time', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const subscriptionId = `sub_test_${Date.now()}`;
|
||||
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
|
||||
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
||||
await createBuilder(harness, account.token)
|
||||
.post(`/test/users/${account.userId}/premium`)
|
||||
.body({
|
||||
|
||||
@@ -47,6 +47,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow {
|
||||
duration_type: durationType,
|
||||
duration_quantity: durationQuantity,
|
||||
duration_months: durationMonths,
|
||||
revoked_at: data.revoked_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,6 +172,10 @@ export class GiftCodeRepository {
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async revokeGiftCode(code: string): Promise<void> {
|
||||
await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())}));
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
const patch: Record<string, DbOp<unknown>> = {};
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface IUserContentRepository {
|
||||
findGiftCodesByRedeemer(userId: UserID): Promise<Array<GiftCode>>;
|
||||
redeemGiftCode(code: string, userId: UserID): Promise<void>;
|
||||
unredeemGiftCode(code: string, userId: UserID): Promise<void>;
|
||||
revokeGiftCode(code: string): Promise<void>;
|
||||
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
|
||||
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
|
||||
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
|
||||
|
||||
@@ -62,6 +62,10 @@ export class UserContentRepository implements IUserContentRepository {
|
||||
return this.giftCodeRepository.unredeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async revokeGiftCode(code: string): Promise<void> {
|
||||
return this.giftCodeRepository.revokeGiftCode(code);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.giftCodeRepository.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
@@ -645,6 +645,10 @@ export class UserRepository implements IUserRepositoryAggregate {
|
||||
return this.contentRepo.unredeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async revokeGiftCode(code: string): Promise<void> {
|
||||
return this.contentRepo.revokeGiftCode(code);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.contentRepo.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {z} from 'zod';
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {Logger} from '../../Logger';
|
||||
import {getWorkerDependencies} from '../WorkerContext';
|
||||
|
||||
const MENTION_CHUNK_SIZE = 250;
|
||||
const MENTION_CHUNK_KEY_DIGEST_LENGTH = 32;
|
||||
const MENTION_CHUNK_ENQUEUE_CONCURRENCY = 16;
|
||||
const MENTION_SOURCE_PAGE_SIZE = 5000;
|
||||
const PayloadSchema = z.object({
|
||||
@@ -100,6 +102,14 @@ function toMentionChunkEntry(entry: GatewayMentionSourceEntry): MentionChunkEntr
|
||||
};
|
||||
}
|
||||
|
||||
function mentionChunkJobKey(messageId: string, chunk: Array<MentionChunkEntry>): string {
|
||||
const digest = createHash('sha256');
|
||||
for (const entry of chunk) {
|
||||
digest.update(`${entry.userId}:${entry.direct ? 1 : 0}:${entry.role ? 1 : 0}:${entry.everyone ? 1 : 0}\n`);
|
||||
}
|
||||
return `mention-chunk:${messageId}:${digest.digest('hex').slice(0, MENTION_CHUNK_KEY_DIGEST_LENGTH)}`;
|
||||
}
|
||||
|
||||
async function enqueueMentionChunks({
|
||||
chunks,
|
||||
channelId,
|
||||
@@ -122,7 +132,6 @@ async function enqueueMentionChunks({
|
||||
await Promise.all(
|
||||
chunkSlice.map((chunk, localIndex) => {
|
||||
const index = firstChunkIndex + offset + localIndex;
|
||||
const jobKeySuffix = chunkCount === undefined ? `${index}` : `${index}:${chunkCount}`;
|
||||
return addJob(
|
||||
'handleMentionChunk',
|
||||
{
|
||||
@@ -134,7 +143,7 @@ async function enqueueMentionChunks({
|
||||
mentions: chunk,
|
||||
},
|
||||
{
|
||||
jobKey: `mention-chunk:${messageId}:${jobKeySuffix}`,
|
||||
jobKey: mentionChunkJobKey(messageId, chunk),
|
||||
skipLedger: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -675,7 +675,9 @@ async function createAndUploadArchive(params: ArchiveParams): Promise<ArchiveRes
|
||||
output.on('close', resolve);
|
||||
output.on('error', reject);
|
||||
});
|
||||
const storageKey = `exports/${userId}/${harvestId}/user-data.zip`;
|
||||
const storageKey = isAdminArchive
|
||||
? `archives/users/${userId}/${harvestId}/user-data.zip`
|
||||
: `exports/${userId}/${harvestId}/user-data.zip`;
|
||||
const expiresAt = new Date(Date.now() + (isAdminArchive ? ms('1 year') : ZIP_EXPIRY_MS));
|
||||
const zipStat = await fs.promises.stat(zipPath);
|
||||
const fileSize = BigInt(zipStat.size);
|
||||
|
||||
@@ -230,6 +230,7 @@ export const ChannelItem = observer(
|
||||
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
|
||||
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
|
||||
const unreadCount = ReadStates.getUnreadCount(channel.id);
|
||||
const hasUnread = ReadStates.hasUnread(channel.id);
|
||||
const connectedVoiceGuildId = channelIsVoice ? MediaEngine.guildId : null;
|
||||
const connectedVoiceChannelId = channelIsVoice ? MediaEngine.channelId : null;
|
||||
const canManageChannels = Permission.can(Permissions.MANAGE_CHANNELS, channel);
|
||||
@@ -265,6 +266,7 @@ export const ChannelItem = observer(
|
||||
type: channel.type,
|
||||
});
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted: isChannelDirectlyMuted,
|
||||
|
||||
@@ -335,6 +335,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
);
|
||||
const hasVisibleUnreadInChannel = (channelId: string): boolean => {
|
||||
const unreadCount = ReadStates.getUnreadCount(channelId);
|
||||
const hasUnread = ReadStates.hasUnread(channelId);
|
||||
const mentionCount = ReadStates.getMentionCount(channelId);
|
||||
const isMuted =
|
||||
UserGuildSettings.isParentCategoryMuted(guild.id, channelId) ||
|
||||
@@ -349,6 +350,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
})
|
||||
: null;
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -197,6 +197,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
);
|
||||
const refs = useMergeRefs([dragConnectorRef, dropConnectorRef, elementRef]);
|
||||
const unreadCount = ReadStates.getUnreadCount(channel.id);
|
||||
const hasUnread = ReadStates.hasUnread(channel.id);
|
||||
const mentionCount = ReadStates.getMentionCount(channel.id);
|
||||
const isGroupDM = channel.isGroupDM();
|
||||
const isDM = channel.isDM();
|
||||
@@ -220,6 +221,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
})
|
||||
: null;
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -663,6 +663,7 @@ function resolveGuildTargetBounds({
|
||||
function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverity | null {
|
||||
const mentionCount = ReadStates.getPrivateChannelMentionCount(channelId);
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread: ReadStates.hasUnreadPrivateChannel(channelId),
|
||||
unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId),
|
||||
mentionCount,
|
||||
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
|
||||
|
||||
@@ -70,6 +70,7 @@ const ACTIVE_CALL_DESCRIPTOR = msg({
|
||||
|
||||
export function resolveDMListItemUnreadState(channelId: string): ChannelUnreadState {
|
||||
return getChannelUnreadState({
|
||||
hasUnread: ReadStates.hasUnread(channelId),
|
||||
unreadCount: ReadStates.getUnreadCount(channelId),
|
||||
mentionCount: ReadStates.getMentionCount(channelId),
|
||||
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {getChannelUnreadState} from './ChannelUnreadState';
|
||||
describe('getChannelUnreadState', () => {
|
||||
it('shows a normal unread indicator for all-messages unread badges', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
@@ -19,6 +20,7 @@ describe('getChannelUnreadState', () => {
|
||||
});
|
||||
it('shows a muted unread indicator for only-mentions unread badges without highlighting the channel', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
@@ -31,6 +33,7 @@ describe('getChannelUnreadState', () => {
|
||||
});
|
||||
it('hides unread and mention surfaces when unread badges are disabled', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 1,
|
||||
isMuted: false,
|
||||
@@ -43,6 +46,7 @@ describe('getChannelUnreadState', () => {
|
||||
});
|
||||
it('keeps legacy muted-channel fading for channels without an unread-badges level', () => {
|
||||
const hiddenState = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: true,
|
||||
@@ -50,6 +54,7 @@ describe('getChannelUnreadState', () => {
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
const fadedState = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: true,
|
||||
@@ -60,4 +65,27 @@ describe('getChannelUnreadState', () => {
|
||||
expect(fadedState.shouldShowUnreadIndicator).toBe(true);
|
||||
expect(fadedState.isUnreadIndicatorMuted).toBe(true);
|
||||
});
|
||||
it('shows the indicator from the unread flag rather than the message count', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
showFadedUnreadOnMutedChannels: false,
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
expect(state.shouldShowUnreadIndicator).toBe(true);
|
||||
});
|
||||
it('hides the indicator for a read channel even if a stale count survives', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: false,
|
||||
unreadCount: 7,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
showFadedUnreadOnMutedChannels: false,
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
expect(state.shouldShowUnreadIndicator).toBe(false);
|
||||
expect(state.hasVisibleUnread).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {resolveChannelUnreadState} from './ChannelUnreadStateMachine';
|
||||
|
||||
export interface ChannelUnreadStateInput {
|
||||
hasUnread: boolean;
|
||||
unreadCount: number;
|
||||
mentionCount: number;
|
||||
isMuted: boolean;
|
||||
@@ -20,6 +21,7 @@ export interface ChannelUnreadState {
|
||||
}
|
||||
|
||||
export function getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
@@ -27,6 +29,7 @@ export function getChannelUnreadState({
|
||||
unreadBadgesLevel,
|
||||
}: ChannelUnreadStateInput): ChannelUnreadState {
|
||||
return resolveChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
function input(overrides: Partial<ChannelUnreadStateInput> = {}): ChannelUnreadStateInput {
|
||||
return {
|
||||
hasUnread: false,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
@@ -38,6 +39,7 @@ describe('channelUnreadStateMachine', () => {
|
||||
const snapshot = createChannelUnreadSnapshot(
|
||||
input({
|
||||
unreadBadgesLevel: MessageNotifications.NO_MESSAGES,
|
||||
hasUnread: true,
|
||||
unreadCount: 1,
|
||||
mentionCount: 1,
|
||||
}),
|
||||
@@ -55,6 +57,7 @@ describe('channelUnreadStateMachine', () => {
|
||||
it('transitions without preserving stale policy output', () => {
|
||||
const legacySnapshot = createChannelUnreadSnapshot(
|
||||
input({
|
||||
hasUnread: true,
|
||||
unreadCount: 2,
|
||||
isMuted: true,
|
||||
showFadedUnreadOnMutedChannels: false,
|
||||
@@ -66,6 +69,7 @@ describe('channelUnreadStateMachine', () => {
|
||||
type: 'channelUnread.updated',
|
||||
input: input({
|
||||
unreadBadgesLevel: MessageNotifications.ALL_MESSAGES,
|
||||
hasUnread: true,
|
||||
unreadCount: 2,
|
||||
isMuted: true,
|
||||
}),
|
||||
|
||||
@@ -86,7 +86,7 @@ export function transitionChannelUnreadSnapshot(
|
||||
|
||||
export function selectChannelUnreadState(snapshot: ChannelUnreadSnapshot): ChannelUnreadState {
|
||||
const context = snapshot.context;
|
||||
const hasUnreadMessages = context.unreadCount > 0;
|
||||
const hasUnreadMessages = context.hasUnread;
|
||||
const rawHasMentions = context.mentionCount > 0;
|
||||
switch (getUnreadStateValue(snapshot)) {
|
||||
case 'disabled':
|
||||
|
||||
@@ -8,12 +8,11 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {buildAppProtocolUrl} from '@app/features/ui/utils/AppProtocol';
|
||||
import {isDesktop, openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import {checkDesktopAvailable, navigateInDesktop} from '@app/features/voice/utils/DesktopRpcClient';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useState} from 'react';
|
||||
|
||||
interface DesktopDeepLinkPromptProps {
|
||||
code: string;
|
||||
@@ -36,25 +35,9 @@ const FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR = msg({
|
||||
export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({code, kind, preferLogin = false}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMobileBrowser = Platform.isMobileBrowser;
|
||||
const useProtocolLaunch = kind === 'invite';
|
||||
const shouldProbeDesktopAvailability = !useProtocolLaunch;
|
||||
useEffect(() => {
|
||||
if (isDesktop() || !shouldProbeDesktopAvailability) return;
|
||||
let cancelled = false;
|
||||
checkDesktopAvailable().then(({available}) => {
|
||||
if (!cancelled) {
|
||||
setDesktopAvailable(available);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [shouldProbeDesktopAvailability]);
|
||||
if (isDesktop() || isMobileBrowser) return null;
|
||||
if (shouldProbeDesktopAvailability && desktopAvailable !== true) return null;
|
||||
const getPath = (): string => {
|
||||
switch (kind) {
|
||||
case 'invite':
|
||||
@@ -69,20 +52,12 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
||||
const handleOpen = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
if (useProtocolLaunch) {
|
||||
try {
|
||||
await openExternalUrl(buildAppProtocolUrl(path));
|
||||
} catch {
|
||||
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const result = await navigateInDesktop(path);
|
||||
setIsLoading(false);
|
||||
if (!result.success) {
|
||||
try {
|
||||
await openExternalUrl(buildAppProtocolUrl(path));
|
||||
} catch {
|
||||
setError(i18n._(FAILED_TO_OPEN_IN_DESKTOP_APP_DESCRIPTOR));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -23,6 +23,7 @@ interface BlockedMessageGroupsProps {
|
||||
messageGroups: Array<ChannelStreamItem>;
|
||||
onReveal: (messageId: string | null) => void;
|
||||
revealed: boolean;
|
||||
hasUnread?: boolean;
|
||||
compact: boolean;
|
||||
messageGroupSpacing: number;
|
||||
variant: 'blocked' | 'spammer';
|
||||
@@ -33,11 +34,13 @@ interface BlockedMessageGroupsProps {
|
||||
messageActionsClassName?: string;
|
||||
renderMessageActions?: (message: Message) => React.ReactNode;
|
||||
renderMessageWrapper?: (props: MessageGroupRenderWrapperProps) => React.ReactNode;
|
||||
suppressUnreadIndicator?: boolean;
|
||||
}
|
||||
|
||||
const arePropsEqual = (prevProps: BlockedMessageGroupsProps, nextProps: BlockedMessageGroupsProps): boolean => {
|
||||
if (prevProps.channel.id !== nextProps.channel.id) return false;
|
||||
if (prevProps.revealed !== nextProps.revealed) return false;
|
||||
if (prevProps.hasUnread !== nextProps.hasUnread) return false;
|
||||
if (prevProps.compact !== nextProps.compact) return false;
|
||||
if (prevProps.messageGroupSpacing !== nextProps.messageGroupSpacing) return false;
|
||||
if (prevProps.variant !== nextProps.variant) return false;
|
||||
@@ -49,6 +52,7 @@ const arePropsEqual = (prevProps: BlockedMessageGroupsProps, nextProps: BlockedM
|
||||
if (prevProps.messageActionsClassName !== nextProps.messageActionsClassName) return false;
|
||||
if (prevProps.renderMessageActions !== nextProps.renderMessageActions) return false;
|
||||
if (prevProps.renderMessageWrapper !== nextProps.renderMessageWrapper) return false;
|
||||
if (prevProps.suppressUnreadIndicator !== nextProps.suppressUnreadIndicator) return false;
|
||||
if (prevProps.messageGroups.length !== nextProps.messageGroups.length) return false;
|
||||
for (let i = 0; i < prevProps.messageGroups.length; i++) {
|
||||
const prevGroup = prevProps.messageGroups[i];
|
||||
@@ -69,6 +73,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
channel,
|
||||
compact,
|
||||
revealed,
|
||||
hasUnread = false,
|
||||
messageGroupSpacing,
|
||||
onReveal,
|
||||
variant,
|
||||
@@ -79,6 +84,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
messageActionsClassName,
|
||||
renderMessageActions,
|
||||
renderMessageWrapper,
|
||||
suppressUnreadIndicator,
|
||||
} = props;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToBottomFrameRef = useRef<number | null>(null);
|
||||
@@ -169,6 +175,12 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
};
|
||||
messageGroups.forEach((item, itemIndex) => {
|
||||
if (item.type === ChannelStreamType.DIVIDER) {
|
||||
if (item.unreadId && suppressUnreadIndicator) {
|
||||
return;
|
||||
}
|
||||
if (itemIndex === 0 && item.unreadId) {
|
||||
return;
|
||||
}
|
||||
flushGroup();
|
||||
nodes.push(
|
||||
<Divider
|
||||
@@ -205,13 +217,23 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
messageActionsClassName,
|
||||
renderMessageActions,
|
||||
renderMessageWrapper,
|
||||
suppressUnreadIndicator,
|
||||
]);
|
||||
const leadingUnreadDivider = messageGroups[0]?.type === ChannelStreamType.DIVIDER && !!messageGroups[0].unreadId;
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(styles.container, className)}
|
||||
data-flx="channel.blocked-message-groups.container"
|
||||
>
|
||||
{hasUnread && (!revealed || leadingUnreadDivider) && (
|
||||
<Divider
|
||||
spacing={messageGroupSpacing}
|
||||
red={true}
|
||||
id="new-messages-bar"
|
||||
data-flx="channel.blocked-message-groups.collapsed-unread-divider"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
|
||||
@@ -189,11 +189,13 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array<Reac
|
||||
key={item.key}
|
||||
revealed={item.key === unblurredMessageId}
|
||||
messageGroups={item.content as Array<ChannelStreamItem>}
|
||||
hasUnread={item.hasUnread === true && !suppressUnreadIndicator}
|
||||
onReveal={onReveal ?? (() => {})}
|
||||
compact={messageDisplayCompact}
|
||||
channel={channel}
|
||||
messageGroupSpacing={messageGroupSpacing}
|
||||
variant={variant}
|
||||
suppressUnreadIndicator={suppressUnreadIndicator}
|
||||
data-flx="channel.channel-message-stream.render-channel-stream.blocked-message-groups"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -236,7 +236,7 @@ export const Messages = observer(function Messages({
|
||||
messages: safeMessages,
|
||||
channel,
|
||||
compact: state.messageDisplayCompact,
|
||||
hasPendingUnreads: state.unreadCount > 0,
|
||||
hasPendingUnreads: state.visualUnreadMessageId != null,
|
||||
focusAnchorId: null,
|
||||
unloadedSpacerHeight: selectChannelMessagesSpacerHeight(windowStatus, placeholderSpecs.totalHeight),
|
||||
allowHistoryFetch: true,
|
||||
|
||||
@@ -14,10 +14,11 @@ import {useVirtualRows} from '@app/features/channel/components/sticker_picker/ho
|
||||
import {StickerPickerCategoryList} from '@app/features/channel/components/sticker_picker/StickerPickerCategoryList';
|
||||
import {
|
||||
buildStickerRowOffsets,
|
||||
getStickerRowHeight,
|
||||
getFixedStickerRowHeight,
|
||||
getStickerGridColumns,
|
||||
getStickerRowWindow,
|
||||
STICKER_GRID_TRACK_WIDTH,
|
||||
STICKER_SECTION_GAP,
|
||||
STICKERS_PER_ROW,
|
||||
type StickerRowKind,
|
||||
} from '@app/features/channel/components/sticker_picker/StickerPickerConstants';
|
||||
import {StickerPickerInspector} from '@app/features/channel/components/sticker_picker/StickerPickerInspector';
|
||||
@@ -74,12 +75,12 @@ export const StickersPicker = observer(
|
||||
const [selectedColumn, setSelectedColumn] = useState(-1);
|
||||
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const {scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
|
||||
const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const stickerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
|
||||
const rowListRef = useRef<HTMLDivElement>(null);
|
||||
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0, gridWidth: 0});
|
||||
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0});
|
||||
const [stickerDataVersion, setStickerDataVersion] = useState(0);
|
||||
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
|
||||
const {shouldAnimate: shouldAnimateStickerPreview} = useStickerAnimation();
|
||||
@@ -138,13 +139,26 @@ export const StickersPicker = observer(
|
||||
allUpsell.accessibleItems,
|
||||
renderedStickers,
|
||||
);
|
||||
const zoomLevel = Accessibility.zoomLevel;
|
||||
const gridColumns = useMemo(() => getStickerGridColumns(viewportSize.width), [viewportSize.width, zoomLevel]);
|
||||
const previousGridColumnsRef = useRef(gridColumns);
|
||||
useLayoutEffect(() => {
|
||||
if (previousGridColumnsRef.current === gridColumns) {
|
||||
return;
|
||||
}
|
||||
previousGridColumnsRef.current = gridColumns;
|
||||
setHoveredSticker(null);
|
||||
setSelectedRow(-1);
|
||||
setSelectedColumn(-1);
|
||||
setShouldScrollOnSelection(false);
|
||||
}, [gridColumns]);
|
||||
const pickerRows = useVirtualRows(
|
||||
searchTerm,
|
||||
renderedStickers,
|
||||
favoriteStickers,
|
||||
frequentlyUsedStickers,
|
||||
stickersByGuildId,
|
||||
STICKERS_PER_ROW,
|
||||
gridColumns,
|
||||
);
|
||||
const hasNoStickersAtAll = allItems.length === 0;
|
||||
const isSearching = searchTerm.trim().length > 0;
|
||||
@@ -198,12 +212,8 @@ export const StickersPicker = observer(
|
||||
}
|
||||
return {stickerRowIndexes: indexes, stickerRowStarts: starts, categoryRowIndexes: categories};
|
||||
}, [pickerRows]);
|
||||
const zoomLevel = Accessibility.zoomLevel;
|
||||
const remScale = getAppRemScale();
|
||||
const stickerRowHeight = useMemo(
|
||||
() => getStickerRowHeight(listMetrics.gridWidth, STICKERS_PER_ROW, remScale),
|
||||
[listMetrics.gridWidth, remScale, zoomLevel],
|
||||
);
|
||||
const stickerRowHeight = useMemo(() => getFixedStickerRowHeight(remScale), [remScale, zoomLevel]);
|
||||
const rowOffsets = useMemo(() => {
|
||||
const rowKinds = pickerRows.map((row): StickerRowKind => row.type);
|
||||
return buildStickerRowOffsets(rowKinds, {remScale, stickerRowHeight, sectionGap: STICKER_SECTION_GAP});
|
||||
@@ -219,11 +229,8 @@ export const StickersPicker = observer(
|
||||
rowListNode.getBoundingClientRect().top - scrollerNode.getBoundingClientRect().top + scrollerNode.scrollTop,
|
||||
);
|
||||
const viewportHeight = scrollerNode.clientHeight;
|
||||
const gridWidth = rowListNode.clientWidth;
|
||||
setListMetrics((current) =>
|
||||
current.origin === origin && current.viewportHeight === viewportHeight && current.gridWidth === gridWidth
|
||||
? current
|
||||
: {origin, viewportHeight, gridWidth},
|
||||
current.origin === origin && current.viewportHeight === viewportHeight ? current : {origin, viewportHeight},
|
||||
);
|
||||
});
|
||||
const rowWindow = useMemo(
|
||||
@@ -394,7 +401,8 @@ export const StickersPicker = observer(
|
||||
row={row}
|
||||
handleHover={handleHover}
|
||||
handleSelect={handleStickerSelect}
|
||||
gridColumns={STICKERS_PER_ROW}
|
||||
gridColumns={gridColumns}
|
||||
cellTrackWidth={STICKER_GRID_TRACK_WIDTH}
|
||||
selectedRow={selectedRow}
|
||||
selectedColumn={selectedColumn}
|
||||
stickerRowIndex={stickerRowIndexes[rowIndex]!}
|
||||
|
||||
@@ -200,18 +200,15 @@ export const UserMessage = observer(() => {
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(
|
||||
finishEditing();
|
||||
void MessageCommands.edit(
|
||||
channel.id,
|
||||
message.id,
|
||||
'',
|
||||
undefined,
|
||||
message._allowedMentions,
|
||||
buildExistingAttachmentEditReferences(message),
|
||||
).then((result) => {
|
||||
if (result) {
|
||||
finishEditing();
|
||||
}
|
||||
});
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleDelete();
|
||||
@@ -220,11 +217,8 @@ export const UserMessage = observer(() => {
|
||||
if (checkCustomEmojiAvailability(content)) {
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions).then((result) => {
|
||||
if (result) {
|
||||
finishEditing();
|
||||
}
|
||||
});
|
||||
finishEditing();
|
||||
void MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions);
|
||||
},
|
||||
[
|
||||
channel.id,
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,63 @@ type AttemptDecision =
|
||||
| {next: 'retry-after'; delayMs: number; mode: 'backoff' | 'fixed'}
|
||||
| {next: 'fail'; error: unknown};
|
||||
|
||||
interface OnlineWaiter {
|
||||
resolve: () => void;
|
||||
signal: AbortSignal | undefined;
|
||||
onAbort: () => void;
|
||||
}
|
||||
|
||||
const onlineWaiters = new Set<OnlineWaiter>();
|
||||
let onlineListenerActive = false;
|
||||
|
||||
function createRequestAbortError(): DOMException {
|
||||
return new DOMException('Request aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function removeOnlineListener(): void {
|
||||
if (!onlineListenerActive) return;
|
||||
window.removeEventListener('online', resolveOnlineWaiters);
|
||||
onlineListenerActive = false;
|
||||
}
|
||||
|
||||
function releaseOnlineWaiter(waiter: OnlineWaiter): boolean {
|
||||
if (!onlineWaiters.delete(waiter)) return false;
|
||||
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
||||
if (onlineWaiters.size === 0) removeOnlineListener();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveOnlineWaiters(): void {
|
||||
const pending = Array.from(onlineWaiters);
|
||||
onlineWaiters.clear();
|
||||
removeOnlineListener();
|
||||
for (const waiter of pending) {
|
||||
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
||||
waiter.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function waitUntilOnline(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(createRequestAbortError());
|
||||
if (navigator.onLine) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const waiter: OnlineWaiter = {
|
||||
resolve,
|
||||
signal,
|
||||
onAbort: () => {
|
||||
if (releaseOnlineWaiter(waiter)) reject(createRequestAbortError());
|
||||
},
|
||||
};
|
||||
onlineWaiters.add(waiter);
|
||||
if (signal) signal.addEventListener('abort', waiter.onAbort, {once: true});
|
||||
if (!onlineListenerActive) {
|
||||
window.addEventListener('online', resolveOnlineWaiters);
|
||||
onlineListenerActive = true;
|
||||
}
|
||||
if (navigator.onLine) queueMicrotask(resolveOnlineWaiters);
|
||||
});
|
||||
}
|
||||
|
||||
export class RestClient {
|
||||
private readonly state: RuntimeState = {
|
||||
baseUrl: '/api',
|
||||
@@ -216,6 +273,7 @@ async function runRetryLoop<T>(
|
||||
attempt: number,
|
||||
): Promise<RestResponse<T>> {
|
||||
const plan = composePlan(state, method, path, options, sudoApplied);
|
||||
if (plan.retries > 0) await waitUntilOnline(plan.signal);
|
||||
const pacingHit = consultPacing(state.pacing, plan.rateLimitKey);
|
||||
if (pacingHit) {
|
||||
if (plan.mode === 'auto-retry') {
|
||||
@@ -240,7 +298,11 @@ async function runRetryLoop<T>(
|
||||
return finalizeAfterRetriesExhausted<T>(state, plan, outcome);
|
||||
}
|
||||
const wait = decision.mode === 'backoff' ? computeBackoffMs(attempt) : decision.delayMs;
|
||||
await delay(wait, plan.signal);
|
||||
if (outcome.status === 'transport-error' && !navigator.onLine) {
|
||||
await waitUntilOnline(plan.signal);
|
||||
} else {
|
||||
await delay(wait, plan.signal);
|
||||
}
|
||||
return runRetryLoop<T>(state, method, path, options, sudoApplied, attempt + 1);
|
||||
}
|
||||
case 'fail':
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user