Compare commits

...
Author SHA1 Message Date
HampusandGitHub 45289b5531 fix(api): let single community setup work without email verification (#1683) 2026-08-16 22:14:59 +02:00
HampusandGitHub 30a1271774 fix(app): keep timers running while the window is visible but unfocused (#1682) 2026-08-16 22:06:09 +02:00
HampusandGitHub 438f11adda perf(app): stop per-frame style recalculation in skeletons and member list (#1681) 2026-08-16 22:00:10 +02:00
HampusandGitHub 170ca805c5 fix(app): stop the service worker serving the unrendered index template (#1680) 2026-08-16 20:52:07 +02:00
HampusandGitHub f4547d11d3 fix(app): dim the disabled discovery nav item through colour only (#1678) 2026-08-16 19:33:35 +02:00
HampusandGitHub ccdd85b099 refactor(desktop): replace the native voice engine with a standalone hardware encoder (#1677) 2026-08-16 19:24:31 +02:00
HampusandGitHub 98c40c6979 feat(app): redesign discovery and align skeletons with rendered UI 2026-08-16 19:17:28 +02:00
HampusandGitHub e054aa96be chore(desktop): upgrade Electron to 43.4.0 (#1674) 2026-08-16 15:42:29 +02:00
HampusandGitHub 3e12466c57 fix(unfurl): restore document title fallback for link embeds (#1673) 2026-08-16 15:02:16 +02:00
HampusandGitHub 039bd1a7df fix(embed): correct link thumbnail placement and harden unfurl parsing (#1672) 2026-08-16 14:10:25 +02:00
HampusandGitHub 7a45d5844d fix(app): scale guild list selection pill with app zoom (#1670) 2026-08-16 01:36:16 +02:00
HampusandGitHub 410fa2dba9 fix(app): scale group DM status indicator with app zoom (#1669) 2026-08-16 01:36:01 +02:00
HampusandGitHub 4cb1808959 fix(media-proxy): register coalescer waiters before checking slot state (#1668) 2026-08-16 01:02:09 +02:00
yidoandGitHub 60a62c24c3 fix(media-proxy): handle coalescer future cancellation with optimal drop guard (#1156)
Refs #1146
2026-08-16 00:59:41 +02:00
HampusandGitHub c06e958eb5 fix(ci): resolve knip unused export and stale entry pattern (#1667) 2026-08-16 00:47:56 +02:00
358b7c88fc fix(settings): require all query words to match (#1665)
Signed-off-by: liamt8d <[email protected]>
Co-authored-by: Hampus <[email protected]>
2026-08-16 00:44:34 +02:00
HampusandGitHub 1bced6abae fix(app): keep typing indicator dots animating under reduced motion (#1666) 2026-08-16 00:32:52 +02:00
HampusandGitHub 8cbd55dcaf feat(app): open the emoji picker when selecting Add reaction (#1664) 2026-08-15 23:35:07 +02:00
HampusandGitHub 162937575c feat(search): accept user IDs in from and mentions filters (#1663) 2026-08-15 23:31:04 +02:00
HampusandGitHub c6223d656e ci: fail when compiled locale modules drift from the Weblate catalogs (#1662) 2026-08-15 23:24:17 +02:00
HampusandGitHub 2dd5e6a4b4 chore(i18n): recompile locale modules from the Weblate catalogs (#1661) 2026-08-15 23:15:49 +02:00
HampusandGitHub 490b710c61 feat(api): allow bots to search messages within a single community (#1660) 2026-08-15 23:11:45 +02:00
HampusandGitHub b5285019cd fix(i18n): keep SSO URL error translations in the Weblate catalogs (#1658) 2026-08-15 22:53:55 +02:00
HampusandGitHub dcd3d9d08a fix(app): pan zoomed media with the scroll wheel (#1657) 2026-08-15 22:49:54 +02:00
HampusandGitHub fb718e7e5b fix(search): filter message dates on createdAt instead of the id string (#1656) 2026-08-15 22:41:51 +02:00
HampusandGitHub 578fd61d93 feat(self-hosting): allow opting in to private-address SSO providers (#1655) 2026-08-15 22:30:46 +02:00
HampusandGitHub cbc0a616ea fix(self-hosting): verify seaweedfs buckets instead of trusting exit status (#1654) 2026-08-15 22:21:24 +02:00
HampusandGitHub 6d04fa3e6d fix(api): report non-routable SSO URLs distinctly from malformed ones (#1653) 2026-08-15 21:49:43 +02:00
HampusandGitHub 562819be09 fix(app-proxy): allow extra CSP sources from the environment (#1652) 2026-08-15 21:32:16 +02:00
HampusandGitHub f45a3073c1 fix(app-proxy): allow configured branding image origins in the CSP (#1651) 2026-08-15 21:21:59 +02:00
HampusandGitHub 5f9e4db230 fix(app): treat official invite and gift links as external when self-hosted (#1650) 2026-08-15 21:08:22 +02:00
HampusandGitHub 0be2a7fed9 feat(openapi): recognise the modern Zod integer formats (#1649) 2026-08-15 21:01:43 +02:00
HampusandGitHub e0876d719c fix(auth): reject a weak claim password before sending the code (#1648) 2026-08-15 20:53:03 +02:00
HampusandGitHub ae11ee86aa fix(api): disable Bluesky OAuth instead of failing on an invalid key (#1647) 2026-08-15 20:43:30 +02:00
HampusandGitHub 5022568608 fix(search): match the Meilisearch result window to Elasticsearch (#1646) 2026-08-15 20:38:28 +02:00
HampusandGitHub 004fb0c7b0 fix(self-hosting): surface passkey relying party settings (#1645) 2026-08-15 20:23:19 +02:00
HampusandGitHub 2dc9ded0e8 fix(app): stop muted channels from marking the guild unread (#1644) 2026-08-15 20:18:05 +02:00
HampusandGitHub 0692aa0e25 fix(app): route middle-clicked external links through the warning (#1643) 2026-08-15 20:12:21 +02:00
HampusandGitHub 3ed43ca00f fix(schema): keep the extension when a filename has repeated dots (#1642) 2026-08-15 20:06:19 +02:00
HampusandGitHub 5cd22ee84e fix(app): wrap long guild names in the sidebar tooltip (#1641) 2026-08-15 20:01:20 +02:00
a90168fa66 chore(marketing): advance pointer 1bba956 → 9720a17 (#1640)
Co-authored-by: hampus-fluxer <[email protected]>
2026-08-15 19:55:27 +02:00
HampusandGitHub 1f76c9d3d3 fix(api): auto-join the single community on registration approval (#1639) 2026-08-15 19:54:40 +02:00
HampusandGitHub 4480d4db69 fix(self-hosting): declare postgres dependencies in compose (#1638) 2026-08-15 19:38:19 +02:00
d1e5b00c52 Use locale parameter for date formatting (#1637)
Co-authored-by: Hampus <[email protected]>
2026-08-15 19:37:48 +02:00
HampusandGitHub b937306210 fix(caddy): route server discovery to the API (#1636) 2026-08-15 19:32:17 +02:00
HampusandGitHub de614db368 fix(app): restore text selection on narrow desktop windows (#1635) 2026-08-15 19:28:59 +02:00
HampusandGitHub a4b121345f fix(app-proxy): allow Cloudflare Turnstile origins in the CSP (#1634) 2026-08-15 19:26:12 +02:00
HampusandGitHub 6073ad74d5 fix(schema): raise user trait length limit to fit SSO identities (#1633) 2026-08-15 19:21:46 +02:00
HampusandGitHub 2d51600b6c fix(markdown): stop escaped link destinations from swallowing later content (#1630) 2026-08-15 15:48:58 +02:00
HampusandGitHub 235399cfd6 fix(api): keep activity tracking out of the registration critical path (#1629) 2026-08-15 15:08:48 +02:00
HampusandGitHub 2bb4c92f2b feat(ci): list desktop download URLs in release bodies (#1628) 2026-08-15 14:50:51 +02:00
HampusandGitHub 86afe3aefc fix: repair the failing gateway and knip checks (#1627) 2026-08-15 14:41:07 +02:00
1436 changed files with 54647 additions and 404636 deletions
+42
View File
@@ -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
+1
View File
@@ -14,6 +14,7 @@
**/*.css.d.ts
**/*.tsbuildinfo
**/.cache/
**/.swc/
**/__pycache__/
**/_build/
**/coverage/
Generated
+10
View File
@@ -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",
-2
View File
@@ -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"
+22
View File
@@ -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
+4
View File
@@ -42,6 +42,10 @@
reverse_proxy static-proxy:8080
}
handle /.well-known/fluxer {
reverse_proxy api:8080
}
handle {
reverse_proxy app-proxy:8080
}
+39 -6
View File
@@ -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}`);
+1
View File
@@ -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',
+3 -1
View File
@@ -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({
+37 -25
View File
@@ -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);
}
+1
View File
@@ -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;
+3 -2
View File
@@ -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;
}
+17 -9
View File
@@ -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) {
+1
View File
@@ -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>
+1
View File
@@ -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 {
+2 -8
View File
@@ -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,
+142 -3
View File
@@ -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>
@@ -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>
);
}
@@ -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);
}
@@ -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,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
@@ -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"
@@ -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,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,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,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,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"
@@ -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"
@@ -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,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"
@@ -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,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,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,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