mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45289b5531 | ||
|
|
30a1271774 | ||
|
|
438f11adda | ||
|
|
170ca805c5 | ||
|
|
f4547d11d3 | ||
|
|
ccdd85b099 | ||
|
|
98c40c6979 | ||
|
|
e054aa96be | ||
|
|
3e12466c57 | ||
|
|
039bd1a7df | ||
|
|
7a45d5844d | ||
|
|
410fa2dba9 | ||
|
|
4cb1808959 | ||
|
|
60a62c24c3 | ||
|
|
c06e958eb5 | ||
|
|
358b7c88fc | ||
|
|
1bced6abae | ||
|
|
8cbd55dcaf | ||
|
|
162937575c | ||
|
|
c6223d656e | ||
|
|
2dd5e6a4b4 | ||
|
|
490b710c61 | ||
|
|
b5285019cd | ||
|
|
dcd3d9d08a | ||
|
|
fb718e7e5b | ||
|
|
578fd61d93 | ||
|
|
cbc0a616ea | ||
|
|
6d04fa3e6d | ||
|
|
562819be09 | ||
|
|
f45a3073c1 | ||
|
|
5f9e4db230 | ||
|
|
0be2a7fed9 | ||
|
|
e0876d719c | ||
|
|
ae11ee86aa | ||
|
|
5022568608 | ||
|
|
004fb0c7b0 | ||
|
|
2dc9ded0e8 | ||
|
|
0692aa0e25 | ||
|
|
3ed43ca00f | ||
|
|
5cd22ee84e | ||
|
|
a90168fa66 | ||
|
|
1f76c9d3d3 | ||
|
|
4480d4db69 | ||
|
|
d1e5b00c52 | ||
|
|
b937306210 | ||
|
|
de614db368 | ||
|
|
a4b121345f | ||
|
|
6073ad74d5 | ||
|
|
2d51600b6c | ||
|
|
235399cfd6 | ||
|
|
2bb4c92f2b | ||
|
|
86afe3aefc |
@@ -216,6 +216,48 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- ci
|
||||
--step knip
|
||||
|
||||
i18n:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- name: Set up Rust toolchain (CI helpers)
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- ci
|
||||
--step install_dependencies
|
||||
|
||||
- name: Compile locale catalogs
|
||||
run: pnpm i18n:compile
|
||||
|
||||
- name: Check drift of compiled locale modules
|
||||
run: |
|
||||
if ! git diff --exit-code -- \
|
||||
packages/errors/src/i18n/locales \
|
||||
packages/errors/src/i18n/ErrorI18nTypes.generated.ts \
|
||||
fluxer_api/pkgs/email/src/email_i18n/locales \
|
||||
fluxer_api/pkgs/email/src/email_i18n/EmailI18nTypes.generated.ts \
|
||||
fluxer_api/src/api/content_i18n/locales; then
|
||||
echo "::error::Compiled locale modules are outdated. Run 'pnpm i18n:compile' and commit the result. Translations belong in the weblate/ catalogs, not in the generated locales/ modules."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fonts:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
**/*.css.d.ts
|
||||
**/*.tsbuildinfo
|
||||
**/.cache/
|
||||
**/.swc/
|
||||
**/__pycache__/
|
||||
**/_build/
|
||||
**/coverage/
|
||||
|
||||
Generated
+10
@@ -1624,6 +1624,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "entities"
|
||||
version = "1.0.1"
|
||||
@@ -1931,6 +1940,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"hmac 0.13.0",
|
||||
|
||||
@@ -19,8 +19,6 @@ members = [
|
||||
exclude = [
|
||||
"fluxer_marketing",
|
||||
"packages/markdown_parser/rust/fuzz",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.19.16",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.23.1",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -24,6 +24,28 @@ FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_EMAIL=[email protected]
|
||||
|
||||
# Passkeys follow FLUXER_DOMAIN by default. Set these only if browsers reach the
|
||||
# instance on a different host, and note that changing FLUXER_PASSKEY_RP_ID
|
||||
# invalidates every passkey already registered against the old value.
|
||||
#FLUXER_PASSKEY_RP_ID=chat.example.com
|
||||
#FLUXER_PASSKEY_RP_NAME=Fluxer
|
||||
#FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS=https://chat.example.com
|
||||
|
||||
# Extra Content-Security-Policy sources, appended to the built-in ones. Set these
|
||||
# only when a browser must reach an origin the defaults do not cover, such as a
|
||||
# voice server hosted on a domain other than FLUXER_DOMAIN. Separate several
|
||||
# sources with spaces or commas.
|
||||
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
|
||||
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
|
||||
#FLUXER_CSP_EXTRA_SCRIPT_SRC=https://analytics.example.com
|
||||
|
||||
# Allow the SSO identity provider to resolve to a private or internal address.
|
||||
# Off by default: the API refuses to call non-public addresses so a misconfigured
|
||||
# provider URL cannot be used to reach internal services. Turn it on only when the
|
||||
# provider genuinely lives on your own network, such as split-horizon DNS or a LAN
|
||||
# identity provider, and only when you trust everyone who can configure SSO.
|
||||
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
|
||||
|
||||
LIVEKIT_API_KEY=fluxer
|
||||
LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
reverse_proxy static-proxy:8080
|
||||
}
|
||||
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy app-proxy:8080
|
||||
}
|
||||
|
||||
@@ -72,9 +72,13 @@ x-fluxer-env: &fluxer-env
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
|
||||
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
|
||||
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-false}
|
||||
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
|
||||
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
|
||||
FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_RP_ID: ${FLUXER_PASSKEY_RP_ID:-${FLUXER_DOMAIN}}
|
||||
FLUXER_PASSKEY_RP_NAME: ${FLUXER_PASSKEY_RP_NAME:-Fluxer}
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: ${FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}
|
||||
FLUXER_GATEWAY_RPC_AUTH_TOKEN: ${FLUXER_GATEWAY_RPC_AUTH_TOKEN:?set FLUXER_GATEWAY_RPC_AUTH_TOKEN in .env}
|
||||
FLUXER_MEDIA_PROXY_SECRET_KEY: ${FLUXER_MEDIA_PROXY_SECRET_KEY:?set FLUXER_MEDIA_PROXY_SECRET_KEY in .env}
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64: ${FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64:?set FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 in .env}
|
||||
@@ -173,13 +177,29 @@ services:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- >
|
||||
for i in $$(seq 1 60); do
|
||||
echo "s3.bucket.create -name fluxer" | weed shell -master=seaweedfs:9333 >/dev/null 2>&1 && break || sleep 2;
|
||||
buckets="fluxer fluxer-uploads fluxer-downloads fluxer-reports fluxer-harvests";
|
||||
missing="$$buckets";
|
||||
for attempt in $$(seq 1 60); do
|
||||
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
||||
sleep 2;
|
||||
continue;
|
||||
fi;
|
||||
listed=$$(echo "s3.bucket.list" | timeout 10 weed shell -master=seaweedfs:9333 2>&1);
|
||||
missing="";
|
||||
for b in $$buckets; do
|
||||
echo "$$listed" | grep -q "^[[:space:]]*$$b[[:space:]]" || missing="$${missing:+$$missing }$$b";
|
||||
done;
|
||||
if [ -z "$$missing" ]; then
|
||||
echo "buckets ready";
|
||||
exit 0;
|
||||
fi;
|
||||
for b in $$missing; do
|
||||
echo "s3.bucket.create -name $$b" | timeout 10 weed shell -master=seaweedfs:9333 >/dev/null 2>&1;
|
||||
done;
|
||||
sleep 2;
|
||||
done;
|
||||
for b in fluxer fluxer-uploads fluxer-downloads fluxer-reports fluxer-harvests; do
|
||||
echo "s3.bucket.create -name $$b" | weed shell -master=seaweedfs:9333 || true;
|
||||
done;
|
||||
echo "buckets ready";
|
||||
echo "seaweedfs-init could not verify buckets: $$missing" >&2;
|
||||
exit 1;
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.12.0
|
||||
@@ -282,9 +302,21 @@ services:
|
||||
DISCOVERY_UPSTREAM_URL: http://caddy:8088/api/.well-known/fluxer
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}/api
|
||||
FLUXER_CSP_EXTRA_DEFAULT_SRC: ${FLUXER_CSP_EXTRA_DEFAULT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_CONNECT_SRC: ${FLUXER_CSP_EXTRA_CONNECT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_IMG_SRC: ${FLUXER_CSP_EXTRA_IMG_SRC:-}
|
||||
FLUXER_CSP_EXTRA_MEDIA_SRC: ${FLUXER_CSP_EXTRA_MEDIA_SRC:-}
|
||||
FLUXER_CSP_EXTRA_FONT_SRC: ${FLUXER_CSP_EXTRA_FONT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_SCRIPT_SRC: ${FLUXER_CSP_EXTRA_SCRIPT_SRC:-}
|
||||
FLUXER_CSP_EXTRA_STYLE_SRC: ${FLUXER_CSP_EXTRA_STYLE_SRC:-}
|
||||
FLUXER_CSP_EXTRA_FRAME_SRC: ${FLUXER_CSP_EXTRA_FRAME_SRC:-}
|
||||
FLUXER_CSP_EXTRA_WORKER_SRC: ${FLUXER_CSP_EXTRA_WORKER_SRC:-}
|
||||
FLUXER_CSP_EXTRA_MANIFEST_SRC: ${FLUXER_CSP_EXTRA_MANIFEST_SRC:-}
|
||||
FLUXER_CSP_REPORT_URI: ${FLUXER_CSP_REPORT_URI:-}
|
||||
depends_on:
|
||||
api: {condition: service_healthy}
|
||||
caddy: {condition: service_started}
|
||||
postgres: {condition: service_healthy}
|
||||
|
||||
snowflakes:
|
||||
<<: *fluxer-service
|
||||
@@ -323,6 +355,7 @@ services:
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
depends_on:
|
||||
nats: {condition: service_started}
|
||||
postgres: {condition: service_healthy}
|
||||
|
||||
gifs:
|
||||
<<: *fluxer-service
|
||||
|
||||
@@ -4,16 +4,16 @@ import {defineEmailI18nLocaleMessages} from '../EmailI18nMessages';
|
||||
|
||||
const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"account_disabled_suspicious": {
|
||||
"subject": "Dein {product_name}-Konto wurde vorübergehend deaktiviert",
|
||||
"body": "Hallo {username},\n\nwir haben dein {product_name}-Konto vorübergehend deaktiviert, da wir verdächtige Aktivitäten festgestellt haben.\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nUm wieder Zugriff auf dein Konto zu erhalten, musst du dein Passwort zurücksetzen:\n\n{forgotUrl}\n\nNachdem du dein Passwort zurückgesetzt hast, kannst du dich wieder anmelden.\n\nWenn du glaubst, dass dies ein Fehler war, kontaktiere bitte unser Support-Team.\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Dein {product_name}-Account wurde vorübergehend deaktiviert",
|
||||
"body": "Hallo {username},\n\nwir haben deinen {product_name}-Account vorübergehend deaktiviert, da wir verdächtige Aktivitäten festgestellt haben.\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nUm wieder Zugriff auf deinen Account zu erhalten, musst du dein Passwort zurücksetzen:\n\n{forgotUrl}\n\nNachdem du dein Passwort zurückgesetzt hast, kannst du dich wieder anmelden.\n\nWenn du glaubst, dass dies ein Fehler war, kontaktiere bitte unser Support-Team.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "Dein {product_name}-Konto wird dauerhaft gelöscht",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Konto wurde aufgrund von Verstößen gegen unsere Nutzungsbedingungen oder Community-Richtlinien zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDies ist eine ernsthafte Maßnahme. Deine Kontodaten werden am geplanten Datum dauerhaft gelöscht.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nEinspruchsverfahren:\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, hast du 60 Tage Zeit, Einspruch einzulegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\nIn deinem Einspruch:\n- Erkläre klar, warum du glaubst, dass die Entscheidung falsch oder ungerechtfertigt war\n- Füge alle relevanten Beweise oder Kontextinformationen bei\n\nEin Mitglied des {product_name}-Sicherheitsteams wird deinen Einspruch prüfen und die ausstehende Löschung möglicherweise pausieren, bis eine endgültige Entscheidung getroffen wurde.\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account wurde aufgrund von Verstößen gegen unsere Nutzungsbedingungen oder Community-Richtlinien zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDies ist eine ernsthafte Maßnahme. Deine Accountdaten werden am geplanten Datum dauerhaft gelöscht.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nEinspruchsverfahren:\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, hast du 60 Tage Zeit, Einspruch einzulegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\nIn deinem Einspruch:\n- Erkläre klar, warum du glaubst, dass die Entscheidung falsch oder ungerechtfertigt war\n- Füge alle relevanten Beweise oder Kontextinformationen bei\n\nEin Mitglied des {product_name}-Sicherheitsteams wird deinen Einspruch prüfen und die ausstehende Löschung möglicherweise pausieren, bis eine endgültige Entscheidung getroffen wurde.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "Dein {product_name}-Konto wurde vorübergehend gesperrt",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Konto wurde vorübergehend gesperrt, weil du gegen unsere Nutzungsbedingungen oder Community-Richtlinien verstoßen hast.\n\nDauer: {durationHours, plural,\n one {1 Stunde}\n other {# Stunden}\n}\nGesperrt bis: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nWährend dieser Zeit kannst du nicht auf dein Konto zugreifen.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, kannst du Einspruch einlegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email} und erkläre klar, warum du die Entscheidung für falsch hältst. Wir werden deinen Einspruch prüfen und dir unsere Entscheidung mitteilen.\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Dein {product_name}-Account wurde vorübergehend gesperrt",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account wurde vorübergehend gesperrt, weil du gegen unsere Nutzungsbedingungen oder Community-Richtlinien verstoßen hast.\n\nDauer: {durationHours, plural,\n one {1 Stunde}\n other {# Stunden}\n}\nGesperrt bis: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nWährend dieser Zeit kannst du nicht auf deinen Account zugreifen.\n\nBitte lies dir Folgendes durch:\n- Nutzungsbedingungen: {termsUrl}\n- Community-Richtlinien: {guidelinesUrl}\n\nWenn du glaubst, dass diese Entscheidung falsch oder ungerechtfertigt war, kannst du Einspruch einlegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email} und erkläre klar, warum du die Entscheidung für falsch hältst. Wir werden deinen Einspruch prüfen und dir unsere Entscheidung mitteilen.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "Vielen Dank für deine {product_name}-Spende",
|
||||
@@ -33,35 +33,35 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
},
|
||||
"email_change_original": {
|
||||
"subject": "Bestätige deine {product_name}-E-Mail-Änderung",
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung der E-Mail-Adresse deines {product_name}-Kontos erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du dies nicht angefordert hast, sichere dein Konto bitte sofort.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung der E-Mail-Adresse deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diese Änderung nicht angefordert hast, sichere deinen Account bitte sofort ab.\n\n– {product_name} Team"
|
||||
},
|
||||
"email_change_revert": {
|
||||
"subject": "Deine {product_name}-E-Mail wurde geändert",
|
||||
"body": "Hallo {username},\n\ndie E-Mail-Adresse deines {product_name}-Kontos wurde zu {newEmail} geändert.\n\nWenn du diese Änderung vorgenommen hast, ist keine weitere Aktion erforderlich. Wenn nicht, kannst du die Änderung rückgängig machen und dein Konto über diesen Link sichern:\n\n{revertUrl}\n\nDadurch wird deine vorherige E-Mail wiederhergestellt, du wirst überall abgemeldet, verknüpfte Telefonnummern werden entfernt, MFA wird deaktiviert und du musst ein neues Passwort festlegen.\n\n– {product_name}-Sicherheitsteam"
|
||||
"body": "Hallo {username},\n\ndie E-Mail-Adresse deines {product_name}-Accounts wurde zu {newEmail} geändert.\n\nWenn du diese Änderung vorgenommen hast, ist keine weitere Aktion erforderlich. Wenn nicht, kannst du die Änderung rückgängig machen und deinen Account über diesen Link absichern:\n\n{revertUrl}\n\nDadurch wird deine vorherige E-Mail wiederhergestellt, du wirst überall abgemeldet, verknüpfte Telefonnummern werden entfernt, MFA wird deaktiviert und du musst ein neues Passwort festlegen.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"email_verification": {
|
||||
"subject": "Bestätige deine {product_name}-E-Mail-Adresse",
|
||||
"body": "Hallo {username},\n\nbitte bestätige die E-Mail-Adresse für dein {product_name}-Konto, indem du auf den untenstehenden Link klickst:\n\n{verifyUrl}\n\nWenn du kein {product_name}-Konto erstellt hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 24 Stunden gültig.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nbitte bestätige die E-Mail-Adresse für deinen {product_name}-Account, indem du auf den untenstehenden Link klickst:\n\n{verifyUrl}\n\nWenn du keinen {product_name}-Account erstellt hast, kannst du diese E-Mail ignorieren.\n\nDieser Link ist 24 Stunden gültig.\n\n– {product_name} Team"
|
||||
},
|
||||
"gift_chargeback_notification": {
|
||||
"subject": "Vorteile deines eingelösten Geschenks wurden entfernt",
|
||||
"body": "Hallo {username},\n\nein von dir eingelöster Geschenkcode wurde ursprünglich von jemand anderem bezahlt. Diese Zahlung wurde inzwischen rückgängig gemacht (eine Rückbuchung).\n\nAus diesem Grund haben wir die Vorteile entfernt, die deinem Konto hinzugefügt wurden, als du das Geschenk eingelöst hast.\n\nWenn du glaubst, dass dies ein Fehler ist, kontaktiere bitte unser Support-Team und gib alle Details an, die du über den Geschenkcode und den Zeitpunkt der Einlösung hast.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nein von dir eingelöster Geschenkcode wurde ursprünglich von jemand anderem bezahlt. Diese Zahlung wurde inzwischen rückgängig gemacht (eine Rückbuchung).\n\nAus diesem Grund haben wir die Vorteile entfernt, die deinem Account hinzugefügt wurden, als du das Geschenk eingelöst hast.\n\nWenn du glaubst, dass dies ein Fehler ist, kontaktiere bitte unser Support-Team und gib alle Details an, die du über den Geschenkcode und den Zeitpunkt der Einlösung hast.\n\n– {product_name} Team"
|
||||
},
|
||||
"harvest_completed": {
|
||||
"subject": "Dein {product_name}-Datenexport steht zum Herunterladen bereit",
|
||||
"body": "Hallo {username},\n\ndein Datenexport ist bereit.\n\nDownload-Link:\n{downloadUrl}\n\nEnthaltene Nachrichten: {totalMessages, number}\nDateigröße: {fileSizeMB, number} MB\n\nDieser Link läuft am {expiresAt, date, full} um {expiresAt, time, short} ab.\n\nWenn du diesen Export nicht angefordert hast, ändere bitte sofort dein Passwort und kontaktiere unser Support-Team.\n\n– {product_name} Team"
|
||||
},
|
||||
"inactivity_warning": {
|
||||
"subject": "Dein {product_name}-Konto wird wegen Inaktivität gelöscht",
|
||||
"body": "Hallo {username},\n\nwir haben seit dem {lastActiveDate, date, full} keine Aktivität auf deinem {product_name}-Konto festgestellt.\n\nWenn du dich nicht bis zum {deletionDate, date, full} um {deletionDate, time, short} anmeldest, wird dein Konto aufgrund von Inaktivität dauerhaft gelöscht.\n\nMelde dich hier an:\n{loginUrl}\n\nWenn du {product_name} kürzlich genutzt hast, kontaktiere bitte sofort unser Support-Team.\n\n– {product_name} Team"
|
||||
"subject": "Dein {product_name}-Account wird wegen Inaktivität gelöscht",
|
||||
"body": "Hallo {username},\n\nwir haben seit dem {lastActiveDate, date, full} keine Aktivität auf deinem {product_name}-Account festgestellt.\n\nWenn du dich nicht bis zum {deletionDate, date, full} um {deletionDate, time, short} anmeldest, wird dein Account aufgrund von Inaktivität dauerhaft gelöscht.\n\nMelde dich hier an:\n{loginUrl}\n\nWenn du {product_name} kürzlich genutzt hast, kontaktiere bitte sofort unser Support-Team.\n\n– {product_name} Team"
|
||||
},
|
||||
"ip_authorization": {
|
||||
"subject": "Anmeldung von einer neuen IP-Adresse bestätigen",
|
||||
"body": "Hallo {username},\n\nwir haben einen Anmeldeversuch für dein {product_name}-Konto von einer neuen IP-Adresse festgestellt:\n\nIP-Adresse: {ipAddress}\nStandort: {location}\n\nWenn du das warst, bestätige diese IP-Adresse bitte, indem du auf den untenstehenden Link klickst:\n\n{authUrl}\n\nWenn du dich nicht anmelden wolltest, ändere bitte sofort dein Passwort.\n\nDieser Link ist 30 Minuten gültig.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nwir haben einen Anmeldeversuch für deinen {product_name}-Account von einer neuen IP-Adresse festgestellt:\n\nIP-Adresse: {ipAddress}\nStandort: {location}\n\nWenn du das warst, bestätige diese IP-Adresse bitte, indem du auf den untenstehenden Link klickst:\n\n{authUrl}\n\nWenn du dich nicht anmelden wolltest, ändere bitte sofort dein Passwort.\n\nDieser Link ist 30 Minuten gültig.\n\n– {product_name} Team"
|
||||
},
|
||||
"password_change_verification": {
|
||||
"subject": "Bestätige deine {product_name}-Passwortänderung",
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung des Passworts deines {product_name}-Kontos erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft um {expiresAt} ab.\n\nWenn du dies nicht angefordert hast, könnte jemand Zugriff auf dein Konto haben. Ändere dein Passwort sofort und aktiviere die Zwei-Faktor-Authentifizierung.\n\n– {product_name} Team"
|
||||
"body": "Hallo {username},\n\nwir haben eine Anfrage zur Änderung des Passworts deines {product_name}-Accounts erhalten.\n\nUm diese Änderung zu bestätigen, gib diesen Code in der App ein:\n\n{code}\n\nDieser Code läuft um {expiresAt} ab.\n\nWenn du diese Änderung nicht angefordert hast, könnte jemand Zugriff auf deinen Account haben. Ändere dein Passwort sofort und aktiviere die Zwei-Faktor-Authentifizierung.\n\n– {product_name} Team"
|
||||
},
|
||||
"password_reset": {
|
||||
"subject": "Setze dein {product_name}-Passwort zurück",
|
||||
@@ -76,16 +76,16 @@ const EMAIL_I18N_DE_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"body": "Hallo {username},\n\ndein Bericht (ID: {reportId}) wurde von unserem Sicherheitsteam geprüft.{hasComment, select, yes {\n\nAntwort vom Sicherheitsteam:\n{publicComment}} other {}}\n\nDanke, dass du hilfst, {product_name} für alle sicher zu halten. Wir nehmen alle Berichte ernst und schätzen deinen Beitrag zur Community.\n\nWenn du Fragen oder Bedenken zu diesem Ergebnis hast, kontaktiere bitte {safety_email}.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"scheduled_deletion_notification": {
|
||||
"subject": "Dein {product_name}-Konto wird dauerhaft gelöscht",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Konto wurde zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDies ist eine ernsthafte Maßnahme. Deine Kontodaten werden am geplanten Datum dauerhaft gelöscht.\n\nWenn du glaubst, dass diese Entscheidung falsch war, kannst du Einspruch einlegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Dein {product_name}-Account wird dauerhaft gelöscht",
|
||||
"body": "Hallo {username},\n\ndein {product_name}-Account wurde zur dauerhaften Löschung vorgemerkt.\n\nGeplante Löschung: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDies ist eine ernsthafte Maßnahme. Deine Accountdaten werden am geplanten Datum dauerhaft gelöscht.\n\nWenn du glaubst, dass diese Entscheidung falsch war, kannst du Einspruch einlegen. Sende eine E-Mail von dieser E-Mail-Adresse an {appeals_email}.\n\n– {product_name}-Sicherheitsteam"
|
||||
},
|
||||
"self_deletion_scheduled": {
|
||||
"subject": "Die Löschung deines {product_name}-Kontos ist geplant",
|
||||
"body": "Hallo {username},\n\ndu hast die Löschung deines {product_name}-Kontos beantragt. Dein Konto ist zur dauerhaften Löschung vorgesehen am:\n\n{deletionDate, date, full} um {deletionDate, time, short}\n\nWenn du dies nicht beantragt hast, melde dich in deinem Konto an, um die Löschung abzubrechen. Wir empfehlen außerdem, dein Passwort zu ändern, um dein Konto zu sichern.\n\n– {product_name} Team"
|
||||
"subject": "Die Löschung deines {product_name}-Accounts ist geplant",
|
||||
"body": "Hallo {username},\n\ndu hast die Löschung deines {product_name}-Accounts beantragt. Dein Account ist zur dauerhaften Löschung vorgesehen am:\n\n{deletionDate, date, full} um {deletionDate, time, short}\n\nWenn du diese Löschung nicht beantragt hast, melde dich in deinem Account an, um die Löschung abzubrechen. Wir empfehlen außerdem, dein Passwort zu ändern, um deinen Account abzusichern.\n\n– {product_name} Team"
|
||||
},
|
||||
"unban_notification": {
|
||||
"subject": "Die Sperrung deines {product_name}-Kontos wurde aufgehoben",
|
||||
"body": "Hallo {username},\n\ngute Nachrichten: Die Sperrung deines {product_name}-Kontos wurde aufgehoben.\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDu kannst dich jetzt wieder anmelden und {product_name} wie gewohnt nutzen.\n\n– {product_name}-Sicherheitsteam"
|
||||
"subject": "Die Sperrung deines {product_name}-Accounts wurde aufgehoben",
|
||||
"body": "Hallo {username},\n\ngute Nachrichten: Die Sperrung deines {product_name}-Accounts wurde aufgehoben.\n\n{reason, select,\n null {}\n other {Grund: {reason}}\n}\n\nDu kannst dich jetzt wieder anmelden und {product_name} wie gewohnt nutzen.\n\n– {product_name}-Sicherheitsteam"
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ const EMAIL_I18N_JA_MESSAGES = defineEmailI18nLocaleMessages({
|
||||
"body": "こんにちは、{username}さん\n\n不審なアクティビティが検出されたため、{product_name}アカウントを一時的に無効にしました。\n\n{reason, select,\n null {}\n other {理由: {reason}}\n}\n\nアカウントに再度アクセスするには、パスワードをリセットする必要があります。\n\n{forgotUrl}\n\nパスワードをリセットすると、再度ログインできるようになります。\n\nこの措置が誤りであると思われる場合は、サポートチームにお問い合わせください。\n\n– {product_name}安全チーム"
|
||||
},
|
||||
"account_scheduled_deletion": {
|
||||
"subject": "{product_name}アカウントは完全に削除されます",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントは、利用規約またはコミュニティガイドラインへの違反のため、永久削除が予定されています。\n\n削除予定日: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {理由: {reason}}\n}\n\nこれは重大な措置です。アカウントデータは、予定日に完全に削除されます。\n\n以下をご確認ください。\n- 利用規約: {termsUrl}\n- コミュニティガイドライン: {guidelinesUrl}\n\n異議申し立て手続き:\nこの措置が不正確または不当であると思われる場合は、60日以内に異議申し立てを提出できます。このメールアドレスから{appeals_email}までメールを送信してください。\n\n異議申し立ての際は、以下を含めてください。\n- 措置が不正確または不当であると考える理由を明確に説明してください\n- 関連する証拠や状況を提供してください\n\n{product_name}安全チームのメンバーが異議申し立てを審査し、最終決定が下されるまで削除を一時停止する場合があります。\n\n– {product_name}安全チーム"
|
||||
"subject": "あなたの{product_name}アカウントは完全に削除されます",
|
||||
"body": "こんにちは、{username}さん\n\nあなたの{product_name}アカウントは、利用規約またはコミュニティガイドラインへの違反のため、永久削除が予定されています。\n\n削除予定日: {deletionDate, date, full} {deletionDate, time, short}\n\n{reason, select,\n null {}\n other {理由: {reason}}\n}\n\nこれは重大な措置です。アカウントデータは、予定日に完全に削除されます。\n\n以下をご確認ください。\n- 利用規約: {termsUrl}\n- コミュニティガイドライン: {guidelinesUrl}\n\n異議申し立て手続き:\nこの措置が不正確または不当であると思われる場合は、60日以内に異議申し立てを提出できます。このメールアドレスから{appeals_email}までメールを送信してください。\n\n異議申し立ての際は、以下を含めてください。\n- 措置が不正確または不当であると考える理由を明確に説明してください\n- 関連する証拠や状況を提供してください\n\n{product_name}安全チームのメンバーが異議申し立てを審査し、最終決定が下されるまで削除を一時停止する場合があります。\n\n– {product_name}安全チーム"
|
||||
},
|
||||
"account_temp_banned": {
|
||||
"subject": "{product_name}アカウントが一時的に停止されました",
|
||||
"body": "こんにちは、{username}さん\n\n{product_name}アカウントは、利用規約またはコミュニティガイドラインへの違反のため、一時的に停止されました。\n\n期間: {durationHours, plural,\n =1 {1時間}\n other {#時間}\n}\n停止期間終了: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {理由: {reason}}\n}\n\nこの期間中、アカウントにアクセスすることはできません。\n\n以下をご確認ください。\n- 利用規約: {termsUrl}\n- コミュニティガイドライン: {guidelinesUrl}\n\nこの措置が不正確または不当であると思われる場合は、異議申し立てを提出できます。このメールアドレスから{appeals_email}までメールを送信し、決定が不正確であると考える理由を明確に説明してください。異議申し立てを審査し、決定をご連絡いたします。\n\n– {product_name}安全チーム"
|
||||
"subject": "あなたの{product_name}アカウントが一時的に停止されました",
|
||||
"body": "こんにちは、{username}さん\n\nあなたの{product_name}アカウントは、利用規約またはコミュニティガイドラインへの違反のため、一時的に停止されました。\n\n期間: {durationHours, plural,\n =1 {1時間}\n other {#時間}\n}\n停止期間終了: {bannedUntil, date, full} {bannedUntil, time, short}\n\n{reason, select,\n null {}\n other {理由: {reason}}\n}\n\nこの期間中、アカウントにアクセスすることはできません。\n\n以下をご確認ください。\n- 利用規約: {termsUrl}\n- コミュニティガイドライン: {guidelinesUrl}\n\nこの措置が不正確または不当であると思われる場合は、異議申し立てを提出できます。このメールアドレスから{appeals_email}までメールを送信し、決定が不正確であると考える理由を明確に説明してください。異議申し立てを審査し、決定をご連絡いたします。\n\n– {product_name}安全チーム"
|
||||
},
|
||||
"donation_confirmation": {
|
||||
"subject": "{product_name}への寄付ありがとうございます",
|
||||
|
||||
@@ -23,6 +23,7 @@ interface CachedLookupResult {
|
||||
interface PublicInternetRequestUrlPolicyOptions {
|
||||
dnsCacheTtlMs?: number;
|
||||
lookupHost?: (hostname: string) => Promise<Array<string>>;
|
||||
allowPrivateAddresses?: boolean;
|
||||
}
|
||||
|
||||
const BLOCKED_IPV4_SUBNETS: Array<BlockedSubnet> = [
|
||||
@@ -175,6 +176,7 @@ export function createPublicInternetRequestUrlPolicy(
|
||||
? options.dnsCacheTtlMs
|
||||
: DEFAULT_DNS_CACHE_TTL_MS;
|
||||
const lookupHost = options?.lookupHost ?? defaultLookupHost;
|
||||
const allowPrivateAddresses = options?.allowPrivateAddresses === true;
|
||||
const dnsCache = new Map<string, CachedLookupResult>();
|
||||
async function resolveHostname(hostname: string): Promise<Array<string>> {
|
||||
const now = Date.now();
|
||||
@@ -198,7 +200,7 @@ export function createPublicInternetRequestUrlPolicy(
|
||||
throw createBlockedRequestError(url, context, 'Hostname is empty');
|
||||
}
|
||||
if (isIP(normalizedHostname)) {
|
||||
if (isBlockedIpAddress(normalizedHostname)) {
|
||||
if (!allowPrivateAddresses && isBlockedIpAddress(normalizedHostname)) {
|
||||
throw createBlockedRequestError(url, context, 'IP address is in an internal or special-use range');
|
||||
}
|
||||
return;
|
||||
@@ -210,6 +212,9 @@ export function createPublicInternetRequestUrlPolicy(
|
||||
if (resolvedAddresses.length === 0) {
|
||||
throw createBlockedRequestError(url, context, 'Hostname resolved to no IP addresses');
|
||||
}
|
||||
if (allowPrivateAddresses) {
|
||||
return;
|
||||
}
|
||||
for (const address of resolvedAddresses) {
|
||||
if (isBlockedIpAddress(address)) {
|
||||
throw createBlockedRequestError(url, context, `Hostname resolved to disallowed address ${address}`);
|
||||
|
||||
@@ -364,6 +364,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
auth: {
|
||||
sudoModeSecret: master.auth.sudo_mode_secret,
|
||||
connectionInitiationSecret: master.auth.connection_initiation_secret,
|
||||
ssoAllowPrivateAddresses: master.auth.sso_allow_private_addresses,
|
||||
passkeys: {
|
||||
rpName: master.auth.passkeys.rp_name,
|
||||
rpId: master.auth.passkeys.rp_id,
|
||||
|
||||
@@ -619,6 +619,9 @@ async function updatePendingRegistrationUser(
|
||||
traits.delete(REGISTRATION_REJECTED_TRAIT);
|
||||
}
|
||||
await userRepository.patchUpsert(user.id, {traits: traits.size > 0 ? traits : null}, user.toRow());
|
||||
if (decision === 'approve') {
|
||||
await ctx.get('singleCommunityService').joinStockCommunity(user.id, ctx.get('requestCache'));
|
||||
}
|
||||
await ctx.get('adminService').auditService.createAuditLog({
|
||||
adminUserId: ctx.get('adminUserId'),
|
||||
targetType: 'user',
|
||||
|
||||
@@ -291,7 +291,6 @@ export async function register(
|
||||
last_voice_activity_sharing_change_at: null,
|
||||
version: 1,
|
||||
});
|
||||
await kvActivityTracker.updateActivity(user.id, now);
|
||||
await users.upsertSettings(
|
||||
UserSettings.getDefaultUserSettings({
|
||||
userId,
|
||||
@@ -300,6 +299,9 @@ export async function register(
|
||||
theme: data.theme,
|
||||
}),
|
||||
);
|
||||
void kvActivityTracker.updateActivity(user.id, now).catch((error: unknown) => {
|
||||
Logger.warn({error, userId: user.id}, 'Failed to update real-time user activity');
|
||||
});
|
||||
const isUnclaimed = !rawEmail;
|
||||
const usernameIsUserChosen = data.username != null || data.global_name != null;
|
||||
const riskResult = await registrationRiskEvaluator.evaluate({
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
} from '../../instance/InstanceConfigRepository';
|
||||
import {
|
||||
deriveSsoRedirectUri,
|
||||
getSsoRequestUrlPolicy,
|
||||
isTestSsoProvider,
|
||||
validateSsoPublicOutboundUrl,
|
||||
} from '../../instance/SsoConfigValidation';
|
||||
@@ -529,7 +530,9 @@ export class SsoService {
|
||||
isAdult: true,
|
||||
}),
|
||||
);
|
||||
await this.kvActivityTracker.updateActivity(user.id, now);
|
||||
void this.kvActivityTracker.updateActivity(user.id, now).catch((error: unknown) => {
|
||||
this.logger.warn({error, userId: user.id}, 'Failed to update real-time user activity');
|
||||
});
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (!userCreated) {
|
||||
@@ -651,13 +654,16 @@ export class SsoService {
|
||||
return cached.jwks;
|
||||
}
|
||||
const fetchJwks = async (): Promise<JSONWebKeySet> => {
|
||||
const response = await FetchUtils.sendRequest({
|
||||
url: jwksUrl,
|
||||
method: 'GET',
|
||||
headers: {Accept: 'application/json'},
|
||||
timeout: ms('5 seconds'),
|
||||
serviceName: 'sso_jwks',
|
||||
});
|
||||
const response = await FetchUtils.sendRequest(
|
||||
{
|
||||
url: jwksUrl,
|
||||
method: 'GET',
|
||||
headers: {Accept: 'application/json'},
|
||||
timeout: ms('5 seconds'),
|
||||
serviceName: 'sso_jwks',
|
||||
},
|
||||
{requestUrlPolicy: getSsoRequestUrlPolicy()},
|
||||
);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
|
||||
}
|
||||
@@ -744,16 +750,19 @@ export class SsoService {
|
||||
}
|
||||
|
||||
private async fetchUserInfo(userInfoUrl: string, accessToken: string): Promise<Record<string, unknown>> {
|
||||
const resp = await FetchUtils.sendRequest({
|
||||
url: userInfoUrl,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
const resp = await FetchUtils.sendRequest(
|
||||
{
|
||||
url: userInfoUrl,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
timeout: ms('15 seconds'),
|
||||
serviceName: 'sso_user_info',
|
||||
},
|
||||
timeout: ms('15 seconds'),
|
||||
serviceName: 'sso_user_info',
|
||||
});
|
||||
{requestUrlPolicy: getSsoRequestUrlPolicy()},
|
||||
);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw InputValidationError.fromCode('access_token', ValidationErrorCodes.FAILED_TO_FETCH_SSO_USER_INFO);
|
||||
}
|
||||
@@ -805,14 +814,17 @@ export class SsoService {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
const resp = await FetchUtils.sendRequest({
|
||||
url: config.tokenUrl ?? '',
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
timeout: ms('15 seconds'),
|
||||
serviceName: 'sso_token_exchange',
|
||||
});
|
||||
const resp = await FetchUtils.sendRequest(
|
||||
{
|
||||
url: config.tokenUrl ?? '',
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
timeout: ms('15 seconds'),
|
||||
serviceName: 'sso_token_exchange',
|
||||
},
|
||||
{requestUrlPolicy: getSsoRequestUrlPolicy()},
|
||||
);
|
||||
if (resp.status < 200 || resp.status >= 300) {
|
||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_SSO_AUTHORIZATION_CODE);
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ export interface APIConfig {
|
||||
auth: {
|
||||
sudoModeSecret: string;
|
||||
connectionInitiationSecret: string;
|
||||
ssoAllowPrivateAddresses: boolean;
|
||||
passkeys: {
|
||||
rpName: string;
|
||||
rpId: string;
|
||||
|
||||
@@ -7,8 +7,8 @@ const CONTENT_I18N_ES_419_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"bulk_message_deletion.complete": "Terminamos de eliminar tus mensajes. Eliminamos {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 lugares} one {# lugar} other {# lugares}}.",
|
||||
"content.virus_detected": "Ese archivo fue marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se alcanzó el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de pegatinas ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Ocurrió un error desconocido.",
|
||||
"guild.bulk_create.sticker_limit": "Se alcanzó el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
"guild.default_channel_text": "general",
|
||||
|
||||
@@ -7,7 +7,7 @@ const CONTENT_I18N_ES_ES_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"bulk_message_deletion.complete": "Hemos terminado de eliminar tus mensajes. Hemos eliminado {message_count, plural, =0 {0 mensajes} one {# mensaje} other {# mensajes}} de {channel_count, plural, =0 {0 sitios} one {# sitio} other {# sitios}}.",
|
||||
"content.virus_detected": "Ese archivo se ha marcado como potencialmente inseguro y se ha eliminado.",
|
||||
"guild.bulk_create.emoji_limit": "Se ha alcanzado el límite máximo de emojis ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se ha alcanzado el límite máximo de pegatinas ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Se ha alcanzado el límite máximo de stickers ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Error desconocido.",
|
||||
"guild.default_category_text": "Canales de texto",
|
||||
"guild.default_category_voice": "Canales de voz",
|
||||
|
||||
@@ -9,7 +9,7 @@ const CONTENT_I18N_HU_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"guild.bulk_create.emoji_limit": "A maximális emoji szám elérve ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "A maximális matricák száma elérve ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Ismeretlen hiba.",
|
||||
"guild.default_category_text": "Szöveges csatornák",
|
||||
"guild.default_category_text": "Szöveges Csatornák",
|
||||
"guild.default_category_voice": "Hangcsatornák",
|
||||
"guild.default_channel_text": "általános",
|
||||
"guild.default_channel_voice": "Általános"
|
||||
|
||||
@@ -5,13 +5,13 @@ import {defineContentI18nLocaleMessages} from '../ContentI18nMessages';
|
||||
const CONTENT_I18N_ID_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"billing.eu_withdrawal_waiver_checkout": "Jika saya konsumen EU/EEA, saya setuju konten digital {product_name} paket {premium_tier_name} diberikan segera dan mengakui bahwa saya kehilangan hak pembatalan begitu akses diberikan. Ini tidak memengaruhi hak konsumen wajib lainnya. Lihat [Ketentuan Layanan]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Kami sudah selesai menghapus pesanmu. Kami menghapus {message_count, plural, =0 {0 pesan} other {# pesan}} dari {channel_count, plural, =0 {0 tempat} other {# tempat}}.",
|
||||
"content.virus_detected": "File itu terdeteksi tidak aman dan telah dihapus.",
|
||||
"guild.bulk_create.emoji_limit": "Jumlah emoji maksimum telah tercapai ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Jumlah stiker maksimum telah tercapai ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Terjadi kesalahan yang tidak diketahui.",
|
||||
"guild.default_category_text": "Kanal teks",
|
||||
"guild.default_category_voice": "Kanal suara",
|
||||
"guild.default_channel_text": "Umum",
|
||||
"content.virus_detected": "File itu ditandai sebagai tidak aman dan berbahaya dan sudah dihapus.",
|
||||
"guild.bulk_create.emoji_limit": "Maksimal emoji telah mencapai ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maksimal stiker sudah mencapai ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Kesalahan tidak diketahui.",
|
||||
"guild.default_category_text": "Saluran teks",
|
||||
"guild.default_category_voice": "Saluran suara",
|
||||
"guild.default_channel_text": "umum",
|
||||
"guild.default_channel_voice": "Umum"
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {defineContentI18nLocaleMessages} from '../ContentI18nMessages';
|
||||
const CONTENT_I18N_NL_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"billing.eu_withdrawal_waiver_checkout": "Als ik een EU/EER-consument ben, geef ik uitdrukkelijk toestemming dat de digitale inhoud van {product_name} {premium_tier_name} onmiddellijk wordt geleverd en erken ik dat ik mijn wettelijke herroepingsrecht verlies zodra toegang wordt verleend. Dit heeft geen invloed op andere dwingende consumentenrechten. Zie de [Servicevoorwaarden]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "We zijn klaar met het verwijderen van je berichten. We hebben {message_count, plural, =0 {0 berichten} one {# bericht} other {# berichten}} verwijderd uit {channel_count, plural, =0 {0 plekken} one {# plek} other {# plekken}}.",
|
||||
"content.virus_detected": "Dat bestand is gemarkeerd als mogelijk onveilig en is verwijderd.",
|
||||
"content.virus_detected": "Dit bestand is gemarkeerd als mogelijk onveilig en is verwijderd.",
|
||||
"guild.bulk_create.emoji_limit": "Maximaal aantal emoji bereikt ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximaal aantal stickers bereikt ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Onbekende fout.",
|
||||
|
||||
@@ -6,7 +6,7 @@ const CONTENT_I18N_SV_SE_MESSAGES = defineContentI18nLocaleMessages({
|
||||
"billing.eu_withdrawal_waiver_checkout": "Om jag är en EU/EES-konsument samtycker jag uttryckligen till att {product_name} {premium_tier_name} digitalt innehåll tillhandahålls omedelbart och bekräftar att jag förlorar min lagstadgade ångerrätt när åtkomst ges. Detta påverkar inte andra tvingande konsumenträttigheter. Se [användarvillkoren]({terms_url}).",
|
||||
"bulk_message_deletion.complete": "Vi har raderat dina meddelanden. Vi tog bort {message_count, plural, =0 {0 meddelanden} one {# meddelande} other {# meddelanden}} från {channel_count, plural, =0 {0 platser} one {# plats} other {# platser}}.",
|
||||
"content.virus_detected": "Filen flaggades som potentiellt osäker och har tagits bort.",
|
||||
"guild.bulk_create.emoji_limit": "Maximalt antal emojisar nått ({limit}).",
|
||||
"guild.bulk_create.emoji_limit": "Maximalt antal emojis nått ({limit}).",
|
||||
"guild.bulk_create.sticker_limit": "Maximalt antal klistermärken nått ({limit}).",
|
||||
"guild.bulk_create.unknown_error": "Okänt fel.",
|
||||
"guild.default_category_text": "Textkanaler",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildResponse, GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
|
||||
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
@@ -49,6 +50,7 @@ export function GuildBaseController(app: HonoApp) {
|
||||
if (policy.single_community_enabled) {
|
||||
throw new SingleCommunityCannotCreateGuildsError();
|
||||
}
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const locale = ctx.get('requestLocale') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
|
||||
|
||||
@@ -24,8 +24,13 @@ import {MessageSearchResponseMapper} from '../../search/MessageSearchResponseMap
|
||||
import {searchExistingMessages} from '../../search/MessageSearchResultReconciler';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import {canUserAccessNsfwContent} from '../../utils/AgeUtils';
|
||||
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
|
||||
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
|
||||
|
||||
const GUILD_FANOUT_CONCURRENCY = 16;
|
||||
const PERMISSION_CHECK_CONCURRENCY = 64;
|
||||
const CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY = 16;
|
||||
|
||||
export class GuildSearchService {
|
||||
private readonly responseMapper: MessageSearchResponseMapper;
|
||||
|
||||
@@ -95,15 +100,13 @@ export class GuildSearchService {
|
||||
const channel = channelMap.get(id.toString())!;
|
||||
return !(channel.isNsfw && !canIncludeNsfw);
|
||||
});
|
||||
const permissionResults = await Promise.all(
|
||||
nsfwFilteredIds.map((channelId) =>
|
||||
this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
channelId,
|
||||
permission: Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY,
|
||||
}),
|
||||
),
|
||||
const permissionResults = await mapWithConcurrency(nsfwFilteredIds, PERMISSION_CHECK_CONCURRENCY, (channelId) =>
|
||||
this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
channelId,
|
||||
permission: Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY,
|
||||
}),
|
||||
);
|
||||
const validChannelIds: Array<ChannelID> = [];
|
||||
for (let i = 0; i < nsfwFilteredIds.length; i++) {
|
||||
@@ -133,18 +136,16 @@ export class GuildSearchService {
|
||||
})
|
||||
.map((id) => id.toString());
|
||||
if (channelsToIndex.length > 0) {
|
||||
await Promise.all(
|
||||
channelsToIndex.map((channelId) =>
|
||||
this.workerService.addJob(
|
||||
'indexChannelMessages',
|
||||
{
|
||||
channelId,
|
||||
},
|
||||
{
|
||||
jobKey: `indexChannelMessages-${channelId}`,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
),
|
||||
await mapWithConcurrency(channelsToIndex, CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY, (channelId) =>
|
||||
this.workerService.addJob(
|
||||
'indexChannelMessages',
|
||||
{
|
||||
channelId,
|
||||
},
|
||||
{
|
||||
jobKey: `indexChannelMessages-${channelId}`,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
),
|
||||
);
|
||||
return {indexing: true};
|
||||
@@ -267,16 +268,14 @@ export class GuildSearchService {
|
||||
}
|
||||
|
||||
private async queueIndexingChannels(channelIds: Iterable<string>): Promise<void> {
|
||||
await Promise.all(
|
||||
Array.from(channelIds).map((channelId) =>
|
||||
this.workerService.addJob(
|
||||
'indexChannelMessages',
|
||||
{channelId},
|
||||
{
|
||||
jobKey: `indexChannelMessages-${channelId}`,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
),
|
||||
await mapWithConcurrency(Array.from(channelIds), CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY, (channelId) =>
|
||||
this.workerService.addJob(
|
||||
'indexChannelMessages',
|
||||
{channelId},
|
||||
{
|
||||
jobKey: `indexChannelMessages-${channelId}`,
|
||||
maxAttempts: 3,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -290,42 +289,36 @@ export class GuildSearchService {
|
||||
const accessibleChannels = new Map<string, Channel>();
|
||||
const unindexedChannelIds = new Set<string>();
|
||||
const guildNsfwLevels = new Map<string, number>();
|
||||
const [guildDataResults, guildChannelsResults, viewableChannelsResults] = await Promise.all([
|
||||
Promise.all(guildIds.map((guildId) => this.gatewayService.getGuildData({guildId, userId}))),
|
||||
Promise.all(guildIds.map((guildId) => this.channelRepository.listGuildChannels(guildId))),
|
||||
Promise.all(guildIds.map((guildId) => this.gatewayService.getViewableChannels({guildId, userId}))),
|
||||
]);
|
||||
for (let i = 0; i < guildIds.length; i++) {
|
||||
const guildData = guildDataResults[i];
|
||||
if (guildData) {
|
||||
guildNsfwLevels.set(guildIds[i]!.toString(), guildData.nsfw_level);
|
||||
}
|
||||
}
|
||||
const permissionChecks: Array<{
|
||||
channel: Channel;
|
||||
guildId: GuildID;
|
||||
}> = [];
|
||||
for (let i = 0; i < guildIds.length; i++) {
|
||||
const guildChannels = guildChannelsResults[i]!;
|
||||
if (guildChannels.length === 0) {
|
||||
continue;
|
||||
await mapWithConcurrency(guildIds, GUILD_FANOUT_CONCURRENCY, async (guildId) => {
|
||||
const [guildData, guildChannels, viewableChannels] = await Promise.all([
|
||||
this.gatewayService.getGuildData({guildId, userId}),
|
||||
this.channelRepository.listGuildChannels(guildId),
|
||||
this.gatewayService.getViewableChannels({guildId, userId}),
|
||||
]);
|
||||
if (guildData) {
|
||||
guildNsfwLevels.set(guildId.toString(), guildData.nsfw_level);
|
||||
}
|
||||
const viewableChannelIds = new Set(viewableChannelsResults[i]!.map((channelId) => channelId.toString()));
|
||||
const viewableChannelIds = new Set(viewableChannels.map((channelId) => channelId.toString()));
|
||||
for (const channel of guildChannels) {
|
||||
if (viewableChannelIds.has(channel.id.toString())) {
|
||||
permissionChecks.push({channel, guildId: guildIds[i]!});
|
||||
permissionChecks.push({channel, guildId});
|
||||
}
|
||||
}
|
||||
}
|
||||
const permissionResults = await Promise.all(
|
||||
permissionChecks.map(({channel, guildId}) =>
|
||||
});
|
||||
const permissionResults = await mapWithConcurrency(
|
||||
permissionChecks,
|
||||
PERMISSION_CHECK_CONCURRENCY,
|
||||
({channel, guildId}) =>
|
||||
this.gatewayService.checkPermission({
|
||||
guildId,
|
||||
userId,
|
||||
channelId: channel.id,
|
||||
permission: Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < permissionChecks.length; i++) {
|
||||
if (!permissionResults[i]) {
|
||||
|
||||
@@ -36,7 +36,6 @@ import type {
|
||||
TemplateSerializedGuild,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
|
||||
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
|
||||
import {requireEmailVerified} from '../../../auth/EmailVerificationUtils';
|
||||
import type {ChannelID, GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
||||
import {createChannelID, createGuildID, createRoleID, guildIdToRoleId} from '../../../BrandedTypes';
|
||||
import type {IChannelRepository} from '../../../channel/IChannelRepository';
|
||||
@@ -257,7 +256,6 @@ export class GuildOperationsService {
|
||||
if (user.isUnclaimedAccount()) {
|
||||
throw new UnclaimedAccountCannotCreateGuildsError();
|
||||
}
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
const currentGuildCount = await this.guildRepository.countUserGuilds(user.id);
|
||||
const ctx = createLimitMatchContext({user});
|
||||
const maxGuilds = resolveLimitSafe(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {User} from '../../models/User';
|
||||
import {GuildOperationsService} from '../services/data/GuildOperationsService';
|
||||
|
||||
const REACHED_GUILD_COUNT = new Error('reached guild count lookup');
|
||||
|
||||
function createServiceStoppingAtGuildCount(): GuildOperationsService {
|
||||
const guildRepository = {
|
||||
countUserGuilds: (): never => {
|
||||
throw REACHED_GUILD_COUNT;
|
||||
},
|
||||
};
|
||||
const unused = null as never;
|
||||
return new GuildOperationsService(
|
||||
guildRepository as never,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
unused,
|
||||
);
|
||||
}
|
||||
|
||||
function createUser(overrides: {emailVerified: boolean; isBot?: boolean}): User {
|
||||
return {
|
||||
id: 1n,
|
||||
isBot: overrides.isBot ?? false,
|
||||
emailVerified: overrides.emailVerified,
|
||||
isUnclaimedAccount: () => false,
|
||||
} as unknown as User;
|
||||
}
|
||||
|
||||
describe('guild creation email verification', () => {
|
||||
it('does not gate guild creation on email verification inside the domain operation', async () => {
|
||||
const service = createServiceStoppingAtGuildCount();
|
||||
await expect(
|
||||
service.createGuild({user: createUser({emailVerified: false}), data: {name: 'Stock Community'} as never}),
|
||||
).rejects.toBe(REACHED_GUILD_COUNT);
|
||||
});
|
||||
|
||||
it('reaches the same point for a verified user', async () => {
|
||||
const service = createServiceStoppingAtGuildCount();
|
||||
await expect(
|
||||
service.createGuild({user: createUser({emailVerified: true}), data: {name: 'Stock Community'} as never}),
|
||||
).rejects.toBe(REACHED_GUILD_COUNT);
|
||||
});
|
||||
|
||||
it('still rejects bots before any guild count lookup', async () => {
|
||||
const service = createServiceStoppingAtGuildCount();
|
||||
await expect(
|
||||
service.createGuild({
|
||||
user: createUser({emailVerified: true, isBot: true}),
|
||||
data: {name: 'Stock Community'} as never,
|
||||
}),
|
||||
).rejects.toMatchObject({code: APIErrorCodes.BOTS_CANNOT_CREATE_GUILDS});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,9 @@
|
||||
import {domainToASCII} from 'node:url';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import type {RequestUrlPolicy} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
|
||||
import {Config} from '../Config';
|
||||
|
||||
interface SsoConfigValidationInput {
|
||||
enabled: boolean;
|
||||
@@ -21,7 +23,16 @@ interface NormalizedSsoConfigValidationResult extends SsoConfigValidationInput {
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const SSO_REQUEST_URL_POLICY = createPublicInternetRequestUrlPolicy();
|
||||
let ssoRequestUrlPolicy: RequestUrlPolicy | null = null;
|
||||
|
||||
export function getSsoRequestUrlPolicy(): RequestUrlPolicy {
|
||||
if (ssoRequestUrlPolicy === null) {
|
||||
ssoRequestUrlPolicy = createPublicInternetRequestUrlPolicy({
|
||||
allowPrivateAddresses: Config.auth.ssoAllowPrivateAddresses,
|
||||
});
|
||||
}
|
||||
return ssoRequestUrlPolicy;
|
||||
}
|
||||
const DOMAIN_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
|
||||
function normalizeOptionalSsoString(value: string | null): string | null {
|
||||
@@ -97,12 +108,12 @@ export async function validateSsoPublicOutboundUrl(rawUrl: string, fieldName: st
|
||||
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.INVALID_URL_FORMAT);
|
||||
}
|
||||
try {
|
||||
await SSO_REQUEST_URL_POLICY.validate(parsedUrl, {
|
||||
await getSsoRequestUrlPolicy().validate(parsedUrl, {
|
||||
phase: 'initial',
|
||||
redirectCount: 0,
|
||||
});
|
||||
} catch {
|
||||
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.INVALID_URL_FORMAT);
|
||||
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.URL_NOT_PUBLICLY_ROUTABLE);
|
||||
}
|
||||
// Return the caller's exact input rather than parsedUrl.toString(), which would normalize
|
||||
// the URL (e.g. appending a trailing slash) and break exact-match comparisons such as the
|
||||
|
||||
@@ -236,6 +236,12 @@ export async function resolveBlueskyOAuthService(
|
||||
_blueskyOAuthConfigSignature = signature;
|
||||
return service;
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.error({error}, 'Bluesky OAuth signing keys were rejected – disabling Bluesky OAuth.');
|
||||
_blueskyOAuthService = getDisabledBlueskyOAuthService();
|
||||
_blueskyOAuthConfigSignature = signature;
|
||||
return _blueskyOAuthService;
|
||||
})
|
||||
.finally(() => {
|
||||
_blueskyOAuthInitializationPromise = null;
|
||||
_blueskyOAuthInitializationSignature = null;
|
||||
|
||||
@@ -14978,7 +14978,7 @@
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Search messages"}},
|
||||
"description": "Searches for messages across guilds and channels accessible to the authenticated user.",
|
||||
"description": "Searches for messages across guilds and channels accessible to the authenticated user. Bots must supply a context guild or channel and may only use the current scope.",
|
||||
"security": [{"sessionToken": []}],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -35114,7 +35114,8 @@
|
||||
"properties": {
|
||||
"ticket": {"type": "string", "description": "Email change ticket identifier"},
|
||||
"new_email": {"$ref": "#/components/schemas/EmailType"},
|
||||
"original_proof": {"type": "string", "description": "Proof token obtained from verifying the original email"}
|
||||
"original_proof": {"type": "string", "description": "Proof token obtained from verifying the original email"},
|
||||
"new_password": {"$ref": "#/components/schemas/PasswordType"}
|
||||
},
|
||||
"required": ["ticket", "new_email", "original_proof"]
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {Channel} from '../models/Channel';
|
||||
import {getMessageSearchService} from '../SearchFactory';
|
||||
import type {IUserRepository} from '../user/IUserRepository';
|
||||
import {canUserAccessNsfwContent} from '../utils/AgeUtils';
|
||||
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
|
||||
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
|
||||
import {buildMessageSearchFilters} from './BuildMessageSearchFilters';
|
||||
import {channelNeedsReindexing} from './ChannelIndexingUtils';
|
||||
@@ -270,21 +271,3 @@ export class GlobalSearchService {
|
||||
return canUserAccessNsfwContent(user);
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, TResult>(
|
||||
items: ReadonlyArray<T>,
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<TResult>,
|
||||
): Promise<Array<TResult>> {
|
||||
const results = new Array<TResult>(items.length);
|
||||
let nextIndex = 0;
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = nextIndex++;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index]!, index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, () => worker()));
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {GlobalSearchMessagesRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {DefaultUserOnly, LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '../../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../../RateLimitConfig';
|
||||
@@ -14,11 +16,11 @@ export function SearchController(app: HonoApp) {
|
||||
'/search/messages',
|
||||
RateLimitMiddleware(RateLimitConfigs.SEARCH_MESSAGES),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
OpenAPI({
|
||||
operationId: 'search_messages',
|
||||
summary: 'Search messages',
|
||||
description: 'Searches for messages across guilds and channels accessible to the authenticated user.',
|
||||
description:
|
||||
'Searches for messages across guilds and channels accessible to the authenticated user. Bots must supply a context guild or channel and may only use the current scope.',
|
||||
responseSchema: MessageSearchResponse,
|
||||
statusCode: 200,
|
||||
security: ['bearerToken', 'sessionToken'],
|
||||
@@ -27,7 +29,11 @@ export function SearchController(app: HonoApp) {
|
||||
Validator('json', GlobalSearchMessagesRequest),
|
||||
async (ctx) => {
|
||||
const params = ctx.req.valid('json');
|
||||
const userId = ctx.get('user').id;
|
||||
const user = ctx.get('user');
|
||||
if (user.isBot && (params.scope ?? 'current') !== 'current') {
|
||||
throw InputValidationError.fromCode('scope', ValidationErrorCodes.BOT_SEARCH_SCOPE_UNAVAILABLE);
|
||||
}
|
||||
const userId = user.id;
|
||||
const requestCache = ctx.get('requestCache');
|
||||
const result = await ctx.get('searchService').searchMessages({userId, requestCache, data: params});
|
||||
return ctx.json(result);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
SearchableUser,
|
||||
UserSearchFilters,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {MeilisearchClient} from './MeilisearchClient';
|
||||
import {
|
||||
compactMeiliFilters,
|
||||
@@ -45,6 +46,10 @@ interface MeilisearchAdapterOptions {
|
||||
client: MeilisearchClient;
|
||||
}
|
||||
|
||||
function snowflakeSeconds(snowflake: string): number {
|
||||
return Math.floor(snowflakeToDate(BigInt(snowflake)).getTime() / 1000);
|
||||
}
|
||||
|
||||
function buildMessageFilters(filters: MessageSearchFilters): Array<MeilisearchFilter | undefined> {
|
||||
const clauses: Array<MeilisearchFilter | undefined> = [];
|
||||
if (filters.guildId) clauses.push(meiliTermFilter('guildId', filters.guildId));
|
||||
@@ -109,8 +114,8 @@ function buildMessageFilters(filters: MessageSearchFilters): Array<MeilisearchFi
|
||||
if (filters.excludeAttachmentExtensions && filters.excludeAttachmentExtensions.length > 0) {
|
||||
clauses.push(...meiliExcludeAny('attachmentExtensions', filters.excludeAttachmentExtensions));
|
||||
}
|
||||
if (filters.maxId != null) clauses.push(meiliRangeFilter('id', {lt: filters.maxId}));
|
||||
if (filters.minId != null) clauses.push(meiliRangeFilter('id', {gt: filters.minId}));
|
||||
if (filters.maxId != null) clauses.push(meiliRangeFilter('createdAt', {lt: snowflakeSeconds(filters.maxId)}));
|
||||
if (filters.minId != null) clauses.push(meiliRangeFilter('createdAt', {gt: snowflakeSeconds(filters.minId)}));
|
||||
return compactMeiliFilters(clauses);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class FakeMeilisearchClient implements MeilisearchClient {
|
||||
this.indexExists = true;
|
||||
return this.nextTask() as TResponse;
|
||||
}
|
||||
if (method === 'PUT' && path.includes('/settings/')) {
|
||||
if ((method === 'PUT' || method === 'PATCH') && path.includes('/settings/')) {
|
||||
return this.nextTask() as TResponse;
|
||||
}
|
||||
if (method === 'POST' && path.endsWith('/documents')) {
|
||||
@@ -75,8 +75,12 @@ describe('MeilisearchMessageAdapter', () => {
|
||||
'PUT /indexes/messages/settings/searchable-attributes',
|
||||
'PUT /indexes/messages/settings/filterable-attributes',
|
||||
'PUT /indexes/messages/settings/sortable-attributes',
|
||||
'PATCH /indexes/messages/settings/pagination',
|
||||
]);
|
||||
expect(client.waitedTaskUids).toEqual([1, 2, 3, 4]);
|
||||
expect(client.waitedTaskUids).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(client.requests.find((request) => request.path.endsWith('/settings/pagination'))?.body).toEqual({
|
||||
maxTotalHits: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds Meilisearch search requests from message filters', async () => {
|
||||
@@ -127,6 +131,6 @@ describe('MeilisearchMessageAdapter', () => {
|
||||
|
||||
expect(client.waitedTaskUids).toEqual([]);
|
||||
await adapter.refreshIndex();
|
||||
expect(client.waitedTaskUids).toEqual([4]);
|
||||
expect(client.waitedTaskUids).toEqual([5]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {joinMeiliFilters} from './MeilisearchFilterUtils';
|
||||
import type {MeilisearchIndexDefinition} from './MeilisearchIndexDefinitions';
|
||||
|
||||
const MAX_SEARCH_LIMIT = 1000;
|
||||
const MAX_TOTAL_HITS = 10000;
|
||||
|
||||
interface MeilisearchIndexAdapterOptions<TFilters> {
|
||||
client: MeilisearchClient;
|
||||
@@ -57,9 +58,10 @@ export class MeilisearchIndexAdapter<
|
||||
await this.waitForTask(task.taskUid);
|
||||
}
|
||||
await Promise.all([
|
||||
this.applySetting('searchable-attributes', this.indexDefinition.searchableAttributes),
|
||||
this.applySetting('filterable-attributes', this.indexDefinition.filterableAttributes),
|
||||
this.applySetting('sortable-attributes', this.indexDefinition.sortableAttributes),
|
||||
this.applySetting('PUT', 'searchable-attributes', this.indexDefinition.searchableAttributes),
|
||||
this.applySetting('PUT', 'filterable-attributes', this.indexDefinition.filterableAttributes),
|
||||
this.applySetting('PUT', 'sortable-attributes', this.indexDefinition.sortableAttributes),
|
||||
this.applySetting('PATCH', 'pagination', {maxTotalHits: MAX_TOTAL_HITS}),
|
||||
]);
|
||||
this.initialized = true;
|
||||
}
|
||||
@@ -177,9 +179,13 @@ export class MeilisearchIndexAdapter<
|
||||
}
|
||||
}
|
||||
|
||||
private async applySetting(setting: string, value: Array<string>): Promise<void> {
|
||||
private async applySetting(
|
||||
method: 'PUT' | 'PATCH',
|
||||
setting: string,
|
||||
value: Array<string> | {maxTotalHits: number},
|
||||
): Promise<void> {
|
||||
const task = await this.client.request<MeilisearchTask>(
|
||||
'PUT',
|
||||
method,
|
||||
`/indexes/${encodeURIComponent(this.indexDefinition.uid)}/settings/${setting}`,
|
||||
value,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ export const MEILISEARCH_INDEX_DEFINITIONS: Record<FluxerSearchIndexName, Meilis
|
||||
searchableAttributes: ['content', 'embedContent'],
|
||||
filterableAttributes: [
|
||||
'id',
|
||||
'createdAt',
|
||||
'channelId',
|
||||
'guildId',
|
||||
'authorId',
|
||||
|
||||
@@ -224,7 +224,7 @@ export function UserAccountController(app: HonoApp) {
|
||||
const body = ctx.req.valid('json');
|
||||
const result = await ctx
|
||||
.get('emailChangeService')
|
||||
.requestNewEmail(user, body.ticket, body.new_email, body.original_proof);
|
||||
.requestNewEmail(user, body.ticket, body.new_email, body.original_proof, body.new_password);
|
||||
return ctx.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {ms} from 'itty-time';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {EMAIL_CLEARABLE_SUSPICIOUS_ACTIVITY_FLAGS} from '../../auth/AuthEmail';
|
||||
import * as AuthPassword from '../../auth/AuthPassword';
|
||||
import type {User} from '../../models/User';
|
||||
import type {EmailChangeRepository} from '../repositories/auth/EmailChangeRepository';
|
||||
|
||||
@@ -158,6 +159,7 @@ export class EmailChangeService {
|
||||
ticket: string,
|
||||
newEmail: string,
|
||||
originalProof: string,
|
||||
newPassword?: string,
|
||||
): Promise<RequestNewEmailResult> {
|
||||
const {email, emailDnsValidation, users, rateLimit} = this.apiContext.services;
|
||||
const row = await getActiveChangeTicketForUser(this.repo, ticket, user.id);
|
||||
@@ -182,6 +184,9 @@ export class EmailChangeService {
|
||||
if (existing && existing.id !== user.id) {
|
||||
throw InputValidationError.fromCode('new_email', ValidationErrorCodes.EMAIL_ALREADY_IN_USE);
|
||||
}
|
||||
if (newPassword != null && (await AuthPassword.isPasswordPwned(this.apiContext, newPassword))) {
|
||||
throw InputValidationError.fromCode('new_password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
|
||||
}
|
||||
assertChangeCooldown(row.new_code_sent_at, this.RESEND_COOLDOWN_MS);
|
||||
await checkChangeRateLimit(rateLimit, {
|
||||
identifier: `email_change:new:${user.id}`,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export async function mapWithConcurrency<T, TResult>(
|
||||
items: ReadonlyArray<T>,
|
||||
concurrency: number,
|
||||
mapper: (item: T, index: number) => Promise<TResult>,
|
||||
): Promise<Array<TResult>> {
|
||||
const results = new Array<TResult>(items.length);
|
||||
let nextIndex = 0;
|
||||
async function worker(): Promise<void> {
|
||||
for (;;) {
|
||||
const index = nextIndex++;
|
||||
if (index >= items.length) return;
|
||||
results[index] = await mapper(items[index]!, index);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, () => worker()));
|
||||
return results;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
|
||||
import type {HttpClient, RequestOptions, StreamResponse} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import type {HttpClient, RequestOptions, RequestUrlPolicy, StreamResponse} from '@pkgs/http_client/src/HttpClientTypes';
|
||||
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
|
||||
|
||||
const requestUrlPolicy = createPublicInternetRequestUrlPolicy();
|
||||
@@ -9,27 +9,35 @@ const client: HttpClient = createHttpClient({
|
||||
userAgent: 'fluxer-api',
|
||||
requestUrlPolicy,
|
||||
});
|
||||
const redirectScopedClients = new Map<number, HttpClient>();
|
||||
const scopedClients = new Map<RequestUrlPolicy, Map<number, HttpClient>>();
|
||||
|
||||
interface SendRequestOptions {
|
||||
maxRedirects?: number;
|
||||
requestUrlPolicy?: RequestUrlPolicy;
|
||||
}
|
||||
|
||||
function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
|
||||
if (!options?.maxRedirects) {
|
||||
const policy = options?.requestUrlPolicy ?? requestUrlPolicy;
|
||||
const maxRedirects = options?.maxRedirects ?? 0;
|
||||
if (policy === requestUrlPolicy && maxRedirects === 0) {
|
||||
return client;
|
||||
}
|
||||
const existingClient = redirectScopedClients.get(options.maxRedirects);
|
||||
let clientsForPolicy = scopedClients.get(policy);
|
||||
if (!clientsForPolicy) {
|
||||
clientsForPolicy = new Map<number, HttpClient>();
|
||||
scopedClients.set(policy, clientsForPolicy);
|
||||
}
|
||||
const existingClient = clientsForPolicy.get(maxRedirects);
|
||||
if (existingClient) {
|
||||
return existingClient;
|
||||
}
|
||||
const redirectScopedClient = createHttpClient({
|
||||
const scopedClient = createHttpClient({
|
||||
userAgent: 'fluxer-api',
|
||||
maxRedirects: options.maxRedirects,
|
||||
requestUrlPolicy,
|
||||
...(maxRedirects > 0 ? {maxRedirects} : {}),
|
||||
requestUrlPolicy: policy,
|
||||
});
|
||||
redirectScopedClients.set(options.maxRedirects, redirectScopedClient);
|
||||
return redirectScopedClient;
|
||||
clientsForPolicy.set(maxRedirects, scopedClient);
|
||||
return scopedClient;
|
||||
}
|
||||
|
||||
export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<!--{{FLUXER_BOOTSTRAP}}-->
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"@radix-ui/react-switch": "catalog:",
|
||||
"@simplewebauthn/browser": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"animejs": "4.5.0",
|
||||
"bowser": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"codemirror": "^6.0.2",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {dirname, join, relative, resolve} from 'node:path';
|
||||
import {SKELETON_SURFACE_TOKENS} from '@app/features/app/components/skeleton/SkeletonSurfaceContract';
|
||||
|
||||
type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
|
||||
|
||||
@@ -407,6 +408,11 @@ function renderValueMap(name: string, values: ReadonlyMap<string, string>): stri
|
||||
return `export const ${name}: Readonly<Record<string, string>> = {\n${body}\n};`;
|
||||
}
|
||||
|
||||
function renderNameUnion(definitions: ReadonlyArray<VariableDefinition>): string {
|
||||
const body = definitions.map((definition) => `\t| ${JSON.stringify(definition.name)}`).join('\n');
|
||||
return `export type ThemeVariableName =\n${body};`;
|
||||
}
|
||||
|
||||
function renderDefinitions(definitions: ReadonlyArray<VariableDefinition>): string {
|
||||
const body = definitions
|
||||
.map(
|
||||
@@ -439,6 +445,8 @@ export interface ThemeVariableDefinition {
|
||||
\tsource: string;
|
||||
}
|
||||
|
||||
${renderNameUnion(definitions)}
|
||||
|
||||
${renderDefinitions(definitions)}
|
||||
|
||||
${renderStringArray(
|
||||
@@ -456,9 +464,361 @@ ${renderValueMap('THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES', lightDefaults)}
|
||||
`;
|
||||
}
|
||||
|
||||
interface SkeletonSurfaceInvariant {
|
||||
file: string;
|
||||
requires?: ReadonlyArray<string>;
|
||||
requiresPattern?: ReadonlyArray<string>;
|
||||
forbids?: ReadonlyArray<string>;
|
||||
forbidsPattern?: ReadonlyArray<string>;
|
||||
counts?: Readonly<Record<string, number>>;
|
||||
minimums?: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
const SKELETON_CHROME_BORDER_TOKEN = 'var(--skeleton-chrome-border)';
|
||||
|
||||
const SKELETON_SURFACE_INVARIANTS: ReadonlyArray<SkeletonSurfaceInvariant> = [
|
||||
{
|
||||
file: 'src/app/globals.css',
|
||||
counts: {
|
||||
'--skeleton-chrome-border-color': 2,
|
||||
'--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);': 1,
|
||||
'--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);': 1,
|
||||
'--chat-horizontal-padding-default: 1rem;': 1,
|
||||
'--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);': 1,
|
||||
'--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);': 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/ChatSkeleton.module.css',
|
||||
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 1},
|
||||
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
|
||||
forbids: ['scopeBadge', 'scope-badge'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/ChatSkeleton.tsx',
|
||||
requires: [
|
||||
"import composerWrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';",
|
||||
'composerWrapperStyles.composerRoot',
|
||||
"'--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx),",
|
||||
"'--font-size': remFromPx(messagePresentation.fontSizePx),",
|
||||
"'--message-group-spacing': remFromPx(messagePresentation.groupSpacingPx),",
|
||||
"'--message-compact-timestamp-width': remFromPx(messagePresentation.compactTimestampWidthPx),",
|
||||
],
|
||||
forbids: ['scopeBadge', 'scope-badge', '@tanstack/react-virtual'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/DiscoverySkeleton.module.css',
|
||||
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 2},
|
||||
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/FriendsSkeleton.module.css',
|
||||
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 4},
|
||||
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
|
||||
forbids: ['.activeNowPreview'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/FriendsSkeleton.tsx',
|
||||
forbids: ['LIVE_BADGE', 'CONTEXT_CHEVRON', 'ACTIVE_NOW_CONTEXT_ICON_SIZE'],
|
||||
requires: ['function resolveFriendsListSections('],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/GuildRailSkeleton.module.css',
|
||||
requires: [
|
||||
'.outageSlot {\n\tmin-height: var(--guild-list-item-box-size);\n}',
|
||||
'.sectionTrailingGap {\n\tpadding-bottom: var(--guild-list-item-gap);\n}',
|
||||
'.itemsTrailingGapCancel {\n\tmargin-bottom: calc(-1 * var(--guild-list-item-gap));\n}',
|
||||
'background-color: var(--guilds-layout-item-bg);',
|
||||
".item[data-selected='true'] .fluxerIcon",
|
||||
'clip-path: inset(0);',
|
||||
'\ttransform: translateY(0rem);',
|
||||
],
|
||||
forbids: ['--guild-list-indicator-', '::before'],
|
||||
counts: {'z-index: 0;': 3, 'z-index: 1;': 3},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/GuildRailSkeleton.tsx',
|
||||
requires: [
|
||||
"import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css';",
|
||||
'guildStyles.guildIndicator',
|
||||
'guildStyles.guildIndicatorBar',
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
'const InlineDMPlaceholder = ',
|
||||
],
|
||||
requiresPattern: ['<ChatCircleIcon[^>]*weight="fill"[^>]*className=\\{styles\\.fluxerIconGlyph\\}'],
|
||||
forbids: ['data-indicator', 'GUILD_RAIL_INDICATOR_METRICS', 'Math.round'],
|
||||
counts: {'styles.sectionTrailingGap': 2, 'styles.itemsTrailingGapCancel': 1},
|
||||
},
|
||||
{
|
||||
file: 'src/features/ui/components/Scroller.module.css',
|
||||
counts: {'clip-path: inset(0);': 1},
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/MemberListSkeleton.tsx',
|
||||
counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2},
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/textarea/InputWrapper.module.css',
|
||||
requires: ['.composerRoot:has(.statusTypingSlot)::before'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.module.css',
|
||||
requires: [
|
||||
'.guildListScrollContainer.guildListScrollContainer {\n\toverflow-anchor: auto;\n}',
|
||||
'--layout-user-area-overlay-height',
|
||||
'\tpadding-bottom: calc(\n\t\tvar(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +\n\t\tvar(--spacing-2)\n\t);',
|
||||
'.messageBubbleIcon {\n\theight: 1.75rem;\n\twidth: 1.75rem;',
|
||||
'.guildIndicator {',
|
||||
'.guildIndicatorBar {',
|
||||
],
|
||||
forbids: ['.fluxerSymbolIcon'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.tsx',
|
||||
forbids: ['useListScrollAnchor', 'usePersistentScrollAnchor', '@tanstack/react-virtual'],
|
||||
counts: {'scrollNode.scrollTop = ': 2},
|
||||
},
|
||||
{
|
||||
file: 'src/features/discovery/discovery/DiscoveryPage.tsx',
|
||||
counts: {"import {useVirtualizer} from '@tanstack/react-virtual';": 1},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/LongPressable.ts',
|
||||
forbids: ['useImperativeHandle'],
|
||||
requires: ['useMergeRefs'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListIndicator.ts',
|
||||
requires: ['height: remFromPx(resolveGuildListIndicatorHeight(request))'],
|
||||
forbids: ['transform', 'scale', 'getAppZoomFactor', 'getAppRemScale'],
|
||||
counts: {
|
||||
'function resolveGuildListIndicatorHeight': 1,
|
||||
'export function resolveGuildListIndicatorBarTarget': 1,
|
||||
'export ': 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/FluxerButton.tsx',
|
||||
requires: [
|
||||
'const shouldShowHoverState = isHovering || contextMenuOpen;',
|
||||
'ChatCircleIcon',
|
||||
'styles.messageBubbleIcon',
|
||||
'styles.contextMenuHover',
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
],
|
||||
forbids: ['scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/FavoritesButton.tsx',
|
||||
requires: ['resolveGuildListIndicatorBarTarget('],
|
||||
forbids: ['scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx',
|
||||
requires: [
|
||||
'const shouldShowHoverState = isHovering || contextMenuOpen;',
|
||||
'guildStyles.contextMenuHover',
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
],
|
||||
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx',
|
||||
requires: [
|
||||
'const shouldShowHoverState = isHovering || contextMenuOpen;',
|
||||
'const FOLDER_BACKGROUND_FADE_TARGET = Object.freeze({opacity: 0});',
|
||||
"const FOLDER_GUILDS_COLLAPSE_TARGET = Object.freeze({opacity: 0, translateY: '-0.5rem'});",
|
||||
"to={{opacity: 1, translateY: '0rem'}}",
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
'guildStyles.guildIndicatorBar',
|
||||
],
|
||||
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css',
|
||||
forbids: ['.folderIndicator'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemContent.tsx',
|
||||
requires: [
|
||||
'const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.GUILD_RAIL);',
|
||||
'if (isInitialMount || selectedFromThisRow || !props.isSelected) return;',
|
||||
],
|
||||
forbids: ['preserveInitialScrollPosition', '@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx',
|
||||
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/ChannelMembers.tsx',
|
||||
forbids: ['@tanstack/react-virtual'],
|
||||
requires: ['styles.virtualRow'],
|
||||
},
|
||||
];
|
||||
|
||||
const SKELETON_STYLESHEET_DIRECTORIES: ReadonlyArray<string> = [
|
||||
'src/features/app/components/skeleton',
|
||||
'src/features/channel/components',
|
||||
'src/features/user/components/profile',
|
||||
];
|
||||
const SKELETON_STYLESHEET_SUFFIX = 'Skeleton.module.css';
|
||||
const BORDER_DECLARATION_PATTERN =
|
||||
/(?<![\w-])border(?:-(?:top|right|bottom|left|block|inline|block-start|block-end|inline-start|inline-end))?\s*:\s*([^;}]+)/g;
|
||||
const RAW_COLOR_PATTERN = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\(/;
|
||||
|
||||
function collectSkeletonStylesheets(appDir: string): ReadonlyArray<string> {
|
||||
const files: Array<string> = [];
|
||||
const visit = (directory: string) => {
|
||||
for (const entry of readdirSync(directory, {withFileTypes: true})) {
|
||||
const absolutePath = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(SKELETON_STYLESHEET_SUFFIX)) continue;
|
||||
files.push(toPosixPath(relative(appDir, absolutePath)));
|
||||
}
|
||||
};
|
||||
for (const directory of SKELETON_STYLESHEET_DIRECTORIES) {
|
||||
visit(join(appDir, directory));
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
interface SkeletonSharedRuleSource {
|
||||
readonly file: string;
|
||||
readonly selectors: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
const SKELETON_SHARED_RULE_SOURCES: ReadonlyArray<SkeletonSharedRuleSource> = [
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.module.css',
|
||||
selectors: ['.guildIndicator', '.guildIndicatorBar'],
|
||||
},
|
||||
];
|
||||
const CUSTOM_PROPERTY_REFERENCE_PATTERN = /var\(\s*(--[a-zA-Z0-9-]+)/g;
|
||||
|
||||
function assertSkeletonSharedRuleTokens(appDir: string): void {
|
||||
const violations: Array<string> = [];
|
||||
const declaredTokens = new Set<string>(SKELETON_SURFACE_TOKENS);
|
||||
for (const source of SKELETON_SHARED_RULE_SOURCES) {
|
||||
const contents = readFileSync(join(appDir, source.file), 'utf8');
|
||||
for (const selector of source.selectors) {
|
||||
const rule = new RegExp(`^${selector.replace('.', '\\.')}\\s*\\{([^}]*)\\}`, 'mu').exec(contents);
|
||||
if (rule == null) {
|
||||
violations.push(`${source.file} must declare ${selector}; skeleton markup renders through that rule.`);
|
||||
continue;
|
||||
}
|
||||
for (const match of rule[1].matchAll(CUSTOM_PROPERTY_REFERENCE_PATTERN)) {
|
||||
const token = match[1];
|
||||
if (declaredTokens.has(token)) continue;
|
||||
violations.push(
|
||||
`${source.file} ${selector} reads ${token}, which skeleton markup also renders through; ` +
|
||||
'list it in SKELETON_SURFACE_TOKENS.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const SKELETON_CHROME_BORDER_COLOR_DECLARATION = /--skeleton-chrome-border-color\s*:\s*([^;}]+)/g;
|
||||
|
||||
function assertSkeletonChromeBorderColourIsSoftened(appDir: string, violations: Array<string>): void {
|
||||
const file = 'src/app/globals.css';
|
||||
const contents = readFileSync(join(appDir, file), 'utf8');
|
||||
for (const match of contents.matchAll(SKELETON_CHROME_BORDER_COLOR_DECLARATION)) {
|
||||
const value = match[1].trim();
|
||||
if (value.startsWith('var(--background-modifier-accent')) {
|
||||
violations.push(
|
||||
`${file} declares --skeleton-chrome-border-color as ${JSON.stringify(value)}; ` +
|
||||
'skeleton chrome must use the softened colour, never the raw accent.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSkeletonBorderTokens(appDir: string): void {
|
||||
const violations: Array<string> = [];
|
||||
assertSkeletonChromeBorderColourIsSoftened(appDir, violations);
|
||||
for (const file of collectSkeletonStylesheets(appDir)) {
|
||||
const contents = readFileSync(join(appDir, file), 'utf8');
|
||||
if (contents.includes('--skeleton-chrome-border-color')) {
|
||||
violations.push(
|
||||
`${file} names --skeleton-chrome-border-color directly; skeleton chrome must use ${SKELETON_CHROME_BORDER_TOKEN}.`,
|
||||
);
|
||||
}
|
||||
for (const match of contents.matchAll(BORDER_DECLARATION_PATTERN)) {
|
||||
const value = match[1].trim();
|
||||
if (RAW_COLOR_PATTERN.test(value) || value.includes('--background-modifier-accent')) {
|
||||
violations.push(
|
||||
`${file} writes a literal border colour in ${JSON.stringify(`border: ${value}`)}; ` +
|
||||
'skeleton borders must reference a shared token.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSkeletonSurfaceInvariants(appDir: string): void {
|
||||
const violations: Array<string> = [];
|
||||
for (const invariant of SKELETON_SURFACE_INVARIANTS) {
|
||||
const path = join(appDir, invariant.file);
|
||||
if (!existsSync(path)) {
|
||||
violations.push(`${invariant.file} is missing; a skeleton surface invariant depends on it.`);
|
||||
continue;
|
||||
}
|
||||
const contents = readFileSync(path, 'utf8');
|
||||
for (const required of invariant.requires ?? []) {
|
||||
if (!contents.includes(required)) {
|
||||
violations.push(`${invariant.file} must contain ${JSON.stringify(required)}.`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of invariant.forbids ?? []) {
|
||||
if (contents.includes(forbidden)) {
|
||||
violations.push(`${invariant.file} must not contain ${JSON.stringify(forbidden)}.`);
|
||||
}
|
||||
}
|
||||
for (const source of invariant.requiresPattern ?? []) {
|
||||
if (!new RegExp(source, 'u').test(contents)) {
|
||||
violations.push(`${invariant.file} must match /${source}/.`);
|
||||
}
|
||||
}
|
||||
for (const source of invariant.forbidsPattern ?? []) {
|
||||
if (new RegExp(source, 'u').test(contents)) {
|
||||
violations.push(`${invariant.file} must not match /${source}/.`);
|
||||
}
|
||||
}
|
||||
for (const [needle, expected] of Object.entries(invariant.counts ?? {})) {
|
||||
const actual = contents.split(needle).length - 1;
|
||||
if (actual !== expected) {
|
||||
violations.push(`${invariant.file} must contain ${needle} exactly ${expected} time(s); found ${actual}.`);
|
||||
}
|
||||
}
|
||||
for (const [needle, minimum] of Object.entries(invariant.minimums ?? {})) {
|
||||
const actual = contents.split(needle).length - 1;
|
||||
if (actual < minimum) {
|
||||
violations.push(`${invariant.file} must contain ${needle} at least ${minimum} time(s); found ${actual}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = resolve(scriptDir, '..');
|
||||
assertSkeletonSurfaceInvariants(appDir);
|
||||
assertSkeletonSharedRuleTokens(appDir);
|
||||
assertSkeletonBorderTokens(appDir);
|
||||
const outputPath = join(appDir, 'src', 'features', 'theme', 'variables', 'ThemeVariableManifest.ts');
|
||||
const contents = render(appDir);
|
||||
if (process.argv.includes('--check')) {
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
import {promises as fs} from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type {PrecacheEntry} from '@app/features/platform/service_worker/WorkerAppShell';
|
||||
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
interface PrecacheEntry {
|
||||
url: string;
|
||||
revision: string;
|
||||
}
|
||||
|
||||
const PRECACHE_ROOT_FILES = ['index.html', 'manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
|
||||
const PRECACHE_ROOT_FILES = ['manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
|
||||
|
||||
const NEVER_PRECACHED_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf', '.eot'];
|
||||
|
||||
@@ -38,11 +34,7 @@ async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
|
||||
for (const file of PRECACHE_ROOT_FILES) {
|
||||
const filePath = path.join(DIST_DIR, file);
|
||||
try {
|
||||
const revision = await fileRevision(filePath);
|
||||
entries.set(`/${file}`, revision);
|
||||
if (file === 'index.html') {
|
||||
entries.set('/', revision);
|
||||
}
|
||||
entries.set(`/${file}`, await fileRevision(filePath));
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -19,11 +19,8 @@ import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
|
||||
import {usePlatformClasses} from '@app/features/app/hooks/usePlatformClasses';
|
||||
import {useServiceWorkerBadge} from '@app/features/app/hooks/useServiceWorkerBadge';
|
||||
import {useTabKeyFocusGuard} from '@app/features/app/hooks/useTabKeyFocusGuard';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext';
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
@@ -35,6 +32,7 @@ import {startDesktopLocaleBridge} from '@app/features/platform/utils/DesktopLoca
|
||||
import {PremiumCheckoutReturnWatcher} from '@app/features/premium/components/PremiumCheckoutReturnWatcher';
|
||||
import {QUICK_SWITCHER_PORTAL_ID} from '@app/features/search/components/quick_switcher/QuickSwitcherConstants';
|
||||
import {useCustomThemeStyle} from '@app/features/theme/hooks/useCustomThemeStyle';
|
||||
import {useRemScaleTracking} from '@app/features/theme/hooks/useRemScaleTracking';
|
||||
import {useThemeCssVariables} from '@app/features/theme/hooks/useThemeCssVariables';
|
||||
import Theme from '@app/features/theme/state/Theme';
|
||||
import ThemeLibrary from '@app/features/theme/state/ThemeLibrary';
|
||||
@@ -127,12 +125,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
}, []),
|
||||
);
|
||||
const handleSkipLinkFocus = useTabKeyFocusGuard();
|
||||
const isSplashScreenActive =
|
||||
Authentication.isAuthenticated &&
|
||||
!DeveloperOptions.bypassSplashScreen &&
|
||||
(GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes);
|
||||
useInertBackground(ringsContainerRef, hasBlockingModal || topPopoutRequiresBackdrop);
|
||||
useInertBackground(overlayScopeRef, isSplashScreenActive);
|
||||
useEffect(() => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
@@ -177,6 +170,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
}, [isNative, firstClickPassThroughWhenUnfocused]);
|
||||
useDesktopAllowTransparency(isNative);
|
||||
useWindowEventListeners({preventDocumentScroll: !isNative});
|
||||
useRemScaleTracking();
|
||||
usePlatformClasses(platform, isNative);
|
||||
useThemeCssVariables({
|
||||
effectiveTheme: Theme.effectiveTheme,
|
||||
|
||||
@@ -103,6 +103,25 @@ var {
|
||||
--input-wrapper-padding-bottom: 0.5rem;
|
||||
--textarea-top-bar-height: 2.5rem;
|
||||
|
||||
--chat-horizontal-padding-default: 1rem;
|
||||
--message-compact-horizontal-padding: min(
|
||||
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)),
|
||||
1rem
|
||||
);
|
||||
--textarea-font-size: var(--font-size, 1rem);
|
||||
--textarea-button-height: 2rem;
|
||||
--textarea-button-icon-size: 1.375rem;
|
||||
--textarea-button-compact-height: 2rem;
|
||||
--textarea-button-compact-icon-size: 1.25rem;
|
||||
--textarea-container-padding-x: 0px;
|
||||
--textarea-line-height: calc(var(--textarea-font-size) * 1.375);
|
||||
--textarea-content-offset: max(0rem, calc((var(--textarea-button-height) - var(--textarea-line-height)) / 2));
|
||||
--textarea-upload-gap: var(--message-gutter, 1rem);
|
||||
--textarea-side-button-padding: max(
|
||||
0px,
|
||||
calc((var(--message-avatar-size, 2.5rem) - var(--textarea-button-height)) / 2)
|
||||
);
|
||||
|
||||
--footer-box-height: 3.625rem;
|
||||
--footer-box-inset: 0.375rem;
|
||||
--footer-box-inset-inline: var(--footer-box-inset);
|
||||
@@ -111,8 +130,38 @@ var {
|
||||
--outline-frame-border-width: 0.0625rem;
|
||||
--composer-mobile-box-height: 3rem;
|
||||
--composer-action-gap: 0.25rem;
|
||||
--composer-surface-color: var(--background-secondary-lighter);
|
||||
--composer-status-line-height: 1.125rem;
|
||||
--composer-status-safe-gap: 0.5rem;
|
||||
--composer-status-safe-area: calc(
|
||||
var(--composer-status-line-height) +
|
||||
var(--composer-status-safe-gap) +
|
||||
var(--composer-status-safe-gap)
|
||||
);
|
||||
--guild-list-item-box-size: 3rem;
|
||||
--guild-list-item-gap: 0.375rem;
|
||||
--guild-list-item-target-size: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
|
||||
--guild-list-item-target-half-extra: calc((var(--guild-list-item-target-size) - var(--guild-list-item-box-size)) / 2);
|
||||
--guild-folder-expanded-surface-size: var(--guild-list-item-box-size);
|
||||
--folder-radius: calc(var(--guild-folder-expanded-surface-size) * 0.3);
|
||||
--mention-badge-small-size: 1.25rem;
|
||||
--mention-badge-medium-size: 1.5rem;
|
||||
--outline-frame-radius-native: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
--guild-list-divider-line-size: 0.125rem;
|
||||
--guild-list-divider-base-gap: var(--guild-list-item-gap);
|
||||
--guild-members-columns: minmax(15rem, 2.2fr) minmax(7.5rem, 1fr) minmax(7.5rem, 1fr) minmax(9.375rem, 1.15fr)
|
||||
minmax(10rem, 1.35fr) 4.5rem;
|
||||
--guild-members-min-width: calc(15rem + 7.5rem + 7.5rem + 9.375rem + 10rem + 4.5rem);
|
||||
--guild-members-select-column-width: 2.75rem;
|
||||
--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);
|
||||
--guild-members-min-width-selectable: calc(var(--guild-members-select-column-width) + var(--guild-members-min-width));
|
||||
--floating-surface-ring-color: color-mix(in srgb, var(--background-modifier-accent) 20%, transparent);
|
||||
--floating-surface-ring-color-strong: color-mix(in srgb, var(--background-modifier-accent) 45%, transparent);
|
||||
--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);
|
||||
--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);
|
||||
--skeleton-opacity-strong: 0.55;
|
||||
--skeleton-opacity-default: 0.45;
|
||||
--skeleton-opacity-muted: 0.35;
|
||||
|
||||
--footer-box-padding-y: max(0rem, calc((var(--footer-box-height) - var(--textarea-button-height, 2rem)) / 2));
|
||||
--composer-mobile-padding-y: max(
|
||||
@@ -124,10 +173,14 @@ var {
|
||||
--textarea-min-height: var(--footer-box-height);
|
||||
--textarea-padding-y: var(--footer-box-padding-y);
|
||||
--composer-box-inset: max(0rem, calc((var(--input-container-min-height) - var(--textarea-min-height)) / 2));
|
||||
--composer-box-inset-inline: min(var(--footer-box-inset), var(--chat-horizontal-padding, var(--spacing-4)));
|
||||
--messages-bottom-clearance: var(--composer-status-safe-area);
|
||||
--composer-box-inset-inline: min(
|
||||
var(--footer-box-inset),
|
||||
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))
|
||||
);
|
||||
--composer-box-padding-inline: max(
|
||||
0rem,
|
||||
calc(var(--chat-horizontal-padding, var(--spacing-4)) - var(--composer-box-inset-inline))
|
||||
calc(var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) - var(--composer-box-inset-inline))
|
||||
);
|
||||
|
||||
--typing-indicator-height: 1rem;
|
||||
@@ -167,6 +220,7 @@ var {
|
||||
--layout-header-height: 3.5rem;
|
||||
--layout-user-area-height: var(--footer-box-height);
|
||||
--layout-user-area-reserved-height: 0px;
|
||||
--layout-voice-connection-height: 0px;
|
||||
--layout-mobile-bottom-nav-reserved-height: 0px;
|
||||
--user-area-box-inset-block-end: calc(var(--footer-box-inset) + var(--outline-frame-border-width));
|
||||
--user-area-content-height: var(--textarea-button-height, 2rem);
|
||||
@@ -193,6 +247,90 @@ var {
|
||||
|
||||
--mobile-bottom-nav-height: 3.75rem;
|
||||
|
||||
--message-compact-text-indent-from-username: 2rem;
|
||||
--guilds-layout-item-bg: var(--guild-list-foreground);
|
||||
--guild-badge-surface: var(--background-secondary);
|
||||
--layout-member-list-width: 16.5rem;
|
||||
--layout-header-height-mobile: 4rem;
|
||||
|
||||
--channel-header-gap: var(--spacing-4);
|
||||
--channel-header-padding-inline: var(--spacing-4);
|
||||
--channel-header-action-size: 2rem;
|
||||
--channel-header-action-size-mobile: 2.5rem;
|
||||
--channel-header-actions-gap: var(--spacing-2);
|
||||
--channel-header-back-button-size: 2rem;
|
||||
--channel-header-back-button-gap: var(--spacing-3);
|
||||
--channel-header-icon-size: 1.5rem;
|
||||
--channel-header-back-icon-size: 1.5rem;
|
||||
--channel-header-caret-size: 1rem;
|
||||
--channel-header-action-icon-size: 1.5rem;
|
||||
--channel-header-name-gap: var(--spacing-3);
|
||||
--channel-header-name-gap-group-dm: var(--spacing-2);
|
||||
--channel-header-caret-gap: var(--spacing-1);
|
||||
--channel-header-topic-divider-gap: var(--spacing-2);
|
||||
|
||||
--chat-mobile-horizontal-padding: 0.75rem;
|
||||
--dm-sidebar-mobile-header-height: 3.5rem;
|
||||
|
||||
--message-search-bar-width: 15.25rem;
|
||||
--message-search-bar-height: 2.25rem;
|
||||
--message-search-bar-icon-size: 1rem;
|
||||
--message-search-bar-icon-gap: 0.5rem;
|
||||
|
||||
--member-list-item-content-height: 2rem;
|
||||
--member-list-item-padding-block: 0.25rem;
|
||||
--member-list-item-padding-inline: 0.5rem;
|
||||
--member-list-item-content-gap: 0.625rem;
|
||||
--member-list-item-name-line-height: 1.25rem;
|
||||
--member-list-item-status-line-height: 0.875rem;
|
||||
--member-list-row-gap: 0.125rem;
|
||||
--member-list-group-spacer-height: 0.25rem;
|
||||
--member-list-scroller-padding-block-start: 0.25rem;
|
||||
--member-list-scroller-padding-block-end: var(--spacing-4);
|
||||
--member-list-scroller-padding-inline: var(--spacing-2);
|
||||
|
||||
--friend-row-padding-block: 0.75rem;
|
||||
--friend-row-padding-inline: 1rem;
|
||||
--friend-row-separator-inset: 0 0.4rem 0 var(--spacing-2);
|
||||
--friend-row-separator-radius: 0.375rem;
|
||||
--friend-row-content-gap: 0.75rem;
|
||||
--friend-row-name-line-height: 1.25rem;
|
||||
--friend-row-subtext-line-height: 1rem;
|
||||
--friend-row-subtext-offset: -0.0625rem;
|
||||
--friend-row-avatar-size: 2.25rem;
|
||||
--friend-row-action-size: 2.25rem;
|
||||
--friend-row-action-gap: 0.5rem;
|
||||
|
||||
--avatar-stack-extra-height: 0.375rem;
|
||||
|
||||
--active-now-sidebar-width: 22rem;
|
||||
--active-now-sidebar-header-padding-inline: 1rem;
|
||||
--active-now-sidebar-header-padding-block-start: 1rem;
|
||||
--active-now-sidebar-header-padding-block-end: 0.75rem;
|
||||
--active-now-sidebar-content-gap: 0.5rem;
|
||||
--active-now-sidebar-content-padding-inline: 0.75rem;
|
||||
--active-now-sidebar-content-padding-block-end: 0.75rem;
|
||||
--active-now-card-gap: 0.75rem;
|
||||
--active-now-card-padding-block: 0.75rem;
|
||||
--active-now-card-padding-inline: 0.875rem;
|
||||
--active-now-card-radius: var(--radius-md);
|
||||
--active-now-card-shadow: 0 0.375rem 1.125rem rgba(0, 0, 0, 0.08);
|
||||
--active-now-context-gap: 0.375rem;
|
||||
--active-now-action-height: 2rem;
|
||||
--active-now-action-radius: 0.5rem;
|
||||
--active-now-action-gap: 0.375rem;
|
||||
--active-now-action-padding-block: 0.3125rem;
|
||||
--active-now-action-padding-inline: 0.625rem;
|
||||
|
||||
--discovery-card-description-min-height: 5.25rem;
|
||||
--guild-list-indicator-inset: -0.15rem;
|
||||
--guild-list-indicator-track-width: 0.5rem;
|
||||
--guild-list-indicator-bar-width: 0.35rem;
|
||||
--count-indicator-gap: 0.25rem;
|
||||
|
||||
--guild-members-table-row-min-height: 3rem;
|
||||
--guild-members-table-row-border-width: 0.0625rem;
|
||||
|
||||
--form-surface-background: var(--background-tertiary);
|
||||
--surface-interactive-hover-bg: var(--background-modifier-hover);
|
||||
--surface-interactive-selected-bg: var(--background-modifier-selected);
|
||||
@@ -209,6 +347,7 @@ html.platform-native.platform-macos {
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);
|
||||
--form-surface-background: var(--background-primary);
|
||||
--surface-interactive-hover-bg: var(--background-modifier-hover);
|
||||
--surface-interactive-selected-bg: var(--background-modifier-selected);
|
||||
@@ -516,7 +655,7 @@ img::before {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
@media (max-width: 840px) and (hover: none) and (pointer: coarse) {
|
||||
:root {
|
||||
--user-select: none;
|
||||
}
|
||||
|
||||
@@ -4,15 +4,21 @@ import {Routes} from '@app/app/Routes';
|
||||
import {isAutoRedirectExemptPath} from '@app/app/router/RouterConstants';
|
||||
import {KeyboardModeListener} from '@app/features/app/components/layout/KeyboardModeListener';
|
||||
import {MobileBottomNav} from '@app/features/app/components/layout/MobileBottomNav';
|
||||
import {SplashScreen} from '@app/features/app/components/layout/SplashScreen';
|
||||
import {SelfHostedSetupWizardGate} from '@app/features/app/components/setup/SelfHostedSetupWizardGate';
|
||||
import {AppSkeleton} from '@app/features/app/components/skeleton/AppSkeleton';
|
||||
import {useLoadingSkeletonRetention} from '@app/features/app/hooks/useLoadingSkeletonRetention';
|
||||
import {useSkeletonLayoutMemoryCapture} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import {
|
||||
shouldShowLoadingSkeleton as getShouldShowLoadingSkeleton,
|
||||
shouldShowMobileBottomNav,
|
||||
} from '@app/features/app/state/AppShellPresentationPolicy';
|
||||
import {isClientBooting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
@@ -40,6 +46,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
const requiresSelfHostedSetup = RuntimeConfig.requiresSelfHostedSetup();
|
||||
const mobileLayoutState = MobileLayout;
|
||||
const [hasRestoredLocation, setHasRestoredLocation] = useState(false);
|
||||
const [effectiveSkeletonPathname, setEffectiveSkeletonPathname] = useState<string | null>(null);
|
||||
const currentUser = Users.currentUser;
|
||||
const [hasHandledNotificationNav, setHasHandledNotificationNav] = useState(false);
|
||||
const previousMobileLayoutStateRef = useRef(mobileLayoutState.enabled);
|
||||
@@ -77,13 +84,57 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
const shouldShowSelfHostedSetup = requiresSelfHostedSetup && !isStandaloneRoute;
|
||||
const shouldBypassGateway = requiresSelfHostedSetup || isStandaloneRoute;
|
||||
const authToken = Authentication.authToken;
|
||||
const protectedRouteBlocked = !canNavigateToProtectedRoutes && !shouldBypassGateway;
|
||||
const splashScreenCoversProtectedRoute =
|
||||
const shouldShowLoadingSkeleton = getShouldShowLoadingSkeleton({
|
||||
booting: isClientBooting(),
|
||||
isAuthenticated,
|
||||
isAuthSessionInitialized: SessionManager.isInitialized,
|
||||
shouldBypassGateway,
|
||||
});
|
||||
useSkeletonLayoutMemoryCapture(
|
||||
isAuthenticated && Initialization.isReady && !shouldBypassGateway && !shouldShowLoadingSkeleton,
|
||||
);
|
||||
const shouldDismissTransientOverlays = !isAuthenticated || shouldShowLoadingSkeleton;
|
||||
const {isLoadingSkeletonRetained, handleLoadingSkeletonTransitionEnd} = useLoadingSkeletonRetention({
|
||||
shouldShowLoadingSkeleton,
|
||||
});
|
||||
let pendingRestorableSkeletonPathname: string | null = null;
|
||||
if (
|
||||
isAuthenticated &&
|
||||
!DeveloperOptions.bypassSplashScreen &&
|
||||
!shouldBypassGateway &&
|
||||
(GatewayConnection.isConnectionInterrupted || !canNavigateToProtectedRoutes);
|
||||
const shouldDismissTransientOverlays = !isAuthenticated || protectedRouteBlocked || splashScreenCoversProtectedRoute;
|
||||
isLocationStateHydrated &&
|
||||
!AccountManager.isSwitching &&
|
||||
pathname === Routes.ME &&
|
||||
!hasRestoredLocation
|
||||
) {
|
||||
const lastLocation = Location.getLastLocation();
|
||||
if (lastLocation != null && lastLocation !== pathname) {
|
||||
pendingRestorableSkeletonPathname = lastLocation;
|
||||
}
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
!isAuthenticated ||
|
||||
!isLocationStateHydrated ||
|
||||
AccountManager.isSwitching ||
|
||||
pathname !== Routes.ME ||
|
||||
hasRestoredLocation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const lastLocation = Location.getLastLocation();
|
||||
if (lastLocation && lastLocation !== pathname) {
|
||||
setEffectiveSkeletonPathname(lastLocation);
|
||||
}
|
||||
}, [hasRestoredLocation, isAuthenticated, isLocationStateHydrated, pathname]);
|
||||
useLayoutEffect(() => {
|
||||
if (effectiveSkeletonPathname != null && pathname !== Routes.ME) {
|
||||
setEffectiveSkeletonPathname(null);
|
||||
}
|
||||
}, [effectiveSkeletonPathname, pathname]);
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldShowLoadingSkeleton && !isLoadingSkeletonRetained && effectiveSkeletonPathname != null) {
|
||||
setEffectiveSkeletonPathname(null);
|
||||
}
|
||||
}, [effectiveSkeletonPathname, isLoadingSkeletonRetained, shouldShowLoadingSkeleton]);
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
void PushSubscriptionService.cleanupNativeDesktopWebPushSubscriptions('desktop-authenticated');
|
||||
@@ -298,25 +349,32 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
navigateWithHistoryStack,
|
||||
normalizeInternalUrl,
|
||||
]);
|
||||
const showBottomNav =
|
||||
mobileLayoutState.enabled &&
|
||||
(location.pathname === Routes.ME ||
|
||||
location.pathname === Routes.FAVORITES ||
|
||||
location.pathname === Routes.NOTIFICATIONS ||
|
||||
location.pathname === Routes.YOU ||
|
||||
(Routes.isGuildChannelRoute(location.pathname) && location.pathname.split('/').length === 3));
|
||||
const showBottomNav = shouldShowMobileBottomNav({
|
||||
mobileLayoutEnabled: mobileLayoutState.enabled,
|
||||
pathname,
|
||||
});
|
||||
if (shouldShowSelfHostedSetup) {
|
||||
return <SelfHostedSetupWizardGate />;
|
||||
}
|
||||
if (isAuthenticated && !canNavigateToProtectedRoutes && !shouldBypassGateway) {
|
||||
return <SplashScreen data-flx="app.router.root-component.splash-screen" />;
|
||||
return <SelfHostedSetupWizardGate data-flx="app.router.root-component.self-hosted-setup-wizard-gate" />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<KeyboardModeListener data-flx="app.router.root-component.keyboard-mode-listener" />
|
||||
{children}
|
||||
{showBottomNav && currentUser && (
|
||||
<MobileBottomNav currentUser={currentUser} data-flx="app.router.root-component.mobile-bottom-nav" />
|
||||
{!shouldShowLoadingSkeleton && (
|
||||
<>
|
||||
<KeyboardModeListener data-flx="app.router.root-component.keyboard-mode-listener" />
|
||||
{children}
|
||||
{showBottomNav && currentUser && (
|
||||
<MobileBottomNav currentUser={currentUser} data-flx="app.router.root-component.mobile-bottom-nav" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(shouldShowLoadingSkeleton || isLoadingSkeletonRetained) && (
|
||||
<AppSkeleton
|
||||
key={`loading-skeleton:${AccountManager.currentUserId ?? 'unauthenticated'}`}
|
||||
effectivePathname={effectiveSkeletonPathname ?? pendingRestorableSkeletonPathname ?? pathname}
|
||||
isExiting={!shouldShowLoadingSkeleton}
|
||||
onTransitionEnd={handleLoadingSkeletonTransitionEnd}
|
||||
data-flx="app.router.root-component.app-skeleton"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getMessageGroupSpacingForDisplayMode,
|
||||
migrateLegacyMessageGroupSpacing,
|
||||
} from '@app/features/accessibility/state/MessageGroupSpacing';
|
||||
import {DEFAULT_MESSAGE_GUTTER_PX} from '@app/features/accessibility/state/MessagePresentationDefaults';
|
||||
import type {AnimatedMediaKind} from '@app/features/accessibility/state/MotionPreferencesMachine';
|
||||
import {
|
||||
AuthSessionStorageKey,
|
||||
@@ -667,7 +668,7 @@ class Accessibility {
|
||||
keepStickerAnimationUnderReducedMotion = false;
|
||||
messageGroupSpacing = COMFY_MESSAGE_GROUP_SPACING_DEFAULT;
|
||||
compactMessageGroupSpacing = COMPACT_MESSAGE_GROUP_SPACING_DEFAULT;
|
||||
messageGutter = 16;
|
||||
messageGutter = DEFAULT_MESSAGE_GUTTER_PX;
|
||||
fontSize = 16;
|
||||
showUserAvatarsInCompactMode = false;
|
||||
mobileStickerAnimationOverridden = false;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const DEFAULT_MESSAGE_GUTTER_PX = 16;
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
|
||||
class StalledConnectionAccountSwitchUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('Cannot switch accounts from the current authentication session state');
|
||||
this.name = 'StalledConnectionAccountSwitchUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export function canSwitchAccountFromStalledConnection(): boolean {
|
||||
return SessionManager.canSwitchAccount() || SessionManager.isConnecting;
|
||||
}
|
||||
|
||||
export async function switchAccountFromStalledConnection(userId: string): Promise<void> {
|
||||
if (!canSwitchAccountFromStalledConnection()) {
|
||||
throw new StalledConnectionAccountSwitchUnavailableError();
|
||||
}
|
||||
SessionManager.requireAccountOnCurrentInstance(userId);
|
||||
if (SessionManager.isConnecting) {
|
||||
SessionManager.handleConnectionFailed();
|
||||
}
|
||||
await AccountManager.switchToAccount(userId, null);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--text-link);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 0.125rem;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 0.125rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--text-tertiary);
|
||||
user-select: none;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/ConnectionIssuesLinks.module.css';
|
||||
import type {StatusPageIncident} from '@app/features/user/state/StatusPage';
|
||||
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
|
||||
interface ConnectionIssuesLinksProps {
|
||||
incident: StatusPageIncident | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_HISTORY_URL = `${ExternalUrls.SERVICE_STATUS}/history`;
|
||||
|
||||
export function ConnectionIssuesLinks({incident, className}: ConnectionIssuesLinksProps) {
|
||||
const containerClassName = className != null ? `${styles.container} ${className}` : styles.container;
|
||||
const incidentUrl = incident?.url ?? STATUS_HISTORY_URL;
|
||||
return (
|
||||
<div className={containerClassName} data-flx="app.connection-issues-links.div">
|
||||
<p className={styles.prompt} data-flx="app.connection-issues-links.prompt">
|
||||
<Trans>Connection issues?</Trans>
|
||||
</p>
|
||||
<p className={styles.links} data-flx="app.connection-issues-links.links">
|
||||
<a
|
||||
href={ExternalUrls.SERVICE_STATUS}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.link}
|
||||
data-flx="app.connection-issues-links.link"
|
||||
>
|
||||
<Trans>Status page</Trans>
|
||||
</a>
|
||||
<span aria-hidden="true" className={styles.separator} data-flx="app.connection-issues-links.separator">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.link}
|
||||
data-flx="app.connection-issues-links.link--2"
|
||||
>
|
||||
{incident ? <Trans>Read incident</Trans> : <Trans>Incident history</Trans>}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const LONG_PRESS_MOVEMENT_THRESHOLD = 10;
|
||||
const SWIPE_VELOCITY_THRESHOLD = 0.4;
|
||||
@@ -9,6 +10,7 @@ const MIN_VELOCITY_SAMPLES = 2;
|
||||
const MAX_VELOCITY_SAMPLE_AGE = 100;
|
||||
const PRESS_HIGHLIGHT_DELAY_MS = 100;
|
||||
const LONG_PRESS_DURATION_MS = 500;
|
||||
const LONG_PRESS_CLICK_SUPPRESSION_MS = 750;
|
||||
const HAS_POINTER_EVENTS = typeof window !== 'undefined' && 'PointerEvent' in window;
|
||||
|
||||
interface VelocitySample {
|
||||
@@ -53,8 +55,12 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
const storedEvent = useRef<LongPressEvent | null>(null);
|
||||
const velocitySamples = useRef<Array<VelocitySample>>([]);
|
||||
const isPressIntent = useRef(false);
|
||||
const pressScrollCancelHandler = useRef<(() => void) | null>(null);
|
||||
const clearTimerRef = useRef<() => void>(() => {});
|
||||
const suppressNextClickRef = useRef(false);
|
||||
const suppressClickTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
useImperativeHandle(forwardedRef, () => innerRef.current as HTMLElement);
|
||||
const composedRef = useMergeRefs<HTMLElement>([forwardedRef, innerRef]);
|
||||
const setPressed = useCallback(
|
||||
(pressed: boolean) => {
|
||||
setIsPressed(pressed);
|
||||
@@ -62,6 +68,21 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
},
|
||||
[onPressStateChange],
|
||||
);
|
||||
const clearSuppressedClick = useCallback(() => {
|
||||
suppressNextClickRef.current = false;
|
||||
if (suppressClickTimer.current) {
|
||||
clearTimeout(suppressClickTimer.current);
|
||||
suppressClickTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
const suppressNextClick = useCallback(() => {
|
||||
suppressNextClickRef.current = true;
|
||||
if (suppressClickTimer.current) clearTimeout(suppressClickTimer.current);
|
||||
suppressClickTimer.current = setTimeout(() => {
|
||||
suppressClickTimer.current = null;
|
||||
suppressNextClickRef.current = false;
|
||||
}, LONG_PRESS_CLICK_SUPPRESSION_MS);
|
||||
}, []);
|
||||
const calculateVelocity = useCallback((): number => {
|
||||
const samples = velocitySamples.current;
|
||||
if (samples.length < MIN_VELOCITY_SAMPLES) return 0;
|
||||
@@ -83,7 +104,24 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
return distance / dt;
|
||||
}, []);
|
||||
const detachPressScrollCancel = useCallback(() => {
|
||||
const handler = pressScrollCancelHandler.current;
|
||||
if (handler == null) return;
|
||||
pressScrollCancelHandler.current = null;
|
||||
window.removeEventListener('scroll', handler, {capture: true});
|
||||
}, []);
|
||||
const attachPressScrollCancel = useCallback(() => {
|
||||
if (pressScrollCancelHandler.current != null) return;
|
||||
const handler = () => {
|
||||
if (isPressIntent.current) {
|
||||
clearTimerRef.current();
|
||||
}
|
||||
};
|
||||
pressScrollCancelHandler.current = handler;
|
||||
window.addEventListener('scroll', handler, {capture: true, passive: true});
|
||||
}, []);
|
||||
const clearTimer = useCallback(() => {
|
||||
detachPressScrollCancel();
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
@@ -103,7 +141,8 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
velocitySamples.current = [];
|
||||
isPressIntent.current = false;
|
||||
setPressed(false);
|
||||
}, [setPressed]);
|
||||
}, [detachPressScrollCancel, setPressed]);
|
||||
clearTimerRef.current = clearTimer;
|
||||
const {
|
||||
onPointerDown: userOnPointerDown,
|
||||
onPointerMove: userOnPointerMove,
|
||||
@@ -136,6 +175,7 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
pointerIdRef.current = pointerId ?? null;
|
||||
velocitySamples.current = [{x, y, timestamp: performance.now()}];
|
||||
isPressIntent.current = true;
|
||||
attachPressScrollCancel();
|
||||
if (capturePointer && pointerId != null && innerRef.current?.setPointerCapture) {
|
||||
try {
|
||||
innerRef.current.setPointerCapture(pointerId);
|
||||
@@ -150,13 +190,14 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
}, PRESS_HIGHLIGHT_DELAY_MS);
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
if (!disabled && onLongPress && storedEvent.current && isPressIntent.current) {
|
||||
suppressNextClick();
|
||||
onLongPress(storedEvent.current);
|
||||
setPressed(false);
|
||||
}
|
||||
clearTimer();
|
||||
}, delay);
|
||||
},
|
||||
[clearTimer, delay, disabled, onLongPress, setPressed],
|
||||
[attachPressScrollCancel, clearTimer, delay, disabled, onLongPress, setPressed, suppressNextClick],
|
||||
);
|
||||
const handlePointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
@@ -255,26 +296,35 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
},
|
||||
[clearTimer, userOnTouchCancel],
|
||||
);
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (isPressIntent.current) {
|
||||
clearTimer();
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
if (suppressNextClickRef.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearSuppressedClick();
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll, {capture: true, passive: true});
|
||||
userOnClick?.(event);
|
||||
},
|
||||
[clearSuppressedClick, userOnClick],
|
||||
);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, {capture: true});
|
||||
detachPressScrollCancel();
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
}
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
}
|
||||
if (suppressClickTimer.current) {
|
||||
clearTimeout(suppressClickTimer.current);
|
||||
}
|
||||
};
|
||||
}, [clearTimer]);
|
||||
}, [detachPressScrollCancel]);
|
||||
const finalClassName = isPressed && pressedClassName ? `${className ?? ''} ${pressedClassName}`.trim() : className;
|
||||
return React.createElement(Component, {
|
||||
ref: innerRef,
|
||||
ref: composedRef,
|
||||
className: finalClassName,
|
||||
onPointerDown: HAS_POINTER_EVENTS ? handlePointerDown : undefined,
|
||||
onPointerMove: HAS_POINTER_EVENTS ? handlePointerMove : undefined,
|
||||
@@ -284,7 +334,7 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
onTouchMove: !HAS_POINTER_EVENTS ? handleTouchMove : undefined,
|
||||
onTouchEnd: !HAS_POINTER_EVENTS ? handleTouchEnd : undefined,
|
||||
onTouchCancel: !HAS_POINTER_EVENTS ? handleTouchCancel : undefined,
|
||||
onClick: userOnClick,
|
||||
onClick: userOnClick ? handleClick : undefined,
|
||||
onKeyDown: handleKeyDown,
|
||||
...restWithoutPointer,
|
||||
'data-long-press-owner': !disabled && onLongPress ? 'true' : undefined,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {OutageSplashScreen} from '@app/features/app/components/layout/SplashScreen';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
const AUTO_RELOAD_BASE_MS = 15_000;
|
||||
const AUTO_RELOAD_JITTER_MS = 5_000;
|
||||
|
||||
export function NetworkErrorScreen() {
|
||||
useEffect(() => {
|
||||
const delay = AUTO_RELOAD_BASE_MS + Math.random() * AUTO_RELOAD_JITTER_MS;
|
||||
const timer = setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
return <OutageSplashScreen data-flx="app.network-error-screen.outage-splash-screen" />;
|
||||
}
|
||||
@@ -15,11 +15,3 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.appLayoutSplashCovered,
|
||||
.appLayoutSplashCovered * {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
import styles from '@app/features/app/components/layout/AppLayout.module.css';
|
||||
import {useAppLayoutState} from '@app/features/app/components/layout/app_layout/AppLayoutHooks';
|
||||
import RequiredActionGate from '@app/features/app/components/layout/RequiredActionGate';
|
||||
import {SplashScreen} from '@app/features/app/components/layout/SplashScreen';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager';
|
||||
import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager';
|
||||
@@ -21,10 +19,6 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
|
||||
const isAuthenticated = Authentication.isAuthenticated;
|
||||
const socket = GatewayConnection.socket;
|
||||
const appState = useAppLayoutState();
|
||||
const isSplashScreenActive =
|
||||
isAuthenticated &&
|
||||
!DeveloperOptions.bypassSplashScreen &&
|
||||
(GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes);
|
||||
useEffect(() => {
|
||||
if (Initialization.isLoading) {
|
||||
return;
|
||||
@@ -40,16 +34,11 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated && <SplashScreen data-flx="app.app-layout.splash-screen" />}
|
||||
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
|
||||
{isAuthenticated && <NewDeviceMonitoringManager data-flx="app.app-layout.new-device-monitoring-manager" />}
|
||||
{isAuthenticated && <RequiredActionGate data-flx="app.app-layout.required-action-gate" />}
|
||||
<div
|
||||
className={clsx(
|
||||
styles.appLayout,
|
||||
appState.isStandalone && styles.appLayoutStandalone,
|
||||
isSplashScreenActive && styles.appLayoutSplashCovered,
|
||||
)}
|
||||
className={clsx(styles.appLayout, appState.isStandalone && styles.appLayoutStandalone)}
|
||||
data-flx="app.app-layout.app-layout"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -5,6 +5,11 @@ import styles from '@app/features/app/components/layout/ChannelItem.module.css';
|
||||
import {ChannelItemContent} from '@app/features/app/components/layout/ChannelItemContent';
|
||||
import {ChannelItemIcon} from '@app/features/app/components/layout/ChannelItemIcon';
|
||||
import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css';
|
||||
import {
|
||||
DirectSelectionSurface,
|
||||
markDirectSelection,
|
||||
peekDirectSelection,
|
||||
} from '@app/features/app/components/layout/DirectSelectionOrigin';
|
||||
import {
|
||||
type ChannelReorderTarget,
|
||||
canChannelDropOnTarget,
|
||||
@@ -12,7 +17,7 @@ import {
|
||||
} from '@app/features/app/components/layout/dnd/ChannelReorderStateMachine';
|
||||
import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem';
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {isCategory, isTextChannel} from '@app/features/app/components/layout/utils/ChannelOrganization';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {VoiceChannelUserCount} from '@app/features/app/components/layout/VoiceChannelUserCount';
|
||||
@@ -221,7 +226,7 @@ export const ChannelItem = observer(
|
||||
defaultHiddenForChannel: channelIsVoice,
|
||||
enabled: !channelIsCategory,
|
||||
});
|
||||
const draggingChannel = activeDragItem?.type === DND_TYPES.CHANNEL ? activeDragItem : null;
|
||||
const draggingChannel = activeDragItem?.type === DragItemType.CHANNEL ? activeDragItem : null;
|
||||
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
|
||||
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
|
||||
const unreadCount = ReadStates.getUnreadCount(channel.id);
|
||||
@@ -283,7 +288,7 @@ export const ChannelItem = observer(
|
||||
const [dropIndicator, setDropIndicator] = useState<{position: 'top' | 'bottom'; isValid: boolean} | null>(null);
|
||||
const dragItemData = useMemo<DragItem>(
|
||||
() => ({
|
||||
type: channelIsCategory ? DND_TYPES.CATEGORY : DND_TYPES.CHANNEL,
|
||||
type: channelIsCategory ? DragItemType.CATEGORY : DragItemType.CHANNEL,
|
||||
id: channel.id,
|
||||
channelType: channel.type,
|
||||
parentId: channel.parentId,
|
||||
@@ -319,7 +324,7 @@ export const ChannelItem = observer(
|
||||
);
|
||||
const [{isOver}, dropRef] = useDrop(
|
||||
() => ({
|
||||
accept: [DND_TYPES.CHANNEL, DND_TYPES.CATEGORY, DND_TYPES.VOICE_PARTICIPANT],
|
||||
accept: [DragItemType.CHANNEL, DragItemType.CATEGORY, DragItemType.VOICE_PARTICIPANT],
|
||||
canDrop: (item: DragItem) => canChannelDropOnTarget(item, dropTargetData),
|
||||
hover: (item: DragItem, monitor) => {
|
||||
const node = dropTargetRef.current;
|
||||
@@ -335,7 +340,7 @@ export const ChannelItem = observer(
|
||||
setDropIndicator(null);
|
||||
return;
|
||||
}
|
||||
if (item.type === DND_TYPES.VOICE_PARTICIPANT && channelIsVoice) {
|
||||
if (item.type === DragItemType.VOICE_PARTICIPANT && channelIsVoice) {
|
||||
const canMove = Permission.can(Permissions.MOVE_MEMBERS, {guildId: guild.id});
|
||||
if (!canMove || item.currentChannelId === channel.id) {
|
||||
setDropIndicator(null);
|
||||
@@ -405,6 +410,7 @@ export const ChannelItem = observer(
|
||||
CompactVoiceCallHeight.setExpandedForKey(getGuildVoiceCallExpansionKey(channel.id), false);
|
||||
}, [channelIsVoice, channel.id]);
|
||||
const handleSelect = useCallback(() => {
|
||||
markDirectSelection(DirectSelectionSurface.CHANNEL_LIST);
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
onToggle?.();
|
||||
return;
|
||||
@@ -465,7 +471,8 @@ export const ChannelItem = observer(
|
||||
const shouldShowSelectedState = !channelIsCategory && isSelected && (!channelIsVoice || isSelectedByPath);
|
||||
const hasMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (shouldShowSelectedState && hasMountedRef.current) {
|
||||
const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.CHANNEL_LIST);
|
||||
if (shouldShowSelectedState && hasMountedRef.current && !selectedFromThisRow) {
|
||||
elementRef.current?.scrollIntoView({block: 'nearest'});
|
||||
}
|
||||
hasMountedRef.current = true;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {GenericChannelItem} from '@app/features/app/components/layout/GenericCha
|
||||
import {GuildDetachedBanner} from '@app/features/app/components/layout/GuildDetachedBanner';
|
||||
import {NullSpaceDropIndicator} from '@app/features/app/components/layout/NullSpaceDropIndicator';
|
||||
import {ScrollIndicatorOverlay} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {
|
||||
shouldShowChannelInCollapsedCategory,
|
||||
shouldShowChannelWhenHidingMutedChannels,
|
||||
@@ -23,7 +23,17 @@ import {createChannelMoveOperation} from '@app/features/app/components/layout/ut
|
||||
import {organizeChannels} from '@app/features/app/components/layout/utils/ChannelOrganization';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {VoiceParticipantsList} from '@app/features/app/components/layout/VoiceParticipantsList';
|
||||
import {
|
||||
type RememberedSkeletonGuildChannelGroup,
|
||||
type RememberedSkeletonGuildChannelRow,
|
||||
reportSkeletonGuildChannelList,
|
||||
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList';
|
||||
import {
|
||||
measureSkeletonTextWidthPx,
|
||||
useSkeletonLayoutReport,
|
||||
} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import * as GuildCommands from '@app/features/guild/commands/GuildCommands';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
@@ -90,6 +100,66 @@ const MEMBERS_PAGE_PERMISSIONS =
|
||||
Permissions.MODERATE_MEMBERS |
|
||||
Permissions.KICK_MEMBERS;
|
||||
const EMPTY_ARRAY: ReadonlyArray<never> = Object.freeze([]);
|
||||
|
||||
interface ResolvedChannelGroup {
|
||||
readonly key: string;
|
||||
readonly category: Channel | undefined;
|
||||
readonly isCollapsed: boolean;
|
||||
readonly connectedChannelInGroup: boolean;
|
||||
readonly filteredVoiceChannels: Array<Channel>;
|
||||
readonly visibleTextChannels: ReadonlyArray<Channel>;
|
||||
readonly visibleVoiceChannels: ReadonlyArray<Channel>;
|
||||
readonly showTextChannels: boolean;
|
||||
readonly showVoiceChannels: boolean;
|
||||
}
|
||||
|
||||
function createChannelListProjectionKey(
|
||||
groups: ReadonlyArray<ResolvedChannelGroup>,
|
||||
membersRowVisible: boolean,
|
||||
): string {
|
||||
const groupKeys = groups.map((group) => {
|
||||
const channelKeys = [...group.visibleTextChannels, ...group.visibleVoiceChannels].map(
|
||||
(channel) => `${channel.id}~${channel.name}`,
|
||||
);
|
||||
return `${group.category?.id ?? ''}~${group.category?.name ?? ''}~${group.isCollapsed}~${channelKeys.join(',')}`;
|
||||
});
|
||||
return `${membersRowVisible}|${groupKeys.join('|')}`;
|
||||
}
|
||||
|
||||
function createRememberedChannelGroups(
|
||||
groups: ReadonlyArray<ResolvedChannelGroup>,
|
||||
container: HTMLElement | null,
|
||||
): ReadonlyArray<RememberedSkeletonGuildChannelGroup> {
|
||||
const categoryNameElements = container?.querySelectorAll(`.${channelItemStyles.categoryName}`);
|
||||
const channelNameElements = container?.querySelectorAll(`.${channelItemStyles.channelName}`);
|
||||
let categoryNameIndex = 0;
|
||||
let channelNameIndex = 0;
|
||||
const readNextChannelWidth = (): number => {
|
||||
const width = measureSkeletonTextWidthPx(channelNameElements?.item(channelNameIndex) ?? null);
|
||||
channelNameIndex += 1;
|
||||
return width;
|
||||
};
|
||||
return groups.map((group) => {
|
||||
let categoryNameWidthPx = measureSkeletonTextWidthPx(null);
|
||||
if (group.category != null) {
|
||||
categoryNameWidthPx = measureSkeletonTextWidthPx(categoryNameElements?.item(categoryNameIndex) ?? null);
|
||||
categoryNameIndex += 1;
|
||||
}
|
||||
const channels: Array<RememberedSkeletonGuildChannelRow> = [];
|
||||
for (const _textChannel of group.visibleTextChannels) {
|
||||
channels.push({voice: false, nameWidthPx: readNextChannelWidth()});
|
||||
}
|
||||
for (const _voiceChannel of group.visibleVoiceChannels) {
|
||||
channels.push({voice: true, nameWidthPx: readNextChannelWidth()});
|
||||
}
|
||||
return {
|
||||
categoryHeaderVisible: group.category != null,
|
||||
collapsed: group.isCollapsed,
|
||||
categoryNameWidthPx,
|
||||
channels,
|
||||
};
|
||||
});
|
||||
}
|
||||
export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scrollY: MotionValue<number>}) => {
|
||||
const {i18n} = useLingui();
|
||||
const channels = Channels.getGuildChannels(guild.id);
|
||||
@@ -99,11 +169,14 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
if (!monitor.isDragging()) return false;
|
||||
const itemType = monitor.getItemType();
|
||||
return (
|
||||
itemType === DND_TYPES.CHANNEL || itemType === DND_TYPES.CATEGORY || itemType === DND_TYPES.VOICE_PARTICIPANT
|
||||
itemType === DragItemType.CHANNEL ||
|
||||
itemType === DragItemType.CATEGORY ||
|
||||
itemType === DragItemType.VOICE_PARTICIPANT
|
||||
);
|
||||
});
|
||||
const [activeDragItem, setActiveDragItem] = useState<DragItem | null>(null);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const channelGroupsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(false);
|
||||
const pendingScrollTopRef = useRef<number | null>(null);
|
||||
const scrollPersistRafRef = useRef<number | null>(null);
|
||||
@@ -283,6 +356,142 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
});
|
||||
return unreadState.hasVisibleUnread;
|
||||
};
|
||||
const resolvedGroups: Array<ResolvedChannelGroup> = [];
|
||||
for (const group of channelGroups) {
|
||||
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
|
||||
const isNullSpace = !group.category;
|
||||
const isCategoryMuted = group.category ? UserGuildSettings.isChannelMuted(guild.id, group.category.id) : false;
|
||||
let filteredTextChannels: Array<Channel>;
|
||||
let filteredVoiceChannels: Array<Channel>;
|
||||
if (hideMutedChannels) {
|
||||
filteredTextChannels = [];
|
||||
for (const ch of group.textChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
filteredTextChannels.push(ch);
|
||||
}
|
||||
}
|
||||
filteredVoiceChannels = [];
|
||||
for (const ch of group.voiceChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: ch.id === connectedChannelId,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
filteredVoiceChannels.push(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filteredTextChannels = group.textChannels;
|
||||
filteredVoiceChannels = group.voiceChannels;
|
||||
}
|
||||
let visibleTextChannels: ReadonlyArray<Channel> = filteredTextChannels;
|
||||
let visibleVoiceChannels: ReadonlyArray<Channel> = filteredVoiceChannels;
|
||||
let connectedChannelInGroup = false;
|
||||
if (isCollapsed) {
|
||||
const showTextSelected = selectedChannelInGuildId;
|
||||
const showSet = new Set<string>();
|
||||
for (const ch of filteredTextChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === showTextSelected,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
showSet.add(ch.id);
|
||||
}
|
||||
}
|
||||
if (showSet.size === 0) {
|
||||
visibleTextChannels = EMPTY_ARRAY;
|
||||
} else if (showSet.size === filteredTextChannels.length) {
|
||||
visibleTextChannels = filteredTextChannels;
|
||||
} else {
|
||||
const next: Array<Channel> = [];
|
||||
for (const ch of filteredTextChannels) if (showSet.has(ch.id)) next.push(ch);
|
||||
visibleTextChannels = next;
|
||||
}
|
||||
const voiceSet = new Set<string>();
|
||||
if (selectedChannelInGuildId) voiceSet.add(selectedChannelInGuildId);
|
||||
if (connectedChannelId) {
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (ch.id === connectedChannelId) {
|
||||
connectedChannelInGroup = true;
|
||||
voiceSet.add(connectedChannelId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
voiceSet.add(ch.id);
|
||||
}
|
||||
}
|
||||
if (voiceSet.size === 0) {
|
||||
visibleVoiceChannels = EMPTY_ARRAY;
|
||||
} else {
|
||||
const next: Array<Channel> = [];
|
||||
let connectedRow: Channel | null = null;
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (!voiceSet.has(ch.id)) continue;
|
||||
if (ch.id === connectedChannelId) {
|
||||
connectedRow = ch;
|
||||
} else {
|
||||
next.push(ch);
|
||||
}
|
||||
}
|
||||
visibleVoiceChannels = connectedRow ? [connectedRow, ...next] : next;
|
||||
}
|
||||
}
|
||||
if (isNullSpace && filteredTextChannels.length === 0 && filteredVoiceChannels.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
hideMutedChannels &&
|
||||
group.category &&
|
||||
filteredTextChannels.length === 0 &&
|
||||
filteredVoiceChannels.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
resolvedGroups.push({
|
||||
key: group.category?.id || 'null-space',
|
||||
category: group.category,
|
||||
isCollapsed,
|
||||
connectedChannelInGroup,
|
||||
filteredVoiceChannels,
|
||||
visibleTextChannels,
|
||||
visibleVoiceChannels,
|
||||
showTextChannels: !isCollapsed || visibleTextChannels.length > 0,
|
||||
showVoiceChannels: !isCollapsed || visibleVoiceChannels.length > 0,
|
||||
});
|
||||
}
|
||||
useSkeletonLayoutReport(
|
||||
() => {
|
||||
reportSkeletonGuildChannelList(guild.id, {
|
||||
membersRowVisible: canViewMembers,
|
||||
groups: createRememberedChannelGroups(resolvedGroups, channelGroupsContainerRef.current),
|
||||
});
|
||||
},
|
||||
`${guild.id}|${createChannelListProjectionKey(resolvedGroups, canViewMembers)}`,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.channelListScrollerWrapper}
|
||||
@@ -352,156 +561,53 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
<div className={styles.membersSeparator} data-flx="app.channel-list-content.members-separator" />
|
||||
</>
|
||||
)}
|
||||
<div className={styles.channelGroupsContainer} data-flx="app.channel-list-content.channel-groups-container">
|
||||
{channelGroups.map((group) => {
|
||||
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
|
||||
const isNullSpace = !group.category;
|
||||
const isCategoryMuted = group.category
|
||||
? UserGuildSettings.isChannelMuted(guild.id, group.category.id)
|
||||
: false;
|
||||
type ChannelRow = (typeof group.textChannels)[number];
|
||||
let filteredTextChannels: Array<ChannelRow>;
|
||||
let filteredVoiceChannels: Array<ChannelRow>;
|
||||
if (hideMutedChannels) {
|
||||
filteredTextChannels = [];
|
||||
for (const ch of group.textChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
filteredTextChannels.push(ch);
|
||||
}
|
||||
}
|
||||
filteredVoiceChannels = [];
|
||||
for (const ch of group.voiceChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: ch.id === connectedChannelId,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
filteredVoiceChannels.push(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filteredTextChannels = group.textChannels;
|
||||
filteredVoiceChannels = group.voiceChannels;
|
||||
}
|
||||
let visibleTextChannels: ReadonlyArray<ChannelRow> = filteredTextChannels;
|
||||
let visibleVoiceChannels: ReadonlyArray<ChannelRow> = filteredVoiceChannels;
|
||||
let connectedChannelInGroup = false;
|
||||
if (isCollapsed) {
|
||||
const showTextSelected = selectedChannelInGuildId;
|
||||
const showSet = new Set<string>();
|
||||
for (const ch of filteredTextChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === showTextSelected,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
showSet.add(ch.id);
|
||||
}
|
||||
}
|
||||
if (showSet.size === 0) {
|
||||
visibleTextChannels = EMPTY_ARRAY;
|
||||
} else if (showSet.size === filteredTextChannels.length) {
|
||||
visibleTextChannels = filteredTextChannels;
|
||||
} else {
|
||||
const next: Array<ChannelRow> = [];
|
||||
for (const ch of filteredTextChannels) if (showSet.has(ch.id)) next.push(ch);
|
||||
visibleTextChannels = next;
|
||||
}
|
||||
const voiceSet = new Set<string>();
|
||||
if (selectedChannelInGuildId) voiceSet.add(selectedChannelInGuildId);
|
||||
if (connectedChannelId) {
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (ch.id === connectedChannelId) {
|
||||
connectedChannelInGroup = true;
|
||||
voiceSet.add(connectedChannelId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
voiceSet.add(ch.id);
|
||||
}
|
||||
}
|
||||
if (voiceSet.size === 0) {
|
||||
visibleVoiceChannels = EMPTY_ARRAY;
|
||||
} else {
|
||||
const next: Array<ChannelRow> = [];
|
||||
let connectedRow: ChannelRow | null = null;
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (!voiceSet.has(ch.id)) continue;
|
||||
if (ch.id === connectedChannelId) {
|
||||
connectedRow = ch;
|
||||
} else {
|
||||
next.push(ch);
|
||||
}
|
||||
}
|
||||
visibleVoiceChannels = connectedRow ? [connectedRow, ...next] : next;
|
||||
}
|
||||
}
|
||||
if (isNullSpace && filteredTextChannels.length === 0 && filteredVoiceChannels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
hideMutedChannels &&
|
||||
group.category &&
|
||||
filteredTextChannels.length === 0 &&
|
||||
filteredVoiceChannels.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const showTextChannels = !isCollapsed || visibleTextChannels.length > 0;
|
||||
const showVoiceChannels = !isCollapsed || visibleVoiceChannels.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={group.category?.id || 'null-space'}
|
||||
className={styles.channelGroup}
|
||||
data-flx="app.channel-list-content.channel-group"
|
||||
>
|
||||
{group.category && (
|
||||
<div
|
||||
ref={channelGroupsContainerRef}
|
||||
className={styles.channelGroupsContainer}
|
||||
data-flx="app.channel-list-content.channel-groups-container"
|
||||
>
|
||||
{resolvedGroups.map((group) => (
|
||||
<div key={group.key} className={styles.channelGroup} data-flx="app.channel-list-content.channel-group">
|
||||
{group.category && (
|
||||
<ChannelItem
|
||||
guild={guild}
|
||||
channel={group.category}
|
||||
isCollapsed={group.isCollapsed}
|
||||
onToggle={() => toggleCategory(group.category!.id)}
|
||||
isDraggingAnything={isDraggingAnything}
|
||||
activeDragItem={activeDragItem}
|
||||
onChannelDrop={handleChannelDrop}
|
||||
onDragStateChange={setActiveDragItem}
|
||||
isSelectedByPath={selectedChannelInGuildId === group.category.id}
|
||||
isOnMembersRoute={isMembersSelected}
|
||||
data-flx="app.channel-list-content.channel-item"
|
||||
/>
|
||||
)}
|
||||
{group.isCollapsed && group.category && !group.connectedChannelInGroup && (
|
||||
<CollapsedCategoryVoiceParticipants
|
||||
guild={guild}
|
||||
voiceChannels={group.filteredVoiceChannels}
|
||||
data-flx="app.channel-list-content.collapsed-category-voice-participants"
|
||||
/>
|
||||
)}
|
||||
{group.showTextChannels &&
|
||||
group.visibleTextChannels.map((ch) => (
|
||||
<ChannelItem
|
||||
key={ch.id}
|
||||
guild={guild}
|
||||
channel={group.category}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleCategory(group.category!.id)}
|
||||
channel={ch}
|
||||
isDraggingAnything={isDraggingAnything}
|
||||
activeDragItem={activeDragItem}
|
||||
onChannelDrop={handleChannelDrop}
|
||||
onDragStateChange={setActiveDragItem}
|
||||
isSelectedByPath={selectedChannelInGuildId === group.category.id}
|
||||
isSelectedByPath={selectedChannelInGuildId === ch.id}
|
||||
isOnMembersRoute={isMembersSelected}
|
||||
data-flx="app.channel-list-content.channel-item"
|
||||
data-flx="app.channel-list-content.channel-item--2"
|
||||
/>
|
||||
)}
|
||||
{isCollapsed && group.category && !connectedChannelInGroup && (
|
||||
<CollapsedCategoryVoiceParticipants
|
||||
guild={guild}
|
||||
voiceChannels={filteredVoiceChannels}
|
||||
data-flx="app.channel-list-content.collapsed-category-voice-participants"
|
||||
/>
|
||||
)}
|
||||
{showTextChannels &&
|
||||
visibleTextChannels.map((ch) => (
|
||||
))}
|
||||
{group.showVoiceChannels &&
|
||||
group.visibleVoiceChannels.map((ch) => {
|
||||
const channelRow = (
|
||||
<ChannelItem
|
||||
key={ch.id}
|
||||
guild={guild}
|
||||
@@ -512,53 +618,36 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
onDragStateChange={setActiveDragItem}
|
||||
isSelectedByPath={selectedChannelInGuildId === ch.id}
|
||||
isOnMembersRoute={isMembersSelected}
|
||||
data-flx="app.channel-list-content.channel-item--2"
|
||||
data-flx="app.channel-list-content.channel-item--3"
|
||||
/>
|
||||
))}
|
||||
{showVoiceChannels &&
|
||||
visibleVoiceChannels.map((ch) => {
|
||||
const channelRow = (
|
||||
<ChannelItem
|
||||
key={ch.id}
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
isDraggingAnything={isDraggingAnything}
|
||||
activeDragItem={activeDragItem}
|
||||
onChannelDrop={handleChannelDrop}
|
||||
onDragStateChange={setActiveDragItem}
|
||||
isSelectedByPath={selectedChannelInGuildId === ch.id}
|
||||
isOnMembersRoute={isMembersSelected}
|
||||
data-flx="app.channel-list-content.channel-item--3"
|
||||
/>
|
||||
);
|
||||
if (isCollapsed && connectedChannelId && ch.id === connectedChannelId) {
|
||||
return (
|
||||
<React.Fragment key={ch.id}>
|
||||
{channelRow}
|
||||
<CollapsedChannelAvatarStack
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
data-flx="app.channel-list-content.collapsed-channel-avatar-stack"
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
);
|
||||
if (group.isCollapsed && connectedChannelId && ch.id === connectedChannelId) {
|
||||
return (
|
||||
<React.Fragment key={ch.id}>
|
||||
{channelRow}
|
||||
{!isCollapsed && (
|
||||
<VoiceParticipantsList
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
data-flx="app.channel-list-content.voice-participants-list"
|
||||
/>
|
||||
)}
|
||||
<CollapsedChannelAvatarStack
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
data-flx="app.channel-list-content.collapsed-channel-avatar-stack"
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={ch.id}>
|
||||
{channelRow}
|
||||
{!group.isCollapsed && (
|
||||
<VoiceParticipantsList
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
data-flx="app.channel-list-content.voice-participants-list"
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showTrailingDropZone && (
|
||||
<div className={styles.bottomDropZone} data-flx="app.channel-list-content.bottom-drop-zone">
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/GuildNavbarSkeleton.module.css';
|
||||
import type React from 'react';
|
||||
|
||||
export const ChannelListSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.skeletonContent} data-flx="app.channel-list-skeleton.skeleton-content">
|
||||
<div className={styles.skeletonCategory} data-flx="app.channel-list-skeleton.skeleton-category">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.channel-list-skeleton.skeleton-category-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--2">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--2" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--3">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--3" />
|
||||
</div>
|
||||
<div className={styles.skeletonCategory} data-flx="app.channel-list-skeleton.skeleton-category--2">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.channel-list-skeleton.skeleton-category-pill--2" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--4">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--4" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--5">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--5" />
|
||||
</div>
|
||||
<div className={styles.skeletonCategory} data-flx="app.channel-list-skeleton.skeleton-category--3">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.channel-list-skeleton.skeleton-category-pill--3" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--6">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--6" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--7">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--7" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--8">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--8" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--9">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--9" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const DirectSelectionSurface = Object.freeze({
|
||||
GUILD_RAIL: 'guild-rail',
|
||||
CHANNEL_LIST: 'channel-list',
|
||||
FAVORITES_LIST: 'favorites-list',
|
||||
} as const);
|
||||
|
||||
export type DirectSelectionSurface = (typeof DirectSelectionSurface)[keyof typeof DirectSelectionSurface];
|
||||
|
||||
const markedSurfaces = new Set<DirectSelectionSurface>();
|
||||
|
||||
export function markDirectSelection(surface: DirectSelectionSurface): void {
|
||||
markedSurfaces.add(surface);
|
||||
}
|
||||
|
||||
export function peekDirectSelection(surface: DirectSelectionSurface): boolean {
|
||||
return markedSurfaces.has(surface);
|
||||
}
|
||||
|
||||
export function consumeDirectSelection(surface: DirectSelectionSurface): boolean {
|
||||
const marked = markedSurfaces.has(surface);
|
||||
markedSurfaces.delete(surface);
|
||||
return marked;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.headerIconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.headerIcon {
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import guildHeaderStyles from '@app/features/app/components/layout/GuildHeader.m
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CompassIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
|
||||
const EXPLORE_DESCRIPTOR = msg({
|
||||
message: 'Explore',
|
||||
@@ -17,7 +15,7 @@ export function DiscoveryGuildHeader() {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<div
|
||||
className={clsx(guildHeaderStyles.headerContainer, guildHeaderStyles.headerContainerNoBanner)}
|
||||
className={guildHeaderStyles.headerContainer}
|
||||
style={{height: remFromPx(56)}}
|
||||
data-flx="app.discovery-guild-header.div"
|
||||
>
|
||||
@@ -26,16 +24,9 @@ export function DiscoveryGuildHeader() {
|
||||
style={{cursor: 'default'}}
|
||||
data-flx="app.discovery-guild-header.div--2"
|
||||
>
|
||||
<div className={styles.headerIconContainer} data-flx="app.discovery-guild-header.header-icon-container">
|
||||
<CompassIcon
|
||||
weight="fill"
|
||||
className={clsx(guildHeaderStyles.verifiedIconDefault, styles.headerIcon)}
|
||||
data-flx="app.discovery-guild-header.header-icon"
|
||||
/>
|
||||
<span className={guildHeaderStyles.guildNameDefault} data-flx="app.discovery-guild-header.span">
|
||||
{i18n._(EXPLORE_DESCRIPTOR)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className={styles.headerTitle} data-flx="app.discovery-guild-header.header-title">
|
||||
{i18n._(EXPLORE_DESCRIPTOR)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,13 +6,19 @@ import channelItemStyles from '@app/features/app/components/layout/ChannelItem.m
|
||||
import {ChannelItemIcon} from '@app/features/app/components/layout/ChannelItemIcon';
|
||||
import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css';
|
||||
import styles from '@app/features/app/components/layout/ChannelListContent.module.css';
|
||||
import {ChannelListSkeleton} from '@app/features/app/components/layout/ChannelListSkeleton';
|
||||
import {
|
||||
DirectSelectionSurface,
|
||||
markDirectSelection,
|
||||
peekDirectSelection,
|
||||
} from '@app/features/app/components/layout/DirectSelectionOrigin';
|
||||
import {computeVerticalDropPosition} from '@app/features/app/components/layout/dnd/DndDropPosition';
|
||||
import {useDragTargetRect} from '@app/features/app/components/layout/dnd/useDragTargetRect';
|
||||
import favoritesChannelListStyles from '@app/features/app/components/layout/FavoritesChannelListContent.module.css';
|
||||
import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem';
|
||||
import {DragItemType} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
|
||||
import {GuildChannelListSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton';
|
||||
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import * as LinkChannelCommands from '@app/features/channel/commands/LinkChannelCommands';
|
||||
@@ -65,24 +71,19 @@ const EMPTY_FAVORITES_DESCRIPTOR = msg({
|
||||
message: 'Empty favorites',
|
||||
comment: 'Short label in the app layout favorites channel list content.',
|
||||
});
|
||||
const DND_TYPES = {
|
||||
FAVORITES_CHANNEL: 'favorites-channel',
|
||||
FAVORITES_CATEGORY: 'favorites-category',
|
||||
} as const;
|
||||
|
||||
type ChannelDragItem = {
|
||||
type: typeof DND_TYPES.FAVORITES_CHANNEL;
|
||||
type: typeof DragItemType.FAVORITES_CHANNEL;
|
||||
channelId: string;
|
||||
parentId: string | null;
|
||||
};
|
||||
type CategoryDragItem = {
|
||||
type: typeof DND_TYPES.FAVORITES_CATEGORY;
|
||||
type: typeof DragItemType.FAVORITES_CATEGORY;
|
||||
categoryId: string;
|
||||
};
|
||||
type DragItem = ChannelDragItem | CategoryDragItem;
|
||||
|
||||
const isCategoryDragItem = (item: DragItem): item is CategoryDragItem => item.type === DND_TYPES.FAVORITES_CATEGORY;
|
||||
const isChannelDragItem = (item: DragItem): item is ChannelDragItem => item.type === DND_TYPES.FAVORITES_CHANNEL;
|
||||
const isCategoryDragItem = (item: DragItem): item is CategoryDragItem => item.type === DragItemType.FAVORITES_CATEGORY;
|
||||
const isChannelDragItem = (item: DragItem): item is ChannelDragItem => item.type === DragItemType.FAVORITES_CHANNEL;
|
||||
|
||||
type FavoriteDropIndicator = {position: 'top' | 'bottom'; isValid: boolean};
|
||||
|
||||
@@ -120,7 +121,8 @@ const FavoriteChannelResolvedItem = observer(
|
||||
const isSelected = location.pathname === Routes.favoritesChannel(favoriteChannel.channelId);
|
||||
const shouldShowSelectedState = isSelected;
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.FAVORITES_LIST);
|
||||
if (isSelected && !selectedFromThisRow) {
|
||||
elementRef.current?.scrollIntoView({block: 'nearest'});
|
||||
}
|
||||
}, [isSelected]);
|
||||
@@ -128,9 +130,9 @@ const FavoriteChannelResolvedItem = observer(
|
||||
const {keyboardModeEnabled} = KeyboardMode;
|
||||
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
|
||||
const [{isDragging}, dragRef, preview] = useDrag<ChannelDragItem, unknown, {isDragging: boolean}>({
|
||||
type: DND_TYPES.FAVORITES_CHANNEL,
|
||||
type: DragItemType.FAVORITES_CHANNEL,
|
||||
item: {
|
||||
type: DND_TYPES.FAVORITES_CHANNEL,
|
||||
type: DragItemType.FAVORITES_CHANNEL,
|
||||
channelId: favoriteChannel.channelId,
|
||||
parentId: favoriteChannel.parentId,
|
||||
},
|
||||
@@ -140,7 +142,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
end: resetFavoriteDropIndicator,
|
||||
});
|
||||
const [{isOver}, dropRef] = useDrop<ChannelDragItem, unknown, {isOver: boolean}>({
|
||||
accept: DND_TYPES.FAVORITES_CHANNEL,
|
||||
accept: DragItemType.FAVORITES_CHANNEL,
|
||||
hover: (_item, monitor) => {
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
@@ -233,6 +235,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
if (LinkChannelCommands.openLinkChannel(channel)) {
|
||||
return;
|
||||
}
|
||||
markDirectSelection(DirectSelectionSurface.FAVORITES_LIST);
|
||||
preloadChannelNow();
|
||||
NavigationCommands.selectChannel(FAVORITES_GUILD_ID, favoriteChannel.channelId);
|
||||
};
|
||||
@@ -427,9 +430,9 @@ const FavoriteCategoryItem = observer(
|
||||
const {keyboardModeEnabled} = KeyboardMode;
|
||||
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
|
||||
const [{isDragging}, dragRef, preview] = useDrag<CategoryDragItem, unknown, {isDragging: boolean}>({
|
||||
type: DND_TYPES.FAVORITES_CATEGORY,
|
||||
type: DragItemType.FAVORITES_CATEGORY,
|
||||
item: {
|
||||
type: DND_TYPES.FAVORITES_CATEGORY,
|
||||
type: DragItemType.FAVORITES_CATEGORY,
|
||||
categoryId: category.id,
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
@@ -442,7 +445,7 @@ const FavoriteCategoryItem = observer(
|
||||
unknown,
|
||||
{isOver: boolean; draggedItemType: string | symbol | null}
|
||||
>({
|
||||
accept: [DND_TYPES.FAVORITES_CHANNEL, DND_TYPES.FAVORITES_CATEGORY],
|
||||
accept: [DragItemType.FAVORITES_CHANNEL, DragItemType.FAVORITES_CATEGORY],
|
||||
hover: (item, monitor) => {
|
||||
if (!isCategoryDragItem(item)) {
|
||||
resetCategoryDropIndicator();
|
||||
@@ -490,8 +493,8 @@ const FavoriteCategoryItem = observer(
|
||||
draggedItemType: monitor.getItemType(),
|
||||
}),
|
||||
});
|
||||
const isCategoryDragOver = isOver && draggedItemType === DND_TYPES.FAVORITES_CATEGORY;
|
||||
const isChannelDragOver = isOver && draggedItemType === DND_TYPES.FAVORITES_CHANNEL;
|
||||
const isCategoryDragOver = isOver && draggedItemType === DragItemType.FAVORITES_CATEGORY;
|
||||
const isChannelDragOver = isOver && draggedItemType === DragItemType.FAVORITES_CHANNEL;
|
||||
useEffect(() => {
|
||||
if (!isCategoryDragOver) resetCategoryDropIndicator();
|
||||
}, [isCategoryDragOver, resetCategoryDropIndicator]);
|
||||
@@ -605,7 +608,7 @@ const FavoriteCategoryItem = observer(
|
||||
);
|
||||
const UncategorizedGroup = ({children}: {children: React.ReactNode}) => {
|
||||
const [{isOver}, dropRef] = useDrop<ChannelDragItem, unknown, {isOver: boolean}>({
|
||||
accept: DND_TYPES.FAVORITES_CHANNEL,
|
||||
accept: DragItemType.FAVORITES_CHANNEL,
|
||||
drop: (item, monitor) => {
|
||||
if (monitor.didDrop()) return;
|
||||
if (item.parentId === null) return;
|
||||
@@ -640,7 +643,8 @@ export const FavoritesChannelListContent = observer(() => {
|
||||
const isDraggingFavorite = useDragLayer((monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
return (
|
||||
monitor.isDragging() && (itemType === DND_TYPES.FAVORITES_CHANNEL || itemType === DND_TYPES.FAVORITES_CATEGORY)
|
||||
monitor.isDragging() &&
|
||||
(itemType === DragItemType.FAVORITES_CHANNEL || itemType === DragItemType.FAVORITES_CATEGORY)
|
||||
);
|
||||
});
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
@@ -704,7 +708,11 @@ export const FavoritesChannelListContent = observer(() => {
|
||||
aria-label={i18n._(EMPTY_FAVORITES_DESCRIPTOR)}
|
||||
data-flx="app.favorites-channel-list-content.region.context-menu"
|
||||
>
|
||||
<ChannelListSkeleton data-flx="app.favorites-channel-list-content.channel-list-skeleton" />
|
||||
<GuildChannelListSkeleton
|
||||
channelList={null}
|
||||
detachedBannerAspectRatio={null}
|
||||
data-flx="app.favorites-channel-list-content.guild-channel-list-skeleton"
|
||||
/>
|
||||
<div className={styles.bottomSpacer} data-flx="app.favorites-channel-list-content.bottom-spacer.empty" />
|
||||
</div>
|
||||
</Scroller>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorStateMachine';
|
||||
|
||||
export interface FloatingUnreadTarget {
|
||||
id: string;
|
||||
severity: ScrollIndicatorSeverity;
|
||||
}
|
||||
|
||||
export interface MeasuredFloatingUnreadTarget {
|
||||
id: string;
|
||||
severity: ScrollIndicatorSeverity;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface FloatingUnreadEdges {
|
||||
top: MeasuredFloatingUnreadTarget | null;
|
||||
bottom: MeasuredFloatingUnreadTarget | null;
|
||||
}
|
||||
|
||||
export interface FloatingUnreadTargetIndex {
|
||||
byBottom: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
topCandidates: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
byTop: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
bottomCandidates: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
}
|
||||
|
||||
export interface FloatingUnreadTargetBounds {
|
||||
readonly bottom: number;
|
||||
readonly top: number;
|
||||
}
|
||||
|
||||
export interface FloatingUnreadViewport {
|
||||
scrollTop: number;
|
||||
viewportHeight: number;
|
||||
targetIndex: FloatingUnreadTargetIndex;
|
||||
}
|
||||
|
||||
interface CreateFloatingUnreadTargetIndexFromMeasurementsRequest {
|
||||
readonly targets: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
}
|
||||
|
||||
interface CreateFloatingUnreadTargetIndexFromBoundsRequest {
|
||||
readonly boundsByTargetId: ReadonlyMap<string, FloatingUnreadTargetBounds>;
|
||||
readonly targets: ReadonlyArray<FloatingUnreadTarget>;
|
||||
}
|
||||
|
||||
interface CompareFloatingUnreadEdgesQuery {
|
||||
readonly left: FloatingUnreadEdges;
|
||||
readonly right: FloatingUnreadEdges;
|
||||
}
|
||||
|
||||
interface PreserveStableFloatingUnreadEdgesQuery {
|
||||
readonly current: FloatingUnreadEdges;
|
||||
readonly next: FloatingUnreadEdges;
|
||||
}
|
||||
|
||||
const VISIBILITY_EPSILON = 0.5;
|
||||
const EMPTY_MEASURED_TARGETS: ReadonlyArray<MeasuredFloatingUnreadTarget> = Object.freeze([]);
|
||||
|
||||
function compareTargetIdentifiers(left: string, right: string): number {
|
||||
if (left === right) return 0;
|
||||
if (left < right) return -1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
class FloatingUnreadEdgeLayoutOwner {
|
||||
public readonly EMPTY_EDGES: FloatingUnreadEdges = Object.freeze({top: null, bottom: null});
|
||||
public readonly EMPTY_TARGET_INDEX: FloatingUnreadTargetIndex = Object.freeze({
|
||||
byBottom: EMPTY_MEASURED_TARGETS,
|
||||
topCandidates: EMPTY_MEASURED_TARGETS,
|
||||
byTop: EMPTY_MEASURED_TARGETS,
|
||||
bottomCandidates: EMPTY_MEASURED_TARGETS,
|
||||
});
|
||||
|
||||
public createTargetIndexFromMeasurements({
|
||||
targets,
|
||||
}: CreateFloatingUnreadTargetIndexFromMeasurementsRequest): FloatingUnreadTargetIndex {
|
||||
if (targets.length === 0) return this.EMPTY_TARGET_INDEX;
|
||||
|
||||
const byBottom = targets.slice().sort(this.compareByBottom);
|
||||
const topCandidates = new Array<MeasuredFloatingUnreadTarget>(byBottom.length);
|
||||
let topCandidate: MeasuredFloatingUnreadTarget | null = null;
|
||||
for (let index = 0; index < byBottom.length; index++) {
|
||||
topCandidate = this.chooseTopCandidate(topCandidate, byBottom[index]);
|
||||
topCandidates[index] = topCandidate;
|
||||
}
|
||||
|
||||
const byTop = targets.slice().sort(this.compareByTop);
|
||||
const bottomCandidates = new Array<MeasuredFloatingUnreadTarget>(byTop.length);
|
||||
let bottomCandidate: MeasuredFloatingUnreadTarget | null = null;
|
||||
for (let index = byTop.length - 1; index >= 0; index--) {
|
||||
bottomCandidate = this.chooseBottomCandidate(byTop[index], bottomCandidate);
|
||||
bottomCandidates[index] = bottomCandidate;
|
||||
}
|
||||
|
||||
return {byBottom, topCandidates, byTop, bottomCandidates};
|
||||
}
|
||||
|
||||
public createTargetIndexFromBounds({
|
||||
targets,
|
||||
boundsByTargetId,
|
||||
}: CreateFloatingUnreadTargetIndexFromBoundsRequest): FloatingUnreadTargetIndex {
|
||||
if (targets.length === 0) return this.EMPTY_TARGET_INDEX;
|
||||
const measuredTargets: Array<MeasuredFloatingUnreadTarget> = [];
|
||||
for (const target of targets) {
|
||||
const bounds = boundsByTargetId.get(target.id);
|
||||
if (bounds == null) continue;
|
||||
measuredTargets.push({...target, ...bounds});
|
||||
}
|
||||
return this.createTargetIndexFromMeasurements({targets: measuredTargets});
|
||||
}
|
||||
|
||||
public selectEdges({scrollTop, viewportHeight, targetIndex}: FloatingUnreadViewport): FloatingUnreadEdges {
|
||||
const viewportTop = Math.max(0, scrollTop);
|
||||
const viewportBottom = viewportTop + Math.max(0, viewportHeight);
|
||||
const topIndex = this.findLastTargetEndingBefore(targetIndex.byBottom, viewportTop + VISIBILITY_EPSILON);
|
||||
const bottomIndex = this.findFirstTargetStartingAfter(targetIndex.byTop, viewportBottom - VISIBILITY_EPSILON);
|
||||
let top: MeasuredFloatingUnreadTarget | null;
|
||||
if (topIndex >= 0) {
|
||||
top = targetIndex.topCandidates[topIndex];
|
||||
} else {
|
||||
top = null;
|
||||
}
|
||||
let bottom: MeasuredFloatingUnreadTarget | null;
|
||||
if (bottomIndex < targetIndex.byTop.length) {
|
||||
bottom = targetIndex.bottomCandidates[bottomIndex];
|
||||
} else {
|
||||
bottom = null;
|
||||
}
|
||||
return {top, bottom};
|
||||
}
|
||||
|
||||
public areEdgesEqual({left, right}: CompareFloatingUnreadEdgesQuery): boolean {
|
||||
return this.areEdgesEqualAtPosition(left.top, right.top) && this.areEdgesEqualAtPosition(left.bottom, right.bottom);
|
||||
}
|
||||
|
||||
public preserveStableEdges({current, next}: PreserveStableFloatingUnreadEdgesQuery): FloatingUnreadEdges {
|
||||
if (this.areEdgesEqual({left: current, right: next})) {
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private chooseTopCandidate(
|
||||
current: MeasuredFloatingUnreadTarget | null,
|
||||
next: MeasuredFloatingUnreadTarget,
|
||||
): MeasuredFloatingUnreadTarget {
|
||||
if (current == null || (current.severity !== 'mention' && next.severity === 'mention')) {
|
||||
return next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private chooseBottomCandidate(
|
||||
current: MeasuredFloatingUnreadTarget,
|
||||
next: MeasuredFloatingUnreadTarget | null,
|
||||
): MeasuredFloatingUnreadTarget {
|
||||
if (next == null || (next.severity !== 'mention' && current.severity === 'mention')) {
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private compareByBottom(left: MeasuredFloatingUnreadTarget, right: MeasuredFloatingUnreadTarget): number {
|
||||
const bottomDifference = left.bottom - right.bottom;
|
||||
if (bottomDifference !== 0) return bottomDifference;
|
||||
const topDifference = left.top - right.top;
|
||||
if (topDifference !== 0) return topDifference;
|
||||
return compareTargetIdentifiers(left.id, right.id);
|
||||
}
|
||||
|
||||
private compareByTop(left: MeasuredFloatingUnreadTarget, right: MeasuredFloatingUnreadTarget): number {
|
||||
const topDifference = left.top - right.top;
|
||||
if (topDifference !== 0) return topDifference;
|
||||
const bottomDifference = left.bottom - right.bottom;
|
||||
if (bottomDifference !== 0) return bottomDifference;
|
||||
return compareTargetIdentifiers(left.id, right.id);
|
||||
}
|
||||
|
||||
private findLastTargetEndingBefore(
|
||||
targets: ReadonlyArray<MeasuredFloatingUnreadTarget>,
|
||||
viewportTop: number,
|
||||
): number {
|
||||
let low = 0;
|
||||
let high = targets.length;
|
||||
while (low < high) {
|
||||
const middle = low + Math.floor((high - low) / 2);
|
||||
if (targets[middle].bottom <= viewportTop) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
return low - 1;
|
||||
}
|
||||
|
||||
private findFirstTargetStartingAfter(
|
||||
targets: ReadonlyArray<MeasuredFloatingUnreadTarget>,
|
||||
viewportBottom: number,
|
||||
): number {
|
||||
let low = 0;
|
||||
let high = targets.length;
|
||||
while (low < high) {
|
||||
const middle = low + Math.floor((high - low) / 2);
|
||||
if (targets[middle].top < viewportBottom) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
private areEdgesEqualAtPosition(
|
||||
left: MeasuredFloatingUnreadTarget | null,
|
||||
right: MeasuredFloatingUnreadTarget | null,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (left == null || right == null) return false;
|
||||
return left.id === right.id && left.severity === right.severity;
|
||||
}
|
||||
}
|
||||
|
||||
export const FloatingUnreadEdgeLayout = Object.freeze(new FloatingUnreadEdgeLayoutOwner());
|
||||
@@ -0,0 +1,50 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-index-elevated-2);
|
||||
}
|
||||
|
||||
.slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slotTop {
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
.slotBottom {
|
||||
bottom: var(--floating-unread-indicator-bottom-inset, 0.5rem);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
pointer-events: auto;
|
||||
border: none;
|
||||
border-radius: 62.4375rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1rem;
|
||||
letter-spacing: 0.025em;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0.625rem 1.25rem -0.75rem rgba(0, 0, 0, 0.75),
|
||||
0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.indicatorBrand {
|
||||
background-color: hsl(220, 6%, 30%);
|
||||
}
|
||||
|
||||
.indicatorMention {
|
||||
background-color: var(--status-danger);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {
|
||||
FloatingUnreadEdgeLayout,
|
||||
type FloatingUnreadEdges,
|
||||
type FloatingUnreadTarget,
|
||||
type FloatingUnreadTargetBounds,
|
||||
type MeasuredFloatingUnreadTarget,
|
||||
} from '@app/features/app/components/layout/FloatingUnreadEdges';
|
||||
import styles from '@app/features/app/components/layout/FloatingUnreadIndicators.module.css';
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorStateMachine';
|
||||
import {Edge, type VerticalEdge} from '@app/features/ui/AxisOrientation';
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {AnimeButton, AnimePresence} from '@app/features/ui/motion/AnimeElement';
|
||||
import {resolveVisibleScrollViewportHeight} from '@app/features/ui/utils/ScrollViewportGeometry';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const SCROLL_TARGET_PADDING_PX = 12;
|
||||
const SETTLE_REFRESH_DELAY_MS = 280;
|
||||
|
||||
const FloatingUnreadRefreshScope = Object.freeze({
|
||||
GEOMETRY: 'geometry',
|
||||
SCROLL: 'scroll',
|
||||
TARGETS: 'targets',
|
||||
} as const);
|
||||
|
||||
type FloatingUnreadRefreshScope = (typeof FloatingUnreadRefreshScope)[keyof typeof FloatingUnreadRefreshScope];
|
||||
|
||||
function buildFloatingUnreadTargetsSignature(targets: ReadonlyArray<FloatingUnreadTarget>): string {
|
||||
if (targets.length === 0) return '';
|
||||
const parts = new Array<string>(targets.length);
|
||||
for (let index = 0; index < targets.length; index += 1) {
|
||||
const target = targets[index];
|
||||
parts[index] = `${target.id}:${target.severity}`;
|
||||
}
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
export interface FloatingUnreadTargetRegistry {
|
||||
register: (id: string) => React.RefCallback<HTMLElement>;
|
||||
getTargetNode: (id: string) => HTMLElement | null;
|
||||
}
|
||||
|
||||
export function useFloatingUnreadTargetRegistry(): FloatingUnreadTargetRegistry {
|
||||
const nodesRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const callbacksRef = useRef<Map<string, React.RefCallback<HTMLElement>>>(new Map());
|
||||
const register = useCallback((id: string): React.RefCallback<HTMLElement> => {
|
||||
const cached = callbacksRef.current.get(id);
|
||||
if (cached != null) return cached;
|
||||
const callback = (node: HTMLElement | null) => {
|
||||
const registeredNode = nodesRef.current.get(id);
|
||||
if (registeredNode === node) return;
|
||||
if (registeredNode == null && node == null) return;
|
||||
if (node != null) {
|
||||
nodesRef.current.set(id, node);
|
||||
callbacksRef.current.set(id, callback);
|
||||
} else {
|
||||
nodesRef.current.delete(id);
|
||||
callbacksRef.current.delete(id);
|
||||
}
|
||||
};
|
||||
callbacksRef.current.set(id, callback);
|
||||
return callback;
|
||||
}, []);
|
||||
const getTargetNode = useCallback((id: string): HTMLElement | null => {
|
||||
const node = nodesRef.current.get(id);
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
return node;
|
||||
}, []);
|
||||
return useMemo(() => ({register, getTargetNode}), [register, getTargetNode]);
|
||||
}
|
||||
|
||||
interface FloatingUnreadPillProps {
|
||||
readonly label: string;
|
||||
readonly severity: ScrollIndicatorSeverity;
|
||||
readonly direction: VerticalEdge;
|
||||
readonly onClick: () => void;
|
||||
}
|
||||
|
||||
interface VisibleFloatingUnreadEdge {
|
||||
readonly direction: VerticalEdge;
|
||||
readonly edge: MeasuredFloatingUnreadTarget;
|
||||
}
|
||||
|
||||
const FloatingUnreadPill = ({label, severity, direction, onClick}: FloatingUnreadPillProps) => {
|
||||
const prefersReducedMotion = Accessibility.useReducedMotion;
|
||||
let directionOffset: 10 | -10;
|
||||
if (direction === Edge.TOP) {
|
||||
directionOffset = -10;
|
||||
} else {
|
||||
directionOffset = 10;
|
||||
}
|
||||
let severityClassName = styles.indicatorBrand;
|
||||
if (severity === 'mention') {
|
||||
severityClassName = styles.indicatorMention;
|
||||
}
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<FocusRing offset={-2} data-flx="app.floating-unread-indicators.floating-unread-pill.focus-ring">
|
||||
<AnimeButton
|
||||
type="button"
|
||||
className={clsx(styles.indicator, severityClassName)}
|
||||
onClick={onClick}
|
||||
from={{opacity: 1, translateY: 0, scale: 1}}
|
||||
to={{opacity: 1, translateY: 0, scale: 1}}
|
||||
leave={{opacity: 1, translateY: 0, scale: 1, tween: {duration: 0}}}
|
||||
tween={{duration: 0, ease: 'out(3)'}}
|
||||
aria-label={label}
|
||||
data-flx="app.floating-unread-indicators.floating-unread-pill.indicator.click.button"
|
||||
>
|
||||
{label}
|
||||
</AnimeButton>
|
||||
</FocusRing>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FocusRing offset={-2} data-flx="app.floating-unread-indicators.floating-unread-pill.focus-ring--2">
|
||||
<AnimeButton
|
||||
type="button"
|
||||
className={clsx(styles.indicator, severityClassName)}
|
||||
onClick={onClick}
|
||||
from={{opacity: 0, translateY: directionOffset, scale: 0.96}}
|
||||
to={{opacity: 1, translateY: 0, scale: 1}}
|
||||
leave={{
|
||||
opacity: 0,
|
||||
translateY: directionOffset,
|
||||
scale: 0.96,
|
||||
tween: {duration: 0.16, ease: 'inOut(2)'},
|
||||
}}
|
||||
tween={{duration: 0.18, ease: 'out(3)'}}
|
||||
hover={{scale: 1.05}}
|
||||
press={{translateY: 1}}
|
||||
aria-label={label}
|
||||
data-flx="app.floating-unread-indicators.floating-unread-pill.indicator.click.button--2"
|
||||
>
|
||||
{label}
|
||||
</AnimeButton>
|
||||
</FocusRing>
|
||||
);
|
||||
};
|
||||
|
||||
export interface FloatingUnreadIndicatorsProps {
|
||||
readonly label: string;
|
||||
readonly scrollerRef: React.RefObject<ScrollerHandle | null>;
|
||||
readonly scrollerIdentity: string;
|
||||
readonly targets: ReadonlyArray<FloatingUnreadTarget>;
|
||||
readonly getTargetNode: (id: string) => HTMLElement | null;
|
||||
readonly measurementRevision: number;
|
||||
readonly getTargetBounds: (
|
||||
targets: ReadonlyArray<FloatingUnreadTarget>,
|
||||
) => ReadonlyMap<string, FloatingUnreadTargetBounds>;
|
||||
readonly scrollToTarget: (id: string, direction: VerticalEdge) => boolean;
|
||||
}
|
||||
|
||||
function resolveScrollerNode(scroller: ScrollerHandle | null): HTMLElement | null {
|
||||
if (scroller == null) {
|
||||
return null;
|
||||
}
|
||||
return scroller.getScrollerNode();
|
||||
}
|
||||
|
||||
function resolveOwnerWindow(container: HTMLElement): Window & typeof globalThis {
|
||||
const defaultView = container.ownerDocument.defaultView;
|
||||
if (defaultView == null) {
|
||||
throw new Error('Floating unread indicator container has no owner window');
|
||||
}
|
||||
return defaultView;
|
||||
}
|
||||
|
||||
function observeScrollContentElements(
|
||||
resizeObserver: ResizeObserver,
|
||||
container: HTMLElement,
|
||||
ownerWindow: Window & typeof globalThis,
|
||||
): void {
|
||||
const contentWrapper = container.firstElementChild;
|
||||
if (!(contentWrapper instanceof ownerWindow.HTMLElement)) return;
|
||||
const contentChildren = contentWrapper.children;
|
||||
if (contentChildren.length === 0) {
|
||||
resizeObserver.observe(contentWrapper);
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < contentChildren.length; index += 1) {
|
||||
const child = contentChildren[index];
|
||||
if (child instanceof ownerWindow.HTMLElement) resizeObserver.observe(child);
|
||||
}
|
||||
}
|
||||
|
||||
interface OwnedAnimationFrame {
|
||||
readonly id: number;
|
||||
readonly ownerWindow: Window & typeof globalThis;
|
||||
}
|
||||
|
||||
interface OwnedSettleTimer {
|
||||
readonly id: number;
|
||||
readonly ownerWindow: Window & typeof globalThis;
|
||||
}
|
||||
|
||||
interface AlignMountedTargetRequest {
|
||||
readonly animate: boolean;
|
||||
readonly direction: VerticalEdge;
|
||||
readonly scroller: ScrollerHandle;
|
||||
readonly targetNode: HTMLElement;
|
||||
}
|
||||
|
||||
function cancelOwnedAnimationFrame(frame: OwnedAnimationFrame | null): void {
|
||||
if (frame == null) return;
|
||||
frame.ownerWindow.cancelAnimationFrame(frame.id);
|
||||
}
|
||||
|
||||
function cancelOwnedSettleTimer(timer: OwnedSettleTimer | null): void {
|
||||
if (timer == null) return;
|
||||
timer.ownerWindow.clearTimeout(timer.id);
|
||||
}
|
||||
|
||||
function alignMountedTarget({animate, direction, scroller, targetNode}: AlignMountedTargetRequest): boolean {
|
||||
const container = scroller.getScrollerNode();
|
||||
if (container == null) return false;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = targetNode.getBoundingClientRect();
|
||||
const targetTop = container.scrollTop + targetRect.top - containerRect.top;
|
||||
const targetBottom = container.scrollTop + targetRect.bottom - containerRect.top;
|
||||
let to = targetBottom + SCROLL_TARGET_PADDING_PX - resolveVisibleScrollViewportHeight(container);
|
||||
if (direction === Edge.TOP) {
|
||||
to = targetTop - SCROLL_TARGET_PADDING_PX;
|
||||
}
|
||||
scroller.scrollTo({to, animate});
|
||||
return true;
|
||||
}
|
||||
|
||||
export const FloatingUnreadIndicators = observer(
|
||||
({
|
||||
label,
|
||||
scrollerRef,
|
||||
scrollerIdentity,
|
||||
targets,
|
||||
getTargetNode,
|
||||
measurementRevision,
|
||||
getTargetBounds,
|
||||
scrollToTarget,
|
||||
}: FloatingUnreadIndicatorsProps) => {
|
||||
const [edges, setEdges] = useState<FloatingUnreadEdges>(FloatingUnreadEdgeLayout.EMPTY_EDGES);
|
||||
const targetsSignature = useMemo(() => buildFloatingUnreadTargetsSignature(targets), [targets]);
|
||||
const targetsRef = useRef(targets);
|
||||
const getTargetNodeRef = useRef(getTargetNode);
|
||||
const getTargetBoundsRef = useRef(getTargetBounds);
|
||||
const scrollToTargetRef = useRef(scrollToTarget);
|
||||
targetsRef.current = targets;
|
||||
getTargetNodeRef.current = getTargetNode;
|
||||
getTargetBoundsRef.current = getTargetBounds;
|
||||
scrollToTargetRef.current = scrollToTarget;
|
||||
const rafRef = useRef<OwnedAnimationFrame | null>(null);
|
||||
const targetAlignmentFrameRef = useRef<OwnedAnimationFrame | null>(null);
|
||||
const settleTimerRef = useRef<OwnedSettleTimer | null>(null);
|
||||
const geometrySettleTimerRef = useRef<OwnedSettleTimer | null>(null);
|
||||
const geometrySettleArmedRef = useRef(false);
|
||||
const geometrySettleSuppressedRef = useRef(false);
|
||||
const refreshNowRef = useRef<() => void>(() => {});
|
||||
const geometryInvalidatedRef = useRef(true);
|
||||
const targetIndexInvalidatedRef = useRef(true);
|
||||
const visibleViewportHeightRef = useRef(0);
|
||||
const targetBoundsCacheRef = useRef<Map<string, FloatingUnreadTargetBounds>>(new Map());
|
||||
const pendingScrollTopRef = useRef<number | null>(null);
|
||||
const targetIndexRef = useRef(FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX);
|
||||
const refreshTargetIndex = useCallback(() => {
|
||||
const currentTargets = targetsRef.current;
|
||||
const boundsCache = targetBoundsCacheRef.current;
|
||||
const unmeasuredTargets: Array<FloatingUnreadTarget> = [];
|
||||
for (const target of currentTargets) {
|
||||
if (boundsCache.has(target.id)) continue;
|
||||
unmeasuredTargets.push(target);
|
||||
}
|
||||
if (unmeasuredTargets.length > 0) {
|
||||
const measuredBounds = getTargetBoundsRef.current(unmeasuredTargets);
|
||||
for (const [id, bounds] of measuredBounds) {
|
||||
boundsCache.set(id, bounds);
|
||||
}
|
||||
}
|
||||
if (boundsCache.size > currentTargets.length) {
|
||||
const retainedBounds = new Map<string, FloatingUnreadTargetBounds>();
|
||||
for (const target of currentTargets) {
|
||||
const bounds = boundsCache.get(target.id);
|
||||
if (bounds != null) retainedBounds.set(target.id, bounds);
|
||||
}
|
||||
targetBoundsCacheRef.current = retainedBounds;
|
||||
}
|
||||
targetIndexRef.current = FloatingUnreadEdgeLayout.createTargetIndexFromBounds({
|
||||
targets: currentTargets,
|
||||
boundsByTargetId: targetBoundsCacheRef.current,
|
||||
});
|
||||
}, []);
|
||||
const refreshNow = useCallback(() => {
|
||||
const container = resolveScrollerNode(scrollerRef.current);
|
||||
if (container == null) {
|
||||
pendingScrollTopRef.current = null;
|
||||
targetIndexRef.current = FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX;
|
||||
setEdges(FloatingUnreadEdgeLayout.EMPTY_EDGES);
|
||||
return;
|
||||
}
|
||||
if (targetsRef.current.length === 0) {
|
||||
pendingScrollTopRef.current = null;
|
||||
targetBoundsCacheRef.current.clear();
|
||||
targetIndexRef.current = FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX;
|
||||
setEdges(FloatingUnreadEdgeLayout.EMPTY_EDGES);
|
||||
return;
|
||||
}
|
||||
if (geometryInvalidatedRef.current) {
|
||||
geometryInvalidatedRef.current = false;
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
targetBoundsCacheRef.current.clear();
|
||||
visibleViewportHeightRef.current = resolveVisibleScrollViewportHeight(container);
|
||||
if (geometrySettleSuppressedRef.current) {
|
||||
geometrySettleSuppressedRef.current = false;
|
||||
geometrySettleArmedRef.current = false;
|
||||
} else if (!geometrySettleArmedRef.current) {
|
||||
geometrySettleArmedRef.current = true;
|
||||
const settleOwnerWindow = resolveOwnerWindow(container);
|
||||
const settleId = settleOwnerWindow.setTimeout(() => {
|
||||
geometrySettleTimerRef.current = null;
|
||||
geometrySettleArmedRef.current = false;
|
||||
geometrySettleSuppressedRef.current = true;
|
||||
geometryInvalidatedRef.current = true;
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
refreshNowRef.current();
|
||||
}, SETTLE_REFRESH_DELAY_MS);
|
||||
geometrySettleTimerRef.current = {id: settleId, ownerWindow: settleOwnerWindow};
|
||||
}
|
||||
}
|
||||
if (targetIndexInvalidatedRef.current) {
|
||||
targetIndexInvalidatedRef.current = false;
|
||||
refreshTargetIndex();
|
||||
}
|
||||
const pendingScrollTop = pendingScrollTopRef.current;
|
||||
pendingScrollTopRef.current = null;
|
||||
let scrollTop: number;
|
||||
if (pendingScrollTop == null) {
|
||||
scrollTop = container.scrollTop;
|
||||
} else {
|
||||
scrollTop = pendingScrollTop;
|
||||
}
|
||||
const next = FloatingUnreadEdgeLayout.selectEdges({
|
||||
scrollTop,
|
||||
viewportHeight: visibleViewportHeightRef.current,
|
||||
targetIndex: targetIndexRef.current,
|
||||
});
|
||||
setEdges((current) => FloatingUnreadEdgeLayout.preserveStableEdges({current, next}));
|
||||
}, [refreshTargetIndex, scrollerRef]);
|
||||
refreshNowRef.current = refreshNow;
|
||||
const scheduleRefresh = useCallback(
|
||||
(scope: FloatingUnreadRefreshScope) => {
|
||||
if (scope === FloatingUnreadRefreshScope.GEOMETRY) {
|
||||
geometryInvalidatedRef.current = true;
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
geometrySettleSuppressedRef.current = false;
|
||||
geometrySettleArmedRef.current = false;
|
||||
cancelOwnedSettleTimer(geometrySettleTimerRef.current);
|
||||
geometrySettleTimerRef.current = null;
|
||||
} else if (scope === FloatingUnreadRefreshScope.TARGETS) {
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
if (geometrySettleArmedRef.current) geometryInvalidatedRef.current = true;
|
||||
}
|
||||
const container = resolveScrollerNode(scrollerRef.current);
|
||||
if (container == null) {
|
||||
refreshNow();
|
||||
return;
|
||||
}
|
||||
const ownerWindow = resolveOwnerWindow(container);
|
||||
const pendingFrame = rafRef.current;
|
||||
if (pendingFrame != null && pendingFrame.ownerWindow === ownerWindow) return;
|
||||
cancelOwnedAnimationFrame(pendingFrame);
|
||||
const id = ownerWindow.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
refreshNow();
|
||||
});
|
||||
rafRef.current = {id, ownerWindow};
|
||||
},
|
||||
[refreshNow, scrollerRef],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const container = resolveScrollerNode(scrollerRef.current);
|
||||
if (container == null) {
|
||||
targetIndexRef.current = FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX;
|
||||
setEdges(FloatingUnreadEdgeLayout.EMPTY_EDGES);
|
||||
return;
|
||||
}
|
||||
geometryInvalidatedRef.current = true;
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
refreshNow();
|
||||
const ownerWindow = resolveOwnerWindow(container);
|
||||
const handleScroll = () => {
|
||||
pendingScrollTopRef.current = container.scrollTop;
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.SCROLL);
|
||||
};
|
||||
const handleLayoutChange = () => scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
container.addEventListener('scroll', handleScroll, {passive: true});
|
||||
ownerWindow.addEventListener('resize', handleLayoutChange);
|
||||
let resizeObserver: ResizeObserver | null;
|
||||
if (ownerWindow.ResizeObserver != null) {
|
||||
resizeObserver = new ownerWindow.ResizeObserver(handleLayoutChange);
|
||||
} else {
|
||||
resizeObserver = null;
|
||||
}
|
||||
if (resizeObserver != null) {
|
||||
resizeObserver.observe(container);
|
||||
observeScrollContentElements(resizeObserver, container, ownerWindow);
|
||||
}
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
ownerWindow.removeEventListener('resize', handleLayoutChange);
|
||||
if (resizeObserver != null) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
cancelOwnedAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
cancelOwnedAnimationFrame(targetAlignmentFrameRef.current);
|
||||
targetAlignmentFrameRef.current = null;
|
||||
cancelOwnedSettleTimer(settleTimerRef.current);
|
||||
settleTimerRef.current = null;
|
||||
cancelOwnedSettleTimer(geometrySettleTimerRef.current);
|
||||
geometrySettleTimerRef.current = null;
|
||||
geometrySettleArmedRef.current = false;
|
||||
geometrySettleSuppressedRef.current = false;
|
||||
};
|
||||
}, [refreshNow, scheduleRefresh, scrollerIdentity, scrollerRef]);
|
||||
useLayoutEffect(() => {
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
}, [measurementRevision, scheduleRefresh]);
|
||||
useLayoutEffect(() => {
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.TARGETS);
|
||||
}, [scheduleRefresh, targetsSignature]);
|
||||
const alignTarget = useCallback(
|
||||
(id: string, direction: VerticalEdge, animate: boolean): boolean => {
|
||||
const scroller = scrollerRef.current;
|
||||
const targetNode = getTargetNodeRef.current(id);
|
||||
if (scroller == null || targetNode == null) return false;
|
||||
return alignMountedTarget({animate, direction, scroller, targetNode});
|
||||
},
|
||||
[scrollerRef],
|
||||
);
|
||||
const scrollToEdge = useCallback(
|
||||
(edge: MeasuredFloatingUnreadTarget, direction: VerticalEdge) => {
|
||||
const scroller = scrollerRef.current;
|
||||
const scrollTarget = scrollToTargetRef.current;
|
||||
if (scrollTarget(edge.id, direction)) {
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
if (scroller == null) return;
|
||||
const scrollNode = scroller.getScrollerNode();
|
||||
if (scrollNode == null) return;
|
||||
const ownerWindow = resolveOwnerWindow(scrollNode);
|
||||
cancelOwnedAnimationFrame(targetAlignmentFrameRef.current);
|
||||
const id = ownerWindow.requestAnimationFrame(() => {
|
||||
targetAlignmentFrameRef.current = null;
|
||||
alignTarget(edge.id, direction, Accessibility.useSmoothScrolling);
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
});
|
||||
targetAlignmentFrameRef.current = {id, ownerWindow};
|
||||
} else if (!alignTarget(edge.id, direction, Accessibility.useSmoothScrolling)) {
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
return;
|
||||
}
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.SCROLL);
|
||||
if (scroller == null) return;
|
||||
const scrollNode = scroller.getScrollerNode();
|
||||
if (scrollNode == null) return;
|
||||
const ownerWindow = resolveOwnerWindow(scrollNode);
|
||||
cancelOwnedSettleTimer(settleTimerRef.current);
|
||||
const id = ownerWindow.setTimeout(() => {
|
||||
settleTimerRef.current = null;
|
||||
alignTarget(edge.id, direction, false);
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
}, SETTLE_REFRESH_DELAY_MS);
|
||||
settleTimerRef.current = {id, ownerWindow};
|
||||
},
|
||||
[alignTarget, scheduleRefresh, scrollerRef],
|
||||
);
|
||||
const visibleEdges: Array<VisibleFloatingUnreadEdge> = [];
|
||||
if (edges.top != null) visibleEdges.push({direction: Edge.TOP, edge: edges.top});
|
||||
if (edges.bottom != null) visibleEdges.push({direction: Edge.BOTTOM, edge: edges.bottom});
|
||||
function renderVisibleEdge({direction, edge}: VisibleFloatingUnreadEdge): React.ReactNode {
|
||||
let slotPositionClassName = styles.slotBottom;
|
||||
if (direction === Edge.TOP) {
|
||||
slotPositionClassName = styles.slotTop;
|
||||
}
|
||||
return (
|
||||
<flx-app-floating-unread-indicators-slot
|
||||
key={`${direction}:${edge.id}:${edge.severity}`}
|
||||
className={flxElementClassName(styles.slot, slotPositionClassName)}
|
||||
data-flx="app.floating-unread-indicators.render-visible-edge.slot"
|
||||
>
|
||||
<FloatingUnreadPill
|
||||
label={label}
|
||||
severity={edge.severity}
|
||||
direction={direction}
|
||||
onClick={() => scrollToEdge(edge, direction)}
|
||||
data-flx="app.floating-unread-indicators.render-visible-edge.floating-unread-pill.scroll-to-edge"
|
||||
/>
|
||||
</flx-app-floating-unread-indicators-slot>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<flx-app-floating-unread-indicators-layer
|
||||
className={flxElementClassName(styles.layer)}
|
||||
data-flx="app.floating-unread-indicators.layer"
|
||||
>
|
||||
<AnimePresence enterOnMount={false} data-flx="app.floating-unread-indicators.anime-presence">
|
||||
{visibleEdges.map(renderVisibleEdge)}
|
||||
</AnimePresence>
|
||||
</flx-app-floating-unread-indicators-layer>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -3,8 +3,16 @@
|
||||
import styles from '@app/features/app/components/layout/GuildHeader.module.css';
|
||||
import {GuildHeaderShell} from '@app/features/app/components/layout/GuildHeaderShell';
|
||||
import {NativeDragRegion} from '@app/features/app/components/layout/NativeDragRegion';
|
||||
import {
|
||||
reportSkeletonGuildPresentation,
|
||||
SkeletonGuildBannerPlacement,
|
||||
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {useAnimatedImageUrl} from '@app/features/app/hooks/useAnimatedImageUrl';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import {
|
||||
measureSkeletonTextWidthPx,
|
||||
useSkeletonLayoutReport,
|
||||
} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import {clampWideAssetAspectRatio} from '@app/features/expressions/utils/AssetImageGeometry';
|
||||
import {GuildHeaderBottomSheet} from '@app/features/guild/components/bottomsheets/GuildHeaderBottomSheet';
|
||||
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
|
||||
@@ -34,6 +42,25 @@ const OPEN_COMMUNITY_MENU_FOR_DESCRIPTOR = msg({
|
||||
const HEADER_MIN_HEIGHT = 56;
|
||||
const DEFAULT_BANNER_ASPECT_RATIO = 16 / 9;
|
||||
const MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
|
||||
const GUILD_BADGE_FEATURES: ReadonlyArray<string> = [
|
||||
GuildFeatures.VERIFIED,
|
||||
GuildFeatures.PARTNERED,
|
||||
GuildFeatures.DISCOVERABLE,
|
||||
];
|
||||
|
||||
function resolveGuildBadgeVisible(features: ReadonlySet<string>): boolean {
|
||||
return GUILD_BADGE_FEATURES.some((feature) => features.has(feature));
|
||||
}
|
||||
|
||||
function resolveSkeletonBannerPlacement(hasBanner: boolean, isDetachedBanner: boolean): SkeletonGuildBannerPlacement {
|
||||
if (!hasBanner) {
|
||||
return SkeletonGuildBannerPlacement.NONE;
|
||||
}
|
||||
if (isDetachedBanner) {
|
||||
return SkeletonGuildBannerPlacement.DETACHED;
|
||||
}
|
||||
return SkeletonGuildBannerPlacement.INTEGRATED;
|
||||
}
|
||||
export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {popouts} = Popout;
|
||||
@@ -54,6 +81,10 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
});
|
||||
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
|
||||
const showIntegratedBanner = Boolean(bannerURL && !isDetachedBanner);
|
||||
const bannerAspectRatio =
|
||||
guild.bannerWidth && guild.bannerHeight
|
||||
? (clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? DEFAULT_BANNER_ASPECT_RATIO)
|
||||
: DEFAULT_BANNER_ASPECT_RATIO;
|
||||
const headerContainerRef = useRef<HTMLElement | null>(null);
|
||||
const mergedHeaderContainerRef = useMergeRefs([headerContainerRef, bannerHoverRef]);
|
||||
const [containerWidth, setContainerWidth] = useState<number>(() =>
|
||||
@@ -87,11 +118,7 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
if (!showIntegratedBanner || !bannerURL || !containerWidth) {
|
||||
return {bannerMaxHeight: HEADER_MIN_HEIGHT, centerCrop: false};
|
||||
}
|
||||
const aspectRatio =
|
||||
guild.bannerWidth && guild.bannerHeight
|
||||
? (clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? DEFAULT_BANNER_ASPECT_RATIO)
|
||||
: DEFAULT_BANNER_ASPECT_RATIO;
|
||||
const idealHeight = containerWidth / aspectRatio;
|
||||
const idealHeight = containerWidth / bannerAspectRatio;
|
||||
const viewportCap = viewportHeight * MAX_VIEWPORT_HEIGHT_FRACTION;
|
||||
const isCapped = idealHeight > viewportCap;
|
||||
return {
|
||||
@@ -112,6 +139,17 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
[guild],
|
||||
);
|
||||
const headerButtonRef = useRef<HTMLDivElement | null>(null);
|
||||
const guildNameRef = useRef<HTMLSpanElement | null>(null);
|
||||
const badgeVisible = resolveGuildBadgeVisible(guild.features);
|
||||
const bannerPlacement = resolveSkeletonBannerPlacement(Boolean(bannerURL), isDetachedBanner);
|
||||
useSkeletonLayoutReport(() => {
|
||||
reportSkeletonGuildPresentation(guild.id, {
|
||||
headerNameWidthPx: measureSkeletonTextWidthPx(guildNameRef.current),
|
||||
badgeVisible,
|
||||
bannerPlacement,
|
||||
bannerAspectRatio,
|
||||
});
|
||||
}, `${guild.id}:${guild.name}:${badgeVisible}:${bannerPlacement}:${bannerAspectRatio}:${isMobile}`);
|
||||
return (
|
||||
<div className={styles.headerWrapper} data-flx="app.guild-header.header-wrapper">
|
||||
<NativeDragRegion
|
||||
@@ -162,6 +200,7 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
data-flx="app.guild-header.guild-badge"
|
||||
/>
|
||||
<span
|
||||
ref={guildNameRef}
|
||||
className={showIntegratedBanner ? styles.guildNameWithBanner : styles.guildNameDefault}
|
||||
data-flx="app.guild-header.guild-name"
|
||||
>
|
||||
|
||||
@@ -5,9 +5,9 @@ import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {TopNagbarContext} from '@app/features/app/components/layout/app_layout/TopNagbarContext';
|
||||
import styles from '@app/features/app/components/layout/GuildLayout.module.css';
|
||||
import {GuildNavbar} from '@app/features/app/components/layout/GuildNavbar';
|
||||
import {GuildNavbarSkeleton} from '@app/features/app/components/layout/GuildNavbarSkeleton';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {GuildSidebarSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {MatureContentChannelGate} from '@app/features/channel/components/MatureContentChannelGate';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
@@ -497,7 +497,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
return (
|
||||
<TopNagbarContext.Provider value={nagbarContextValue}>
|
||||
<div className={styles.guildLayoutContent} data-flx="app.guild-layout.guild-layout-content">
|
||||
<GuildNavbarSkeleton data-flx="app.guild-layout.guild-navbar-skeleton" />
|
||||
<GuildSidebarSkeleton guildId={guildId} data-flx="app.guild-layout.guild-sidebar-skeleton" />
|
||||
<div className={styles.guildMainContent} data-flx="app.guild-layout.guild-main-content">
|
||||
{guildUnavailable ? (
|
||||
<GuildUnavailable
|
||||
@@ -572,7 +572,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
>
|
||||
{guildNagbars}
|
||||
<div className={styles.guildLayoutContent} data-flx="app.guild-layout.guild-layout-content--2">
|
||||
<GuildNavbarSkeleton data-flx="app.guild-layout.guild-navbar-skeleton--2" />
|
||||
<GuildSidebarSkeleton guildId={guildId} data-flx="app.guild-layout.guild-sidebar-skeleton--2" />
|
||||
<div className={styles.guildMainContent} data-flx="app.guild-layout.guild-main-content--4">
|
||||
<GuildUnavailable
|
||||
icon={NetworkSlashIcon}
|
||||
@@ -595,7 +595,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
>
|
||||
{guildNagbars}
|
||||
<div className={styles.guildLayoutContent} data-flx="app.guild-layout.guild-layout-content--3">
|
||||
<GuildNavbarSkeleton data-flx="app.guild-layout.guild-navbar-skeleton--3" />
|
||||
<GuildSidebarSkeleton guildId={guildId} data-flx="app.guild-layout.guild-sidebar-skeleton--3" />
|
||||
<div className={styles.guildMainContent} data-flx="app.guild-layout.guild-main-content--5">
|
||||
<GuildUnavailable
|
||||
icon={SmileySadIcon}
|
||||
@@ -641,7 +641,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
>
|
||||
{guildNagbars}
|
||||
<div className={styles.guildLayoutContent} data-flx="app.guild-layout.guild-layout-content--5">
|
||||
<GuildNavbarSkeleton data-flx="app.guild-layout.guild-navbar-skeleton--4" />
|
||||
<GuildSidebarSkeleton guildId={guildId} data-flx="app.guild-layout.guild-sidebar-skeleton--4" />
|
||||
<div className={styles.guildMainContent} data-flx="app.guild-layout.guild-main-content--7">
|
||||
<MatureContentChannelGate
|
||||
guildId={guild.id}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.skeletonContainer {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: calc(100% - var(--layout-user-area-reserved-height, 0px));
|
||||
min-height: 0;
|
||||
width: var(--layout-sidebar-width);
|
||||
min-width: 0;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
overflow: hidden;
|
||||
background-color: var(--background-secondary);
|
||||
padding-bottom: var(--spacing-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skeletonContainerMobile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(html.platform-native) .skeletonContainer {
|
||||
border-top-left-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
background-clip: padding-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeletonHeader {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
overflow: hidden;
|
||||
height: var(--layout-header-height);
|
||||
min-height: var(--layout-header-height);
|
||||
border-bottom: 0.0625rem solid var(--user-area-divider-color);
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.skeletonHeaderPill {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--layout-header-height);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 var(--spacing-4);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.skeletonHeaderPill::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 80%;
|
||||
height: 1.25rem;
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-radius: var(--radius-md);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skeletonContent {
|
||||
padding: var(--spacing-3);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.skeletonCategory {
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
margin-top: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.skeletonCategoryPill {
|
||||
width: 60%;
|
||||
height: 1rem;
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-radius: var(--radius-md);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.skeletonChannel {
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
margin-bottom: var(--spacing-1-5);
|
||||
}
|
||||
|
||||
.skeletonChannelPill {
|
||||
width: 75%;
|
||||
height: 1.25rem;
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-radius: var(--radius-md);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(2) .skeletonChannelPill {
|
||||
width: 62%;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(3) .skeletonChannelPill {
|
||||
width: 84%;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(4) .skeletonChannelPill {
|
||||
width: 58%;
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(6) .skeletonChannelPill {
|
||||
width: 78%;
|
||||
opacity: 0.31;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(7) .skeletonChannelPill {
|
||||
width: 55%;
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(10) .skeletonChannelPill {
|
||||
width: 68%;
|
||||
opacity: 0.33;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(11) .skeletonChannelPill {
|
||||
width: 82%;
|
||||
opacity: 0.29;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(12) .skeletonChannelPill {
|
||||
width: 71%;
|
||||
opacity: 0.37;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(13) .skeletonChannelPill {
|
||||
width: 64%;
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.skeletonCategory:nth-child(1) .skeletonCategoryPill {
|
||||
width: 48%;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.skeletonCategory:nth-child(5) .skeletonCategoryPill {
|
||||
width: 68%;
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.skeletonCategory:nth-child(9) .skeletonCategoryPill {
|
||||
width: 53%;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.skeletonHeaderPill {
|
||||
width: 78%;
|
||||
opacity: 0.36;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/GuildNavbarSkeleton.module.css';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
export const GuildNavbarSkeleton = observer(() => {
|
||||
const mobileLayout = MobileLayout;
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.skeletonContainer, mobileLayout.enabled && styles.skeletonContainerMobile)}
|
||||
data-flx="app.guild-navbar-skeleton.skeleton-container"
|
||||
>
|
||||
<div className={styles.skeletonHeader} data-flx="app.guild-navbar-skeleton.skeleton-header">
|
||||
<div className={styles.skeletonHeaderPill} data-flx="app.guild-navbar-skeleton.skeleton-header-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonContent} data-flx="app.guild-navbar-skeleton.skeleton-content">
|
||||
<div className={styles.skeletonCategory} data-flx="app.guild-navbar-skeleton.skeleton-category">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.guild-navbar-skeleton.skeleton-category-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--2">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--2" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--3">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--3" />
|
||||
</div>
|
||||
<div className={styles.skeletonCategory} data-flx="app.guild-navbar-skeleton.skeleton-category--2">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.guild-navbar-skeleton.skeleton-category-pill--2" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--4">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--4" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--5">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--5" />
|
||||
</div>
|
||||
<div className={styles.skeletonCategory} data-flx="app.guild-navbar-skeleton.skeleton-category--3">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.guild-navbar-skeleton.skeleton-category-pill--3" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--6">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--6" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--7">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--7" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--8">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--8" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--9">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -22,7 +22,7 @@
|
||||
--layout-user-area-overlay-height: calc(
|
||||
var(--layout-user-area-height) +
|
||||
var(--user-area-box-inset-block-end) +
|
||||
var(--layout-voice-connection-height, 0px)
|
||||
var(--layout-voice-connection-height)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background-color: var(--background-secondary);
|
||||
overflow-anchor: none;
|
||||
--guild-badge-surface: var(--background-secondary);
|
||||
padding-top: var(--spacing-1);
|
||||
padding-bottom: var(--spacing-2);
|
||||
scroll-padding-bottom: calc(
|
||||
@@ -80,15 +78,15 @@
|
||||
);
|
||||
}
|
||||
|
||||
.guildListScrollContainer.guildListScrollContainer {
|
||||
overflow-anchor: auto;
|
||||
}
|
||||
|
||||
.guildListContent {
|
||||
--guild-list-item-size: var(--guild-icon-size);
|
||||
--guild-list-item-box-size: 3rem;
|
||||
--guild-list-item-gap: 0.375rem;
|
||||
--guild-list-item-inset: calc((var(--guild-list-item-box-size) - var(--guild-list-item-size)) / 2);
|
||||
--guild-folder-expanded-surface-size: var(--guild-list-item-box-size);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-bottom: calc(
|
||||
@@ -97,34 +95,9 @@
|
||||
);
|
||||
}
|
||||
|
||||
.guildListTopSection,
|
||||
.guildListGuildsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.guildListTopSection {
|
||||
gap: var(--guild-list-item-gap);
|
||||
}
|
||||
|
||||
.guildListGuildsSection {
|
||||
position: relative;
|
||||
gap: var(--guild-list-item-gap);
|
||||
}
|
||||
|
||||
.guildListItems {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.guildListItemSlot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -208,7 +181,7 @@
|
||||
}
|
||||
|
||||
.guildListReorderTarget {
|
||||
height: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
|
||||
height: var(--guild-list-item-target-size);
|
||||
}
|
||||
|
||||
.guildIcon {
|
||||
@@ -256,7 +229,7 @@
|
||||
transition-property: background-color, color, border-radius;
|
||||
transition-duration: 70ms;
|
||||
transition-timing-function: ease-out;
|
||||
background-color: var(--guilds-layout-item-bg, var(--guild-list-foreground));
|
||||
background-color: var(--guilds-layout-item-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -306,12 +279,12 @@
|
||||
|
||||
.guildIndicator {
|
||||
position: absolute;
|
||||
left: -0.15rem;
|
||||
left: var(--guild-list-indicator-inset);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--guild-icon-size);
|
||||
width: 0.5rem;
|
||||
width: var(--guild-list-indicator-track-width);
|
||||
pointer-events: none;
|
||||
container-type: layout size;
|
||||
z-index: 2;
|
||||
@@ -319,7 +292,7 @@
|
||||
|
||||
.guildIndicatorBar {
|
||||
display: block;
|
||||
width: 0.35rem;
|
||||
width: var(--guild-list-indicator-bar-width);
|
||||
border-radius: 0 var(--radius-full) var(--radius-full) 0;
|
||||
background-color: var(--text-primary);
|
||||
}
|
||||
@@ -330,20 +303,17 @@
|
||||
bottom: -0.25rem;
|
||||
pointer-events: none;
|
||||
border-radius: var(--radius-md);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.guildBadgeActive {
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary));
|
||||
}
|
||||
|
||||
.dmListSection {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
.guildBadgeActive::before,
|
||||
.guildBadge:not(:empty)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.1875rem;
|
||||
z-index: -1;
|
||||
border-radius: calc(var(--radius-md) + 0.1875rem);
|
||||
background-color: var(--guild-badge-surface);
|
||||
}
|
||||
|
||||
.guildVoiceBadge {
|
||||
@@ -363,7 +333,7 @@
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--status-online);
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary));
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface);
|
||||
pointer-events: none;
|
||||
color: white;
|
||||
}
|
||||
@@ -385,7 +355,7 @@
|
||||
border-radius: var(--radius-full);
|
||||
background-color: white;
|
||||
color: var(--status-danger);
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary));
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface);
|
||||
}
|
||||
|
||||
.dmListItem {
|
||||
@@ -431,7 +401,7 @@
|
||||
|
||||
.fluxerButtonIcon {
|
||||
composes: guildIcon;
|
||||
background-color: var(--guilds-layout-item-bg, var(--guild-list-foreground));
|
||||
background-color: var(--guilds-layout-item-bg);
|
||||
color: var(--text-primary);
|
||||
transition-property: background-color, color, border-radius;
|
||||
transition-duration: 70ms;
|
||||
@@ -487,12 +457,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.guildDividerSlot {
|
||||
--guild-list-divider-previous-extra: 0px;
|
||||
--guild-list-divider-next-extra: 0px;
|
||||
display: grid;
|
||||
grid-template-rows:
|
||||
calc(var(--guild-list-divider-base-gap) + var(--guild-list-divider-next-extra))
|
||||
var(--guild-list-divider-line-size)
|
||||
calc(var(--guild-list-divider-base-gap) + var(--guild-list-divider-previous-extra));
|
||||
justify-items: center;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guildDividerSlot[data-next-row-extended='true'] {
|
||||
--guild-list-divider-next-extra: var(--guild-list-item-target-half-extra);
|
||||
}
|
||||
|
||||
.guildDivider {
|
||||
height: 0.125rem;
|
||||
grid-row: 2;
|
||||
height: var(--guild-list-divider-line-size);
|
||||
width: 2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--guild-list-item-gap);
|
||||
margin-bottom: var(--guild-list-item-gap);
|
||||
border-radius: 0.0625rem;
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
@@ -514,9 +500,9 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fluxerSymbolIcon {
|
||||
height: 2.75rem;
|
||||
width: 2.75rem;
|
||||
.messageBubbleIcon {
|
||||
height: 1.75rem;
|
||||
width: 1.75rem;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
@@ -589,9 +575,8 @@
|
||||
|
||||
.guildTooltipName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
font-weight: 600;
|
||||
@@ -632,7 +617,7 @@
|
||||
|
||||
:global(html.platform-native) .outlineFrame {
|
||||
border-top: 0.0625rem solid var(--user-area-divider-color);
|
||||
--outline-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
--outline-radius: var(--outline-frame-radius-native);
|
||||
}
|
||||
|
||||
:global(html.platform-native) .outlineFrame.outlineFrameFullscreen {
|
||||
@@ -670,7 +655,6 @@
|
||||
}
|
||||
|
||||
.guildTooltipStat {
|
||||
--guild-tooltip-count-skeleton-width: 2.125rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
@@ -678,14 +662,6 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.guildTooltipStatOnline {
|
||||
--guild-tooltip-count-skeleton-width: 2.125rem;
|
||||
}
|
||||
|
||||
.guildTooltipStatMembers {
|
||||
--guild-tooltip-count-skeleton-width: 2.5rem;
|
||||
}
|
||||
|
||||
.guildTooltipStatDot {
|
||||
margin-right: 0.3rem;
|
||||
height: 0.5rem;
|
||||
@@ -714,31 +690,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.guildTooltipStatSkeleton {
|
||||
height: 0.6875rem;
|
||||
width: var(--guild-tooltip-count-skeleton-width);
|
||||
min-width: var(--guild-tooltip-count-skeleton-width);
|
||||
border-radius: 62.4375rem;
|
||||
background: color-mix(in srgb, var(--text-primary-muted) 22%, transparent);
|
||||
animation: guild-tooltip-stat-skeleton-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:global(html.reduced-motion) .guildTooltipStatSkeleton {
|
||||
animation: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
@keyframes guild-tooltip-stat-skeleton-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.44;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
.guildVoiceInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -775,7 +726,7 @@
|
||||
right: calc(-0.25rem - 0.1875rem);
|
||||
bottom: calc(-0.25rem - 0.1875rem);
|
||||
pointer-events: none;
|
||||
background-color: var(--guild-badge-surface, var(--background-secondary));
|
||||
background-color: var(--guild-badge-surface);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1875rem;
|
||||
}
|
||||
@@ -797,10 +748,6 @@
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
:global(.theme-light) .guildsLayoutContainer {
|
||||
--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);
|
||||
}
|
||||
|
||||
.roundedFull {
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import styles from '@app/features/app/components/layout/MobileBottomNav.module.css';
|
||||
import {reportSkeletonVoiceConnected} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import {PRIMARY_NAVIGATION_LANDMARK_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
@@ -49,6 +51,9 @@ export const MobileBottomNav = observer(({currentUser}: MobileBottomNavProps) =>
|
||||
const isDirectCallChannel = Boolean(
|
||||
voiceChannel && (voiceChannel.type === ChannelTypes.DM || voiceChannel.type === ChannelTypes.GROUP_DM),
|
||||
);
|
||||
useSkeletonLayoutReport(() => {
|
||||
reportSkeletonVoiceConnected(isConnectedToVoice);
|
||||
}, `${isConnectedToVoice}`);
|
||||
useEffect(() => {
|
||||
if (Routes.isChannelRoute(location.pathname)) {
|
||||
const channel = Navigation.channelId ? Channels.getChannel(Navigation.channelId) : undefined;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/NagbarButton.module.css';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Button, type ButtonVariant} from '@app/features/ui/button/Button';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
|
||||
@@ -12,6 +12,7 @@ interface NagbarButtonProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
submitting?: boolean;
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
export const NagbarButton = ({
|
||||
@@ -21,10 +22,11 @@ export const NagbarButton = ({
|
||||
className,
|
||||
disabled = false,
|
||||
submitting,
|
||||
variant = 'inverted',
|
||||
}: NagbarButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="inverted"
|
||||
variant={variant}
|
||||
superCompact={!isMobile}
|
||||
compact={isMobile}
|
||||
fitContent
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface NagbarTone {
|
||||
readonly backgroundColor: string;
|
||||
readonly textColor: string;
|
||||
}
|
||||
|
||||
export const NagbarToneKind = Object.freeze({
|
||||
NEUTRAL: 'neutral',
|
||||
MAINTENANCE: 'maintenance',
|
||||
MAINTENANCE_SCHEDULED: 'maintenance_scheduled',
|
||||
MAINTENANCE_ACTIVE: 'maintenance_active',
|
||||
MAINTENANCE_COMPLETED: 'maintenance_completed',
|
||||
BRAND: 'brand',
|
||||
DANGER: 'danger',
|
||||
ALERT: 'alert',
|
||||
PREMIUM: 'premium',
|
||||
LEGAL: 'legal',
|
||||
VOICE: 'voice',
|
||||
CRITICAL: 'critical',
|
||||
DEVELOPMENT: 'development',
|
||||
STREAMER: 'streamer',
|
||||
ENCODER: 'encoder',
|
||||
} as const);
|
||||
|
||||
export type NagbarToneKind = (typeof NagbarToneKind)[keyof typeof NagbarToneKind];
|
||||
|
||||
export const NAGBAR_TONES: Readonly<Record<NagbarToneKind, NagbarTone>> = Object.freeze({
|
||||
[NagbarToneKind.NEUTRAL]: Object.freeze({backgroundColor: '#4e5058', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.MAINTENANCE]: Object.freeze({backgroundColor: '#4285f4', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.MAINTENANCE_SCHEDULED]: Object.freeze({backgroundColor: '#1d4ed8', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.MAINTENANCE_ACTIVE]: Object.freeze({backgroundColor: '#9a3412', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.MAINTENANCE_COMPLETED]: Object.freeze({backgroundColor: '#166534', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.BRAND]: Object.freeze({
|
||||
backgroundColor: 'var(--brand-primary)',
|
||||
textColor: 'var(--text-on-brand-primary)',
|
||||
}),
|
||||
[NagbarToneKind.DANGER]: Object.freeze({
|
||||
backgroundColor: 'var(--status-danger)',
|
||||
textColor: 'var(--text-on-brand-primary)',
|
||||
}),
|
||||
[NagbarToneKind.ALERT]: Object.freeze({backgroundColor: '#ea580c', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.PREMIUM]: Object.freeze({backgroundColor: '#f97316', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.LEGAL]: Object.freeze({backgroundColor: '#4338ca', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.VOICE]: Object.freeze({backgroundColor: '#15803d', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.CRITICAL]: Object.freeze({backgroundColor: '#b00000', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.DEVELOPMENT]: Object.freeze({backgroundColor: '#17231d', textColor: '#72e6a2'}),
|
||||
[NagbarToneKind.STREAMER]: Object.freeze({backgroundColor: '#5865f2', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.ENCODER]: Object.freeze({backgroundColor: '#b45309', textColor: '#ffffff'}),
|
||||
});
|
||||
|
||||
export const NAGBAR_TONE_NEUTRAL: NagbarTone = NAGBAR_TONES[NagbarToneKind.NEUTRAL];
|
||||
export const NAGBAR_TONE_MAINTENANCE: NagbarTone = NAGBAR_TONES[NagbarToneKind.MAINTENANCE];
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/NullSpaceDropIndicator.module.css';
|
||||
import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
@@ -18,7 +18,7 @@ export const NullSpaceDropIndicator = observer(
|
||||
({isDraggingAnything, onChannelDrop, variant = 'top'}: NullSpaceDropIndicatorProps) => {
|
||||
const [{isOver, canDrop}, dropRef] = useDrop(
|
||||
() => ({
|
||||
accept: [DND_TYPES.CHANNEL, DND_TYPES.CATEGORY],
|
||||
accept: [DragItemType.CHANNEL, DragItemType.CATEGORY],
|
||||
drop: (item: DragItem): DropResult => {
|
||||
const result: DropResult =
|
||||
variant === 'top'
|
||||
|
||||
@@ -61,7 +61,7 @@ export const OutlineFrame: React.FC<OutlineFrameProps> = ({
|
||||
{nagbar}
|
||||
<div className={styles.contentWrapper} data-flx="app.outline-frame.content-wrapper">
|
||||
{sidebarDivider && <div className={styles.divider} aria-hidden data-flx="app.outline-frame.divider" />}
|
||||
{sidebarResizeHandle && <SidebarResizeHandle />}
|
||||
{sidebarResizeHandle && <SidebarResizeHandle data-flx="app.outline-frame.sidebar-resize-handle" />}
|
||||
<div className={styles.body} data-flx="app.outline-frame.body">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import SidebarWidth, {
|
||||
SIDEBAR_WIDTH_MAX,
|
||||
SIDEBAR_WIDTH_MIN,
|
||||
} from '@app/features/ui/state/SidebarWidth';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -33,7 +33,7 @@ interface DragState {
|
||||
}
|
||||
|
||||
function resolveZoomFactor(ownerDocument: Document): number {
|
||||
const zoomFactor = getAppZoomFactor(ownerDocument);
|
||||
const zoomFactor = getAppRemScale(ownerDocument);
|
||||
if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return 1;
|
||||
return zoomFactor;
|
||||
}
|
||||
@@ -229,7 +229,7 @@ export const SidebarResizeHandle = observer(() => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FocusRing offset={-2}>
|
||||
<FocusRing offset={-2} data-flx="app.sidebar-resize-handle.focus-ring">
|
||||
<div
|
||||
ref={handleRef}
|
||||
role="separator"
|
||||
@@ -249,7 +249,7 @@ export const SidebarResizeHandle = observer(() => {
|
||||
onKeyDown={onKeyDown}
|
||||
data-flx="app.sidebar-resize-handle"
|
||||
>
|
||||
<span className={styles.indicator} aria-hidden />
|
||||
<span className={styles.indicator} aria-hidden data-flx="app.sidebar-resize-handle.indicator" />
|
||||
</div>
|
||||
</FocusRing>
|
||||
);
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.splashOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-index-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--background-secondary);
|
||||
padding-left: var(--spacing-6);
|
||||
padding-right: var(--spacing-6);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
.splashOverlay * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) .splashOverlay {
|
||||
top: var(--native-titlebar-height);
|
||||
}
|
||||
|
||||
.quote,
|
||||
.quoteSource,
|
||||
.quoteLink,
|
||||
.incidentCta,
|
||||
.connectionIssuesOverlay,
|
||||
.connectionIssuesOverlay * {
|
||||
user-select: text !important;
|
||||
-webkit-user-select: text !important;
|
||||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
}
|
||||
|
||||
.topDragRegion {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--layout-header-height);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos) .topDragRegion {
|
||||
left: var(--traffic-lights-backdrop-width, var(--layout-guild-list-width, 4.5rem));
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.splashOverlay {
|
||||
padding-left: var(--spacing-8);
|
||||
padding-right: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.splashOverlay {
|
||||
padding-left: var(--spacing-12);
|
||||
padding-right: var(--spacing-12);
|
||||
}
|
||||
}
|
||||
|
||||
.splashContent {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
z-index: var(--z-index-elevated-1);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.splashContent {
|
||||
max-width: 32rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.splashContent {
|
||||
max-width: 36rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.splashContent {
|
||||
max-width: 42rem;
|
||||
}
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.iconWrapper {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.iconWrapper {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.iconPulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-primary);
|
||||
opacity: 0.75;
|
||||
animation: splashPulse 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.iconPulseStatic {
|
||||
animation: none;
|
||||
opacity: 0.35;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
z-index: var(--z-index-elevated-1);
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.icon {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.icon {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes splashPulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.75;
|
||||
}
|
||||
75% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.quote {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.quote {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quoteSource {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
margin: var(--spacing-1) 0 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.quoteSource {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quoteContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 4rem;
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.quoteLink {
|
||||
text-align: center;
|
||||
color: var(--text-link);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quoteLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.quoteLink {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.incidentCta {
|
||||
margin-top: var(--spacing-3);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.incidentCta:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.connectionIssuesOverlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: var(--spacing-6);
|
||||
transform: translateX(-50%);
|
||||
width: min(100%, 32rem);
|
||||
padding: 0 var(--spacing-4);
|
||||
z-index: var(--z-index-elevated-2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.connectionIssuesOverlay {
|
||||
bottom: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {ConnectionIssuesLinks} from '@app/features/app/components/ConnectionIssuesLinks';
|
||||
import {NativeDragRegion} from '@app/features/app/components/layout/NativeDragRegion';
|
||||
import styles from '@app/features/app/components/layout/SplashScreen.module.css';
|
||||
import {useSplashScreenGuard} from '@app/features/app/hooks/useSplashScreenGuard';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
||||
import {getReducedMotionProps} from '@app/features/ui/utils/ReducedMotionAnimation';
|
||||
import StatusPage from '@app/features/user/state/StatusPage';
|
||||
import {type SplashQuote, useSplashQuotes} from '@app/media/data/SplashQuotes';
|
||||
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const PROBLEMS_DELAY = 10000;
|
||||
const STATUS_PAGE_DISPLAY_DELAY = 5000;
|
||||
const FORCE_DISMISS_TIMEOUT_MS = 3000;
|
||||
|
||||
type SplashScreenMode = 'live' | 'outage';
|
||||
|
||||
export const SplashScreen = observer(() => {
|
||||
const shouldBypass = DeveloperOptions.bypassSplashScreen;
|
||||
const interrupted = GatewayConnection.isConnectionInterrupted;
|
||||
const isInitialized = Initialization.canNavigateToProtectedRoutes;
|
||||
const isReady = !interrupted && isInitialized;
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady) {
|
||||
setHasTimedOut(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setHasTimedOut(true), FORCE_DISMISS_TIMEOUT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isReady]);
|
||||
|
||||
if (shouldBypass || hasTimedOut) return null;
|
||||
return (
|
||||
<AnimatePresence initial={false} data-flx="app.splash-screen.animate-presence">
|
||||
{!isReady && <SplashScreenContent mode="live" data-flx="app.splash-screen.splash-screen-content" />}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
export const OutageSplashScreen = observer(() => (
|
||||
<SplashScreenContent mode="outage" data-flx="app.splash-screen.outage-splash-screen.splash-screen-content" />
|
||||
));
|
||||
const SPLASH_MOTION = {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
transition: {duration: 0.35},
|
||||
};
|
||||
const CONNECTION_ISSUES_MOTION = {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
transition: {duration: 0.4},
|
||||
};
|
||||
const INCIDENT_CTA_MOTION = {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
transition: {duration: 0.2},
|
||||
};
|
||||
|
||||
interface SplashScreenContentProps {
|
||||
mode: SplashScreenMode;
|
||||
}
|
||||
|
||||
function pickRandomQuote(quotes: ReadonlyArray<SplashQuote>): SplashQuote {
|
||||
return quotes[Math.floor(Math.random() * quotes.length)];
|
||||
}
|
||||
|
||||
const SplashScreenContent = observer(({mode}: SplashScreenContentProps) => {
|
||||
const splashOverlayRef = useRef<HTMLDivElement | null>(null);
|
||||
useSplashScreenGuard(splashOverlayRef);
|
||||
const isOutageMode = mode === 'outage';
|
||||
const splashMotion = getReducedMotionProps(SPLASH_MOTION, Accessibility.useReducedMotion);
|
||||
const connectionIssuesMotion = getReducedMotionProps(CONNECTION_ISSUES_MOTION, Accessibility.useReducedMotion);
|
||||
const incidentCtaMotion = getReducedMotionProps(INCIDENT_CTA_MOTION, Accessibility.useReducedMotion);
|
||||
const interrupted = isOutageMode ? true : GatewayConnection.isConnectionInterrupted;
|
||||
const isInitialized = isOutageMode ? false : Initialization.canNavigateToProtectedRoutes;
|
||||
const isReady = !interrupted && isInitialized;
|
||||
const splashQuotes = useSplashQuotes();
|
||||
const incident = StatusPage.incident;
|
||||
const quoteRef = useRef<SplashQuote | null>(null);
|
||||
if (quoteRef.current == null) {
|
||||
quoteRef.current = pickRandomQuote(splashQuotes);
|
||||
}
|
||||
const quote = quoteRef.current;
|
||||
const [showStatusData, setShowStatusData] = useState(isOutageMode);
|
||||
const [showProblems, setShowProblems] = useState(isOutageMode);
|
||||
const problemsTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const clearProblemsTimer = useCallback(() => {
|
||||
if (problemsTimerRef.current != null) {
|
||||
clearTimeout(problemsTimerRef.current);
|
||||
problemsTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (isOutageMode) {
|
||||
setShowStatusData(true);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setShowStatusData(true), STATUS_PAGE_DISPLAY_DELAY);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOutageMode]);
|
||||
useEffect(() => {
|
||||
if (isOutageMode) {
|
||||
clearProblemsTimer();
|
||||
setShowProblems(true);
|
||||
void StatusPage.checkIncidents();
|
||||
return;
|
||||
}
|
||||
if (isReady) {
|
||||
clearProblemsTimer();
|
||||
setShowProblems(false);
|
||||
return;
|
||||
}
|
||||
problemsTimerRef.current = setTimeout(() => {
|
||||
setShowProblems(true);
|
||||
void StatusPage.checkIncidents();
|
||||
}, PROBLEMS_DELAY);
|
||||
return clearProblemsTimer;
|
||||
}, [clearProblemsTimer, isOutageMode, isReady]);
|
||||
const liveIncident = showStatusData ? incident : null;
|
||||
const frozenDisplayRef = useRef<{incident: typeof liveIncident; text: string} | null>(null);
|
||||
if (!isReady) {
|
||||
frozenDisplayRef.current = {
|
||||
incident: liveIncident,
|
||||
text: liveIncident ? liveIncident.name : quote.text,
|
||||
};
|
||||
}
|
||||
const visibleIncident = frozenDisplayRef.current?.incident ?? liveIncident;
|
||||
const displayText = frozenDisplayRef.current?.text ?? quote.text;
|
||||
const incidentUrl = visibleIncident?.url ?? ExternalUrls.SERVICE_STATUS;
|
||||
if (isOutageMode) {
|
||||
return (
|
||||
<div
|
||||
ref={splashOverlayRef}
|
||||
className={styles.splashOverlay}
|
||||
data-flx="app.splash-screen.splash-screen-content.splash-overlay"
|
||||
>
|
||||
<NativeDragRegion
|
||||
className={styles.topDragRegion}
|
||||
data-flx="app.splash-screen.splash-screen-content.top-drag-region"
|
||||
/>
|
||||
<div className={styles.splashContent} data-flx="app.splash-screen.splash-screen-content.splash-content">
|
||||
<div className={styles.iconWrapper} data-flx="app.splash-screen.splash-screen-content.icon-wrapper">
|
||||
<div
|
||||
className={`${styles.iconPulse} ${styles.iconPulseStatic}`}
|
||||
data-flx="app.splash-screen.splash-screen-content.icon-pulse"
|
||||
/>
|
||||
<FluxerIcon className={styles.icon} data-flx="app.splash-screen.splash-screen-content.icon" />
|
||||
</div>
|
||||
<div className={styles.quoteContainer} data-flx="app.splash-screen.splash-screen-content.quote-container">
|
||||
{visibleIncident != null ? (
|
||||
<a
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.quoteLink}
|
||||
data-flx="app.splash-screen.splash-screen-content.quote-link"
|
||||
>
|
||||
{displayText}
|
||||
</a>
|
||||
) : (
|
||||
<p className={styles.quote} data-flx="app.splash-screen.splash-screen-content.quote">
|
||||
<Trans>Connection lost</Trans>
|
||||
</p>
|
||||
)}
|
||||
{visibleIncident != null && (
|
||||
<a
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.incidentCta}
|
||||
data-flx="app.splash-screen.splash-screen-content.incident-cta"
|
||||
>
|
||||
<Trans>View on status page</Trans>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showProblems && !isReady && visibleIncident == null && (
|
||||
<div
|
||||
className={styles.connectionIssuesOverlay}
|
||||
data-flx="app.splash-screen.splash-screen-content.connection-issues-overlay"
|
||||
>
|
||||
<ConnectionIssuesLinks
|
||||
incident={incident}
|
||||
data-flx="app.splash-screen.splash-screen-content.connection-issues-links"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
ref={splashOverlayRef}
|
||||
data-flx="app.splash-screen.splash-screen-content.splash-overlay--2"
|
||||
{...splashMotion}
|
||||
className={styles.splashOverlay}
|
||||
style={{pointerEvents: isReady ? 'none' : 'auto'}}
|
||||
>
|
||||
<NativeDragRegion
|
||||
className={styles.topDragRegion}
|
||||
data-flx="app.splash-screen.splash-screen-content.top-drag-region--2"
|
||||
/>
|
||||
<div className={styles.splashContent} data-flx="app.splash-screen.splash-screen-content.splash-content--2">
|
||||
<div className={styles.iconWrapper} data-flx="app.splash-screen.splash-screen-content.icon-wrapper--2">
|
||||
<div className={styles.iconPulse} data-flx="app.splash-screen.splash-screen-content.icon-pulse--2" />
|
||||
<FluxerIcon className={styles.icon} data-flx="app.splash-screen.splash-screen-content.icon--2" />
|
||||
</div>
|
||||
<div className={styles.quoteContainer} data-flx="app.splash-screen.splash-screen-content.quote-container--2">
|
||||
{visibleIncident != null ? (
|
||||
<a
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.quoteLink}
|
||||
data-flx="app.splash-screen.splash-screen-content.quote-link--2"
|
||||
>
|
||||
{displayText}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.quote} data-flx="app.splash-screen.splash-screen-content.quote--2">
|
||||
{displayText}
|
||||
</p>
|
||||
<p className={styles.quoteSource} data-flx="app.splash-screen.splash-screen-content.quote-source">
|
||||
{quote.source}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<AnimatePresence data-flx="app.splash-screen.splash-screen-content.animate-presence">
|
||||
{visibleIncident != null && (
|
||||
<motion.a
|
||||
data-flx="app.splash-screen.splash-screen-content.incident-cta--2"
|
||||
{...incidentCtaMotion}
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.incidentCta}
|
||||
>
|
||||
<Trans>View on status page</Trans>
|
||||
</motion.a>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence data-flx="app.splash-screen.splash-screen-content.animate-presence--2">
|
||||
{showProblems && !isReady && visibleIncident == null && (
|
||||
<motion.div
|
||||
data-flx="app.splash-screen.splash-screen-content.connection-issues-overlay--2"
|
||||
{...connectionIssuesMotion}
|
||||
className={styles.connectionIssuesOverlay}
|
||||
>
|
||||
<ConnectionIssuesLinks
|
||||
incident={incident}
|
||||
data-flx="app.splash-screen.splash-screen-content.connection-issues-links--2"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type UserAreaMuteReason,
|
||||
} from '@app/features/app/components/layout/UserAreaState';
|
||||
import {CustomStatusDisplay} from '@app/features/app/components/shared/custom_status_display/CustomStatusDisplay';
|
||||
import {reportSkeletonVoicePresence} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import * as VoiceStateCommands from '@app/features/devtools/commands/VoiceStateCommands';
|
||||
@@ -155,11 +156,13 @@ const UserAreaInner = observer(
|
||||
};
|
||||
if (!hasVoiceConnection) {
|
||||
clearHeight();
|
||||
reportSkeletonVoicePresence(false, 0);
|
||||
return;
|
||||
}
|
||||
const element = voiceConnectionRef.current;
|
||||
if (!element || typeof ResizeObserver === 'undefined') {
|
||||
clearHeight();
|
||||
reportSkeletonVoicePresence(false, 0);
|
||||
return;
|
||||
}
|
||||
let pendingHeight: number | null = null;
|
||||
@@ -169,6 +172,7 @@ const UserAreaInner = observer(
|
||||
if (voiceConnectionHeightRef.current !== roundedHeight) {
|
||||
voiceConnectionHeightRef.current = roundedHeight;
|
||||
root.style.setProperty(VOICE_CONNECTION_HEIGHT_VARIABLE, `${roundedHeight}px`);
|
||||
reportSkeletonVoicePresence(true, roundedHeight);
|
||||
}
|
||||
} else {
|
||||
clearHeight();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {LongPressable} from '@app/features/app/components/LongPressable';
|
||||
import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css';
|
||||
import {DND_TYPES} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {DragItemType} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import styles from '@app/features/app/components/layout/VoiceParticipantItem.module.css';
|
||||
import {VoiceStateIcons} from '@app/features/app/components/layout/VoiceStateIcons';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
@@ -83,9 +83,9 @@ export const VoiceParticipantItem = observer(function VoiceParticipantItem({
|
||||
const isContextMenuOpen = useContextMenuHoverState(rowRef);
|
||||
const [{isDragging}, dragRef] = useDrag(
|
||||
() => ({
|
||||
type: DND_TYPES.VOICE_PARTICIPANT,
|
||||
type: DragItemType.VOICE_PARTICIPANT,
|
||||
item: {
|
||||
type: DND_TYPES.VOICE_PARTICIPANT,
|
||||
type: DragItemType.VOICE_PARTICIPANT,
|
||||
id: user.id,
|
||||
userId: user.id,
|
||||
guildId,
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
NagbarType,
|
||||
} from '@app/features/app/components/layout/app_layout/AppLayoutTypes';
|
||||
import {isScheduledMaintenanceNagbarDismissed} from '@app/features/app/components/layout/app_layout/ScheduledMaintenanceDismissal';
|
||||
import Config from '@app/features/app/config/Config';
|
||||
import {isClientReconnecting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
@@ -42,13 +44,17 @@ function sortNagbarsByPriority(a: NagbarState, b: NagbarState): number {
|
||||
|
||||
export function selectVisibleNagbars(nagbars: Array<NagbarState>): Array<NagbarState> {
|
||||
const visibleNagbars = nagbars.filter((nagbar) => nagbar.visible).sort(sortNagbarsByPriority);
|
||||
const nonDismissible = visibleNagbars.filter((nagbar) => !nagbar.dismissible);
|
||||
const dismissible = visibleNagbars.filter((nagbar) => nagbar.dismissible);
|
||||
const pinned = visibleNagbars.filter((nagbar) => nagbar.type === NagbarType.BUILD_ENVIRONMENT);
|
||||
const selectable = visibleNagbars.filter((nagbar) => nagbar.type !== NagbarType.BUILD_ENVIRONMENT);
|
||||
const nonDismissible = selectable.filter((nagbar) => !nagbar.dismissible);
|
||||
const dismissible = selectable.filter((nagbar) => nagbar.dismissible);
|
||||
const selectedNonDismissible = nonDismissible.slice(0, 1);
|
||||
const selectedDismissible = dismissible.slice(0, 1);
|
||||
return [...selectedNonDismissible, ...selectedDismissible].sort(sortNagbarsByPriority);
|
||||
return [...pinned, ...selectedNonDismissible, ...selectedDismissible].sort(sortNagbarsByPriority);
|
||||
}
|
||||
|
||||
const BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS = new Set<string>(['stable', 'canary']);
|
||||
|
||||
export const useAppLayoutState = (): AppLayoutState => {
|
||||
const [isStandalone, setIsStandalone] = useState(isStandalonePwa());
|
||||
useEffect(() => {
|
||||
@@ -220,6 +226,14 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
const canShowLinuxInputAccess = NativePermission.shouldShowLinuxInputAccessNagbar;
|
||||
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
||||
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
||||
const canShowBuildEnvironment =
|
||||
!BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS.has(Config.PUBLIC_RELEASE_CHANNEL) &&
|
||||
!nagbarState.buildEnvironmentDismissedThisSession;
|
||||
const canShowConnection = nagbarState.forceHideConnectionNotice
|
||||
? false
|
||||
: nagbarState.forceConnectionNotice
|
||||
? true
|
||||
: isClientReconnecting();
|
||||
const needsTermsAcceptance = (() => {
|
||||
if (!user) return false;
|
||||
if (isSelfHosted) return false;
|
||||
@@ -235,6 +249,8 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
return termsOutdated || privacyOutdated;
|
||||
})();
|
||||
return {
|
||||
canShowBuildEnvironment,
|
||||
canShowConnection,
|
||||
canShowCorruptedInstallation: nagbarState.forceHideCorruptedInstallation
|
||||
? false
|
||||
: nagbarState.forceCorruptedInstallation
|
||||
@@ -273,6 +289,18 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarState> => {
|
||||
return useMemo(() => {
|
||||
const nagbars: Array<NagbarState> = [
|
||||
{
|
||||
type: NagbarType.BUILD_ENVIRONMENT,
|
||||
priority: -1000,
|
||||
visible: conditions.canShowBuildEnvironment,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.CONNECTION,
|
||||
priority: -100,
|
||||
visible: conditions.canShowConnection,
|
||||
dismissible: false,
|
||||
},
|
||||
{
|
||||
type: NagbarType.CORRUPTED_INSTALLATION,
|
||||
priority: -10,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
|
||||
export const NagbarType = {
|
||||
BUILD_ENVIRONMENT: 'build-environment',
|
||||
CONNECTION: 'connection',
|
||||
CORRUPTED_INSTALLATION: 'corrupted-installation',
|
||||
SCHEDULED_MAINTENANCE: 'scheduled-maintenance',
|
||||
UNCLAIMED_ACCOUNT: 'unclaimed-account',
|
||||
@@ -36,6 +38,8 @@ export interface AppLayoutState {
|
||||
}
|
||||
|
||||
export interface NagbarConditions {
|
||||
canShowBuildEnvironment: boolean;
|
||||
canShowConnection: boolean;
|
||||
canShowCorruptedInstallation: boolean;
|
||||
canShowScheduledMaintenance: boolean;
|
||||
userIsUnclaimed: boolean;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import {type NagbarState, NagbarType} from '@app/features/app/components/layout/app_layout/AppLayoutTypes';
|
||||
import styles from '@app/features/app/components/layout/app_layout/NagbarContainer.module.css';
|
||||
import {BuildEnvironmentNagbar} from '@app/features/app/components/layout/app_layout/nagbars/BuildEnvironmentNagbar';
|
||||
import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ConnectionNagbar';
|
||||
import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
|
||||
import {DesktopDownloadNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopDownloadNagbar';
|
||||
import {DesktopNotificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopNotificationNagbar';
|
||||
@@ -28,6 +30,13 @@ interface NagbarContainerProps {
|
||||
nagbars: Array<NagbarState>;
|
||||
}
|
||||
|
||||
class UnexpectedNagbarTypeError extends Error {
|
||||
constructor(nagbarType: never) {
|
||||
super(`Unexpected nagbar type: ${String(nagbarType)}`);
|
||||
this.name = 'UnexpectedNagbarTypeError';
|
||||
}
|
||||
}
|
||||
|
||||
export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbars}) => {
|
||||
const mobileLayout = MobileLayout;
|
||||
const showPremiumFeatures = shouldShowPremiumFeatures();
|
||||
@@ -36,6 +45,22 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
<div className={styles.container} data-flx="app.app-layout.nagbar-container.container">
|
||||
{nagbars.map((nagbar) => {
|
||||
switch (nagbar.type) {
|
||||
case NagbarType.BUILD_ENVIRONMENT:
|
||||
return (
|
||||
<BuildEnvironmentNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.build-environment-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.CONNECTION:
|
||||
return (
|
||||
<ConnectionNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.connection-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.CORRUPTED_INSTALLATION:
|
||||
return (
|
||||
<CorruptedInstallationNagbar
|
||||
@@ -177,7 +202,7 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
throw new UnexpectedNagbarTypeError(nagbar.type);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import Config from '@app/features/app/config/Config';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import NagbarController from '@app/features/ui/state/Nagbar';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
|
||||
const BUILD_ENVIRONMENT_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'You are using a {releaseChannel} build of {productName}.',
|
||||
comment:
|
||||
'Session-dismissible nagbar identifying a non-stable official desktop app build. {releaseChannel} is development, staging, or canary. {productName} is the fixed official desktop product name, not the active instance name.',
|
||||
});
|
||||
|
||||
export function BuildEnvironmentNagbar({isMobile}: {isMobile: boolean}) {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.DEVELOPMENT].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.DEVELOPMENT].textColor}
|
||||
dismissible
|
||||
onDismiss={NagbarController.dismissBuildEnvironmentNagbar}
|
||||
data-flx="app.app-layout.nagbars.build-environment-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={NagbarController.dismissBuildEnvironmentNagbar}
|
||||
message={i18n._(BUILD_ENVIRONMENT_MESSAGE_DESCRIPTOR, {
|
||||
releaseChannel: Config.PUBLIC_RELEASE_CHANNEL,
|
||||
productName: PRODUCT_NAME,
|
||||
})}
|
||||
data-flx="app.app-layout.nagbars.build-environment-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
width: var(--typing-indicator-animation-size);
|
||||
height: var(--typing-indicator-animation-size);
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {switchAccountFromStalledConnection} from '@app/features/app/ConnectionRecovery';
|
||||
import styles from '@app/features/app/components/layout/app_layout/nagbars/ConnectionNagbar.module.css';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {
|
||||
NAGBAR_TONE_MAINTENANCE,
|
||||
NAGBAR_TONE_NEUTRAL,
|
||||
type NagbarTone,
|
||||
} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {NagbarSkeleton} from '@app/features/app/components/skeleton/NagbarSkeleton';
|
||||
import type {RememberedSkeletonNagbarRow} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {
|
||||
type ConnectionNotice,
|
||||
ConnectionNoticeTone,
|
||||
useConnectionNotice,
|
||||
} from '@app/features/app/hooks/useConnectionNotice';
|
||||
import AccountSwitcherModal from '@app/features/auth/components/accounts/AccountSwitcherModal';
|
||||
import {Typing} from '@app/features/channel/components/ChannelTyping';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const CONNECTION_NAGBAR_TONES: Record<ConnectionNoticeTone, NagbarTone> = {
|
||||
[ConnectionNoticeTone.NEUTRAL]: NAGBAR_TONE_NEUTRAL,
|
||||
[ConnectionNoticeTone.MAINTENANCE]: NAGBAR_TONE_MAINTENANCE,
|
||||
};
|
||||
const EMPTY_SKELETON_NAGBAR_ROWS: ReadonlyArray<RememberedSkeletonNagbarRow> = Object.freeze([]);
|
||||
const CONNECTION_ACCOUNT_SWITCHER_MODAL_KEY = 'connection-account-switcher';
|
||||
const TYPING_INDICATOR_VIEWBOX_SCALE = 20;
|
||||
const SWITCH_ACCOUNT_DESCRIPTOR = msg({
|
||||
message: 'Switch account',
|
||||
comment: 'Button on the connection banner that opens the account switcher.',
|
||||
});
|
||||
|
||||
function resolveConnectionActionUrl(notice: ConnectionNotice | null): string | null {
|
||||
if (notice == null || notice.action == null) {
|
||||
return null;
|
||||
}
|
||||
return notice.action.url;
|
||||
}
|
||||
|
||||
interface ConnectionNagbarProps {
|
||||
readonly isMobile: boolean;
|
||||
readonly rememberedRows?: ReadonlyArray<RememberedSkeletonNagbarRow> | null;
|
||||
}
|
||||
|
||||
export const ConnectionNagbar = observer(({isMobile, rememberedRows}: ConnectionNagbarProps) => {
|
||||
const skeletonRows = rememberedRows ?? EMPTY_SKELETON_NAGBAR_ROWS;
|
||||
const {i18n} = useLingui();
|
||||
const notice = useConnectionNotice();
|
||||
const actionUrl = resolveConnectionActionUrl(notice);
|
||||
const handleOpenActionUrl = useCallback(() => {
|
||||
if (actionUrl == null || actionUrl === '') {
|
||||
return;
|
||||
}
|
||||
openExternalUrlWithWarning(actionUrl);
|
||||
}, [actionUrl]);
|
||||
const handleSwitchAccount = useCallback(() => {
|
||||
ModalCommands.pushWithKey(
|
||||
modal(() => (
|
||||
<AccountSwitcherModal
|
||||
redirectAfterLogin={null}
|
||||
redirectAfterSwitch={null}
|
||||
switchAccount={switchAccountFromStalledConnection}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.handle-switch-account.account-switcher-modal"
|
||||
/>
|
||||
)),
|
||||
CONNECTION_ACCOUNT_SWITCHER_MODAL_KEY,
|
||||
);
|
||||
}, []);
|
||||
if (notice == null) {
|
||||
return (
|
||||
<NagbarSkeleton
|
||||
isMobile={isMobile}
|
||||
rows={skeletonRows}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.nagbar-skeleton"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const activeNotice = notice;
|
||||
const tone = CONNECTION_NAGBAR_TONES[activeNotice.tone];
|
||||
function renderNoticeAction() {
|
||||
if (activeNotice.action == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
variant="ghost"
|
||||
onClick={handleOpenActionUrl}
|
||||
disabled={false}
|
||||
submitting={false}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.render-notice-action.nagbar-button.open-action-url"
|
||||
>
|
||||
{activeNotice.action.label}
|
||||
</NagbarButton>
|
||||
);
|
||||
}
|
||||
function renderSwitchAccountAction() {
|
||||
if (!activeNotice.showSwitchAccount) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
variant="ghost"
|
||||
onClick={handleSwitchAccount}
|
||||
disabled={false}
|
||||
submitting={false}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.render-switch-account-action.nagbar-button.switch-account"
|
||||
>
|
||||
{i18n._(SWITCH_ACCOUNT_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
);
|
||||
}
|
||||
function renderActions() {
|
||||
if (activeNotice.action == null && !activeNotice.showSwitchAccount) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{renderNoticeAction()}
|
||||
{renderSwitchAccountAction()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={tone.backgroundColor}
|
||||
textColor={tone.textColor}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
message={
|
||||
<div className={styles.message} data-flx="app.app-layout.nagbars.connection-nagbar.message">
|
||||
<Typing
|
||||
className={styles.indicator}
|
||||
size={TYPING_INDICATOR_VIEWBOX_SCALE}
|
||||
color={tone.textColor}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.indicator"
|
||||
/>
|
||||
<span data-flx="app.app-layout.nagbars.connection-nagbar.span">{activeNotice.message}</span>
|
||||
</div>
|
||||
}
|
||||
actions={renderActions()}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
<NagbarSkeleton
|
||||
isMobile={isMobile}
|
||||
rows={skeletonRows.slice(1)}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.nagbar-skeleton--2"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {DESKTOP_DOWNLOAD_URL, PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -25,8 +26,8 @@ export const CorruptedInstallationNagbar = observer(({isMobile}: {isMobile: bool
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#b00000"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.CRITICAL].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.CRITICAL].textColor}
|
||||
data-flx="app.app-layout.nagbars.corrupted-installation-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import styles from '@app/features/app/components/layout/app_layout/nagbars/Deskt
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {DESKTOP_DOWNLOAD_URL, PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {DOWNLOAD_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
|
||||
@@ -28,8 +29,8 @@ export const DesktopDownloadNagbar = observer(({isMobile}: {isMobile: boolean})
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.desktop-download-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ import styles from '@app/features/app/components/layout/app_layout/nagbars/Deskt
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {ENABLE_NOTIFICATIONS_DESCRIPTOR, OKAY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {usePushSubscriptions} from '@app/features/notification/hooks/usePushSubscriptions';
|
||||
@@ -130,8 +131,8 @@ export const DesktopNotificationNagbar = observer(({isMobile}: {isMobile: boolea
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.desktop-notification-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {OPEN_SETTINGS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -37,8 +38,8 @@ export const EmailVerificationNagbar = observer(({isMobile}: {isMobile: boolean}
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#ea580c"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.ALERT].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.ALERT].textColor}
|
||||
data-flx="app.app-layout.nagbars.email-verification-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -44,8 +45,8 @@ const GiftInventoryNagbarContent = observer(function GiftInventoryNagbarContent(
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.gift-inventory-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_HQ_COMMUNITY_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
@@ -78,8 +79,8 @@ export const GuildMembershipCtaNagbar = observer(({isMobile}: {isMobile: boolean
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
onDismiss={handleDismiss}
|
||||
dismissible={true}
|
||||
data-flx="app.app-layout.nagbars.guild-membership-cta-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import KeybindManager from '@app/features/app/keybindings/KeybindManager';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
@@ -71,8 +72,8 @@ export const LinuxInputAccessNagbar = observer(({isMobile}: {isMobile: boolean})
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={NativePermission.dismissLinuxInputAccessNagbar}
|
||||
data-flx="app.app-layout.nagbars.linux-input-access-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import {showPremiumActionErrorModal} from '@app/features/app/components/dialogs/
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
@@ -66,8 +67,8 @@ export const PremiumExpiredNagbar = observer(({isMobile}: {isMobile: boolean}) =
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--status-danger)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.DANGER].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.DANGER].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.premium-expired-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import {showPremiumActionErrorModal} from '@app/features/app/components/dialogs/
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
@@ -70,8 +71,8 @@ export const PremiumGracePeriodNagbar = observer(({isMobile}: {isMobile: boolean
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#f97316"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.PREMIUM].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.PREMIUM].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.premium-grace-period-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -131,8 +132,8 @@ export const PremiumOnboardingNagbar = observer(function PremiumOnboardingNagbar
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.premium-onboarding-nagbar.nagbar"
|
||||
|
||||
+6
-19
@@ -7,6 +7,7 @@ import {
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
@@ -26,24 +27,10 @@ interface MaintenanceScheduleLabels {
|
||||
durationLabel: string;
|
||||
}
|
||||
|
||||
interface MaintenanceNagbarTone {
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
}
|
||||
|
||||
const MAINTENANCE_NAGBAR_TONES: Record<MaintenanceStatus, MaintenanceNagbarTone> = {
|
||||
scheduled: {
|
||||
backgroundColor: '#1d4ed8',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
in_progress: {
|
||||
backgroundColor: '#9a3412',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
completed: {
|
||||
backgroundColor: '#166534',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
export const MAINTENANCE_NAGBAR_TONE_KINDS: Record<MaintenanceStatus, NagbarToneKind> = {
|
||||
scheduled: NagbarToneKind.MAINTENANCE_SCHEDULED,
|
||||
in_progress: NagbarToneKind.MAINTENANCE_ACTIVE,
|
||||
completed: NagbarToneKind.MAINTENANCE_COMPLETED,
|
||||
};
|
||||
const MAINTENANCE_IN_PROGRESS_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Maintenance is in progress. Expected duration: {duration}.',
|
||||
@@ -156,7 +143,7 @@ export const ScheduledMaintenanceNagbar = observer(({isMobile}: {isMobile: boole
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tone = MAINTENANCE_NAGBAR_TONES[scheduledMaintenance.status];
|
||||
const tone = NAGBAR_TONES[MAINTENANCE_NAGBAR_TONE_KINDS[scheduledMaintenance.status]];
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -22,8 +23,8 @@ export const StreamerModeNagbar = observer(({isMobile}: {isMobile: boolean}) =>
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#5865f2"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.STREAMER].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.STREAMER].textColor}
|
||||
dismissible
|
||||
onDismiss={StreamerMode.dismissNagbar}
|
||||
data-flx="app.app-layout.nagbars.streamer-mode-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {TermsAcceptanceModal} from '@app/features/auth/components/modals/TermsAcceptanceModal';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -31,8 +32,8 @@ export const TermsAcceptanceNagbar = observer(({isMobile}: {isMobile: boolean})
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#4338ca"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.LEGAL].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.LEGAL].textColor}
|
||||
data-flx="app.app-layout.nagbars.terms-acceptance-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimAccountModal';
|
||||
import {CLAIM_ACCOUNT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
@@ -27,8 +28,8 @@ export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean})
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#ea580c"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.ALERT].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.ALERT].textColor}
|
||||
data-flx="app.app-layout.nagbars.unclaimed-account-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user