Compare commits

..
Author SHA1 Message Date
HampusandGitHub dcc3273889 feat(app): ask everyone to review who can message them (#3323) 2026-10-08 20:47:44 +02:00
HampusandGitHub e9a4f33a7e feat(api): track privacy setup and open community DMs by default (#3322) 2026-10-08 20:47:22 +02:00
HampusandGitHub ae820ba4ea chore(i18n): refresh catalog references (#3320) 2026-10-08 20:34:03 +02:00
HampusandGitHub 94f1239050 fix(premium): reflect limit config flags in plan comparisons (#3319) 2026-10-08 20:33:20 +02:00
HampusandGitHub acc1392a53 fix(ui): reopen a popout clicked while it is closing (#3318) 2026-10-08 20:33:04 +02:00
HampusandGitHub a6a9789091 fix(ui): stop clicks passing through the user area footer (#3317) 2026-10-08 20:32:49 +02:00
HampusandGitHub fdb7410976 fix(threads): light up joined threads in muted communities (#3316) 2026-10-08 20:32:33 +02:00
HampusandGitHub b146b8ee33 fix(desktop): trust locally added CAs in Chromium on Linux (#3315) 2026-10-08 20:21:04 +02:00
HampusandGitHub 4b1afc953f fix(app): redraw the favicon badge when branding changes (#3314) 2026-10-08 20:20:49 +02:00
HampusandGitHub 4ecbe9603c fix(api): store branding images as media references (#3313) 2026-10-08 20:20:34 +02:00
HampusandGitHub 847d449882 chore(i18n): refresh catalog references (#3312) 2026-10-08 20:06:41 +02:00
HampusandGitHub bef20cba85 fix(links): warn on unhandled middle clicks and desktop links (#3311) 2026-10-08 20:05:54 +02:00
JiraliteandGitHub ebf91ccc76 fix(links): warn on embed link middle clicks (#3305) 2026-10-08 20:05:27 +02:00
HampusandGitHub abfaff16c2 fix(guild): fit text icon initials to the icon by measured width (#3310) 2026-10-08 20:05:09 +02:00
HampusandGitHub 89fa895a0a fix(forum): keep closed posts visible without a reload (#3309) 2026-10-08 20:04:53 +02:00
HampusandGitHub 334fef52e1 fix(desktop): keep slow connects racing instead of aborting them (#3308) 2026-10-08 20:04:39 +02:00
HampusandGitHub aa42bf13ce fix(threads): announce threads above the latest five messages (#3307) 2026-10-08 20:04:23 +02:00
HampusandGitHub c486c2d3d0 revert(admin): restore user type toggles (#3306) 2026-10-08 20:04:01 +02:00
HampusandGitHub 0ec1cee99e test(gateway): keep the recheck timer out of the eunit process (#3304) 2026-10-08 18:14:47 +02:00
HampusandGitHub ccbe4857a1 docs(auth): document the desktop handoff deep link and deny (#3303) 2026-10-08 18:00:17 +02:00
HampusandGitHub 7f8b0afedb chore(i18n): refresh catalog references (#3302) 2026-10-08 17:58:50 +02:00
JiraliteandGitHub e94b7d7ad8 fix(threads): add a debug thread button (#3276) 2026-10-08 17:57:47 +02:00
HampusandGitHub 27baa38fd1 fix(threads): open the thread menu on browser right-click (#3301) 2026-10-08 17:45:33 +02:00
HampusandGitHub 4af10dd3e3 fix(threads): keep header actions clear of the channel name (#3300) 2026-10-08 17:44:48 +02:00
HampusandGitHub 269d33cab2 fix(threads): confirm before leaving a private thread (#3299) 2026-10-08 17:44:00 +02:00
HampusandGitHub 170e12595b fix(threads): let the thread members popout scroll (#3298) 2026-10-08 17:42:56 +02:00
HampusandGitHub b82bbaa2dd fix(ui): drop the switch label tab stop with no focus ring (#3297) 2026-10-08 17:42:11 +02:00
HampusandGitHub b8f3dbddbf fix(threads): keep Tab and focus inside the thread create pane (#3296) 2026-10-08 17:41:14 +02:00
HampusandGitHub 663572fd10 fix(forum): keep attachments on forum and media posts (#3295) 2026-10-08 17:40:29 +02:00
HampusandGitHub 5033cf7203 fix(app): follow theme library changes made in other windows (#3294) 2026-10-08 17:39:34 +02:00
HampusandGitHub 0fcab2f8f0 fix(app): hold the switched view until its route commits (#3293) 2026-10-08 17:38:36 +02:00
HampusandGitHub c3d790f664 fix(app): refocus the editor before text context menu actions (#3292) 2026-10-08 17:37:40 +02:00
HampusandGitHub 0906a85d6f fix(app): keep mature content consent across account switches (#3291) 2026-10-08 17:36:55 +02:00
HampusandGitHub 4da8dec4ac fix(app): ignore leftover mentions in unseen channels (#3290) 2026-10-08 17:35:59 +02:00
HampusandGitHub d6e3254a16 feat(app): show instance branding in switchers and pickers (#3289) 2026-10-08 17:34:56 +02:00
HampusandGitHub 6dc698ff5e fix(app): follow instance branding in tab title and favicon (#3288) 2026-10-08 17:33:28 +02:00
HampusandGitHub 39e48458da fix(app): keep a global define from breaking bundled modules (#3287) 2026-10-08 17:32:40 +02:00
HampusandGitHub c5453f83e8 feat(desktop): sign in with the browser without typing a code (#3286) 2026-10-08 17:31:45 +02:00
HampusandGitHub 510cc1b916 fix(desktop): show the text context menu on password fields (#3285) 2026-10-08 17:30:11 +02:00
HampusandGitHub e7859fefb1 fix(desktop): leave full screen before hiding on macOS close (#3284) 2026-10-08 17:28:58 +02:00
HampusandGitHub 46a356cc40 fix(media): keep self-host media loading after restarts (#3283) 2026-10-08 17:28:18 +02:00
HampusandGitHub bd56174870 fix(desktop): frame streamed request bodies for every method (#3282) 2026-10-08 17:26:41 +02:00
HampusandGitHub 965bb5f634 fix(desktop): trust the system CA store in main requests (#3281) 2026-10-08 17:26:12 +02:00
HampusandGitHub 7937d5f802 fix(desktop): update from the splash only after the user clicks (#3280) 2026-10-08 17:25:34 +02:00
HampusandGitHub f1373a63c3 feat(ci): publish renderer modules without a shell build (#3279) 2026-10-08 17:23:59 +02:00
HampusandGitHub e0d7625c39 feat(api): hand desktop sign-in back with a deep link grant (#3278) 2026-10-08 17:23:28 +02:00
375 changed files with 28311 additions and 20636 deletions
@@ -0,0 +1,350 @@
name: build desktop modules
on:
workflow_dispatch:
inputs:
channel:
description: Release channel to ship renderer modules on. The live shell on that channel stays as it is.
required: true
type: choice
options:
- canary
- stable
default: canary
build_version:
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
required: false
default: ""
type: string
allow_shell_drift:
description: Publish even though shell sources changed since the live shell was built. Only when the renderer does not depend on those changes.
required: false
default: false
type: boolean
permissions:
contents: write
id-token: write
actions: read
concurrency:
group: desktop-modules-${{ inputs.channel }}
cancel-in-progress: false
env:
CHANNEL: ${{ inputs.channel }}
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
jobs:
meta:
name: Resolve build metadata
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 25
permissions:
contents: read
outputs:
version: ${{ steps.meta.outputs.version }}
pub_date: ${{ steps.meta.outputs.pub_date }}
channel: ${{ steps.meta.outputs.channel }}
build_channel: ${{ steps.meta.outputs.build_channel }}
source_sha: ${{ steps.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: main
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Set metadata
id: meta
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs.build_version }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step set_metadata
--channel "${{ inputs.channel }}"
shared_assets:
name: Build shared renderer assets
needs:
- meta
runs-on: ubuntu-24.04
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.channel }}
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (renderer wasm)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Set workdir (Unix)
env:
SUBST_TARGET: ${{ github.workspace }}/source
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 26
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm
- name: Resolve pnpm store path (Unix)
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step resolve_pnpm_store_unix
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-shared-renderer-pnpm-store-
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-shared-renderer-cargo-registry-
- name: Install dependencies
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step install_dependencies
- name: Update version
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step update_version
- name: Set build channel
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Build shared renderer assets
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_shared_assets
- name: Prepare shared renderer artifact
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_shared_assets
- name: Upload shared renderer artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Split renderer into desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step split_modules
- name: Pack desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step pack_modules
- name: Upload desktop module packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: |
source/desktop-modules/classification.json
source/desktop-modules/*/module.json
source/desktop-modules/*/package.br
source/desktop-modules/*/package.br.sha256
if-no-files-found: error
retention-days: 1
compression-level: 0
assemble:
name: Assemble the modules-only release
if: ${{ !cancelled() && needs.shared_assets.result == 'success' }}
needs:
- meta
- shared_assets
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 60
permissions:
actions: read
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
fetch-depth: 0
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Download desktop module packages
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: desktop-modules
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Check the live shell runs these modules
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
ALLOW_SHELL_DRIFT: ${{ inputs.allow_shell_drift }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step check_shell_drift
- name: Build the modules-only manifests
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_modules_only_manifest
- name: Prepare GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_modules_release_assets
- name: Upload GitHub release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fluxer-desktop-modules-release-assets
path: release_assets
if-no-files-found: error
retention-days: 1
compression-level: 0
publish_release:
name: Publish GitHub modules-only release
if: ${{ !cancelled() && needs.assemble.result == 'success' }}
needs:
- meta
- assemble
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: write
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Download GitHub release assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: fluxer-desktop-modules-release-assets
path: release_assets
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub modules-only release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
run: |
set -euo pipefail
release_args=(
release publish
--component "fluxer-desktop-${CHANNEL}"
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
--asset-dir release_assets
)
if [[ "${CHANNEL}" == "canary" ]]; then
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
+152 -7
View File
@@ -6266,6 +6266,72 @@
}
}
},
"/admin/users/{user_id}/bot-status": {
"put": {
"operationId": "set_admin_user_bot_status",
"summary": "Set user bot status",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserBotStatusRequest"}}}
}
}
},
"/admin/users/{user_id}/change-log": {
"get": {
"operationId": "list_admin_user_change_log",
@@ -7967,6 +8033,72 @@
]
}
},
"/admin/users/{user_id}/system-status": {
"put": {
"operationId": "set_admin_user_system_status",
"summary": "Set user system status",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserMutationResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminUserSystemStatusRequest"}}}
}
}
},
"/admin/users/{user_id}/traits": {
"put": {
"operationId": "set_admin_user_traits",
@@ -9413,6 +9545,13 @@
},
"required": ["traits"]
},
"AdminUserSystemStatusRequest": {
"type": "object",
"properties": {
"system": {"type": "boolean", "description": "Whether the user should be marked as a system user"}
},
"required": ["system"]
},
"AdminStorePurchaseListResponse": {
"type": "object",
"properties": {
@@ -9877,6 +10016,11 @@
"required": ["entries", "next_page_token"],
"additionalProperties": false
},
"AdminUserBotStatusRequest": {
"type": "object",
"properties": {"bot": {"type": "boolean", "description": "Whether the user should be marked as a bot"}},
"required": ["bot"]
},
"AdminUserBanNoteRequest": {
"type": "object",
"properties": {
@@ -9991,7 +10135,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions to assign"
@@ -12892,7 +13036,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12910,7 +13054,7 @@
"type": "string"
},
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12940,7 +13084,7 @@
"maximum": 365
},
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12961,7 +13105,7 @@
"type": "string"
},
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12974,7 +13118,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 105,
"maxItems": 106,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -13076,6 +13220,7 @@
"user:view:email",
"user:view:ip",
"user:temp_ban",
"user:update:bot_status",
"user:update:dob",
"user:update:email",
"user:update:flags",
@@ -16123,7 +16268,7 @@
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
"type": "string"
},
"acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}},
"acls": {"maxItems": 106, "type": "array", "items": {"type": "string"}},
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
"has_totp": {"type": "boolean"},
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
+2
View File
@@ -90,6 +90,7 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
pub const USER_VIEW_EMAIL: &str = "user:view:email";
pub const USER_VIEW_IP: &str = "user:view:ip";
pub const USER_TEMP_BAN: &str = "user:temp_ban";
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
pub const USER_UPDATE_DOB: &str = "user:update:dob";
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
@@ -196,6 +197,7 @@ pub const ALL_ACLS: &[&str] = &[
USER_VIEW_EMAIL,
USER_VIEW_IP,
USER_TEMP_BAN,
USER_UPDATE_BOT_STATUS,
USER_UPDATE_DOB,
USER_UPDATE_EMAIL,
USER_UPDATE_FLAGS,
+22
View File
@@ -306,6 +306,28 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
let response = self
.generated()
.set_admin_user_bot_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
let response = self
.generated()
.set_admin_user_system_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn change_username(
&self,
user_id: &str,
+16
View File
@@ -178,6 +178,22 @@ pub async fn dispatch(
"Failed to clear user fields",
)
}
"set_bot_status" => {
let val = form.bool_value("bot");
DispatchOutcome::from_result(
client.set_bot_status(user_id, val).await,
"Bot status updated successfully",
"Failed to update bot status",
)
}
"set_system_status" => {
let val = form.bool_value("system");
DispatchOutcome::from_result(
client.set_system_status(user_id, val).await,
"System status updated successfully",
"Failed to update system status",
)
}
"change_username" => {
let Some(username) = get("username") else {
return DispatchOutcome::error("Username is required");
@@ -52,6 +52,7 @@ pub fn account_tab(
(sessions_card(config, sessions))
(quick_actions_card(base, user, csrf_token, username_sign_in, can_revoke_recovery_kit))
(clear_fields_card(base, user, csrf_token))
(user_status_card(base, user, csrf_token))
(security_actions_card(base, user, csrf_token))
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
}
@@ -310,6 +311,19 @@ fn clear_fields_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
}
}
fn user_status_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
let is_bot = user.bot;
let is_sys = user.system;
html! {
(card_with_header("User Status", html! {
div class="grid grid-cols-1 gap-4 md:grid-cols-2" {
(status_toggle(base, &user.id, "set_bot_status", is_bot, "bot", csrf_token))
(status_toggle(base, &user.id, "set_system_status", is_sys, "system", csrf_token))
}
}))
}
}
fn security_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
html! {
(card_with_header("Security Actions", html! {
@@ -450,3 +464,40 @@ fn action_form(
}
}
}
fn status_toggle(
base: &str,
uid: &str,
action: &str,
active: bool,
kind: &str,
csrf: &str,
) -> Markup {
let status_val = if active { "false" } else { "true" };
let label = format!(
"{} {} Status",
if active { "Remove" } else { "Set" },
capitalize(kind)
);
let action_url = format!("{base}/users/{uid}?action={action}&status={status_val}&tab=account");
html! {
form method="post"
action=(&action_url)
hx-post=(&action_url)
hx-target="#flash-container"
hx-swap="none"
hx-push-url="false" {
(csrf_input(csrf))
input type="hidden" name=(kind) value=(status_val);
button type="submit" class=(BTN_CLS) { (label) }
}
}
}
fn capitalize(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().chain(c).collect(),
}
}
@@ -437,6 +437,11 @@ fn acls_card(
(flag_checkbox("acls[]", item.to_string(), item, checked, true))
}
}
@for item in &user.acls {
@if !acl::ALL_ACLS.iter().any(|known| known == &item.as_str()) {
input type="hidden" name="acls[]" value=(item);
}
}
(form_actions(html! {
(submit_button("Save ACLs"))
}))
@@ -24,6 +24,7 @@ import {
AdminUserAclsRequest,
AdminUserBanNoteRequest,
AdminUserBanRequest,
AdminUserBotStatusRequest,
AdminUserChangeLogQuery,
AdminUserClearFieldsRequest,
AdminUserDeletionCancelRequest,
@@ -38,6 +39,7 @@ import {
AdminUserPremiumFlagsUpdateRequest,
AdminUserRelationshipCategoryQuery,
AdminUserRelationshipParam,
AdminUserSystemStatusRequest,
AdminUsersMeResponse,
AdminUserTraitsRequest,
AdminUserUnbanRequest,
@@ -601,6 +603,70 @@ export function UserAdminController(app: HonoApp) {
);
},
);
app.put(
'/admin/users/:user_id/bot-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('param', UserIdParam),
Validator('json', AdminUserBotStatusRequest),
OpenAPI({
operationId: 'set_admin_user_bot_status',
summary: 'Set user bot status',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Mark or unmark a user account as a bot. Controls bot badge visibility and API permissions. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.profileService.setUserBotStatus(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.put(
'/admin/users/:user_id/system-status',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.USER_UPDATE_BOT_STATUS),
Validator('param', UserIdParam),
Validator('json', AdminUserSystemStatusRequest),
OpenAPI({
operationId: 'set_admin_user_system_status',
summary: 'Set user system status',
responseSchema: UserMutationResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
description:
'Mark or unmark a user as a system account. System accounts have special permissions for automated operations. Creates audit log entry. Requires USER_UPDATE_BOT_STATUS permission.',
}),
async (ctx) => {
const adminService = ctx.get('adminService');
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const adminUserAcls = ctx.get('adminUserAcls');
const {user_id: userId} = ctx.req.valid('param');
return ctx.json(
await adminService.userService.profileService.setUserSystemStatus(
{user_id: userId, ...ctx.req.valid('json')},
adminUserId,
auditLogReason,
adminUserAcls,
),
);
},
);
app.patch(
'/admin/users/:user_id/username',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
@@ -15,6 +15,9 @@ import type {User} from '@app/api/models/User';
import {enqueueStripeCustomerEmailSync} from '@app/api/stripe/StripeCustomer';
import {assertNoDiscriminatorChange, reserveUsername, type UsernameReservation} from '@app/api/user/UniqueUsernames';
import {USERNAME_MODE_DISCRIMINATOR} from '@app/api/user/UserTag';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {TagAlreadyTakenError} from '@fluxer/errors/src/domains/user/TagAlreadyTakenError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {
@@ -22,6 +25,8 @@ import type {
ChangeEmailRequest,
ChangeUsernameRequest,
ClearUserFieldsRequest,
SetUserBotStatusRequest,
SetUserSystemStatusRequest,
VerifyUserEmailRequest,
} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {types} from 'cassandra-driver';
@@ -104,6 +109,75 @@ export class AdminUserProfileService {
};
}
async setUserBotStatus(
data: SetUserBotStatusRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (data.bot && user.acls.size > 0) {
throw new AccessDeniedError();
}
const updates: Record<string, boolean> = {bot: data.bot};
if (!data.bot) {
updates['system'] = false;
}
const updatedUser = await userRepository.patchUpsert(userId, updates, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'set_bot_status',
auditLogReason,
metadata: new Map([['bot', data.bot.toString()]]),
});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async setUserSystemStatus(
data: SetUserSystemStatusRequest,
adminUserId: UserID,
auditLogReason: string | null,
acls: ReadonlySet<string>,
) {
const {users: userRepository, cache: cacheService} = this.deps.apiContext.services;
const {auditService, updatePropagator} = this.deps;
const userId = createUserID(data.user_id);
const user = await userRepository.findUnique(userId);
if (!user) {
throw new UnknownUserError();
}
if (data.system && !user.isBot) {
throw InputValidationError.fromCode(
'system',
ValidationErrorCodes.USER_MUST_BE_A_BOT_TO_BE_MARKED_AS_A_SYSTEM_USER,
);
}
const updatedUser = await userRepository.patchUpsert(userId, {system: data.system}, user.toRow());
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
await auditService.createAuditLog({
adminUserId,
targetType: 'user',
targetId: BigInt(userId),
action: 'set_system_status',
auditLogReason,
metadata: new Map([['system', data.system.toString()]]),
});
return {
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
};
}
async verifyUserEmail(
data: VerifyUserEmailRequest,
adminUserId: UserID,
@@ -78,6 +78,7 @@ const adminEndpoints: Array<AdminEndpointCase> = [
{method: 'GET', path: '/admin/users/1/webauthn-credentials', requiredACL: 'user:update:mfa'},
{method: 'DELETE', path: '/admin/users/1/webauthn-credentials/credential', requiredACL: 'user:update:mfa'},
{method: 'DELETE', path: '/admin/users/1/profile-fields', requiredACL: 'user:update:profile'},
{method: 'PUT', path: '/admin/users/1/bot-status', requiredACL: 'user:update:bot_status'},
{method: 'PUT', path: '/admin/users/1/acls', requiredACL: 'acl:set:user'},
{method: 'PUT', path: '/admin/users/1/deletion', requiredACL: 'user:delete'},
{method: 'POST', path: '/admin/users/1/avatar-block', requiredACL: 'ban:avatar_hash:add'},
@@ -20,6 +20,8 @@ const MUTATIONS: Array<{verb: 'put' | 'patch' | 'delete'; path: string; acl: str
{verb: 'put', path: 'ban', acl: AdminACLs.USER_TEMP_BAN, body: {duration_hours: 1, reason: 'test'}},
{verb: 'put', path: 'deletion', acl: AdminACLs.USER_DELETE, body: {delay_days: 1}},
{verb: 'delete', path: 'profile-fields', acl: AdminACLs.USER_UPDATE_PROFILE, body: {fields: ['bio']}},
{verb: 'put', path: 'bot-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {bot: true}},
{verb: 'put', path: 'system-status', acl: AdminACLs.USER_UPDATE_BOT_STATUS, body: {system: true}},
];
const CASES = SYNTHETIC_USER_IDS.flatMap((userId) =>
@@ -191,6 +191,39 @@ export const UserWriteAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/bot-status',
async prepare({harness}) {
const target = await createTestAccount(harness);
return {
request: {path: `/admin/users/${target.userId}/bot-status`, body: {bot: true}},
expected: {
action: 'set_bot_status',
targetType: 'user',
targetId: target.userId,
metadata: {bot: 'true'},
},
};
},
},
{
method: 'PUT',
route: '/admin/users/:user_id/system-status',
async prepare(context) {
const target = await createTestAccount(context.harness);
await adminBuilder(context).put(`/admin/users/${target.userId}/bot-status`).body({bot: true}).execute();
return {
request: {path: `/admin/users/${target.userId}/system-status`, body: {system: true}},
expected: {
action: 'set_system_status',
targetType: 'user',
targetId: target.userId,
metadata: {system: 'true'},
},
};
},
},
{
method: 'PATCH',
route: '/admin/users/:user_id/username',
+39 -6
View File
@@ -29,7 +29,9 @@ import {
HandoffCancelRequest,
HandoffCodeParam,
HandoffCompleteRequest,
HandoffCompleteResponse,
HandoffInfoResponse,
HandoffInitiateRequest,
HandoffInitiateResponse,
HandoffStatusRequest,
HandoffStatusResponse,
@@ -609,6 +611,7 @@ export function AuthController(app: HonoApp) {
app.post(
'/auth/handoff/initiate',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_INITIATE),
Validator('json', HandoffInitiateRequest),
OpenAPI({
operationId: 'initiate_handoff',
summary: 'Initiate handoff',
@@ -617,10 +620,15 @@ export function AuthController(app: HonoApp) {
security: [],
tags: ['Auth'],
description:
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking. A desktop app that registers a return deep link can be signed in without the user typing the code.',
}),
async (ctx) => {
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
return ctx.json(
await ctx.get('authRequestService').initiateHandoff({
request: ctx.req.raw,
returnUri: ctx.req.valid('json')?.return_uri ?? null,
}),
);
},
);
app.get(
@@ -656,23 +664,47 @@ export function AuthController(app: HonoApp) {
OpenAPI({
operationId: 'complete_handoff',
summary: 'Complete handoff',
responseSchema: null,
statusCode: 204,
responseSchema: HandoffCompleteResponse,
statusCode: [200, 204],
security: [],
tags: ['Auth'],
description: 'Complete the handoff process and authenticate on the target device using the handoff code.',
description:
'Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
await ctx.get('authRequestService').completeHandoff({
const response = await ctx.get('authRequestService').completeHandoff({
data: ctx.req.valid('json'),
clientIp,
authToken: ctx.get('authToken') ?? undefined,
approverOrigin: ctx.req.header('origin'),
});
return response ? ctx.json(response) : ctx.body(null, 204);
},
);
app.post(
'/auth/handoff/:code/deny',
RateLimitMiddleware(RateLimitConfigs.AUTH_HANDOFF_DENY),
Validator('param', HandoffCodeParam),
OpenAPI({
operationId: 'deny_handoff',
summary: 'Deny handoff',
responseSchema: null,
statusCode: 204,
security: [],
tags: ['Auth'],
description:
'Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.',
}),
async (ctx) => {
const clientIp = requireClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
await ctx.get('authRequestService').denyHandoff({code: ctx.req.valid('param').code, clientIp});
return ctx.body(null, 204);
},
);
@@ -726,6 +758,7 @@ export function AuthController(app: HonoApp) {
code: ctx.req.valid('param').code,
clientIp,
pollSecret: ctx.req.valid('json').poll_secret,
grant: ctx.req.valid('json').grant,
});
return ctx.json(response);
},
+27 -5
View File
@@ -41,6 +41,7 @@ import type {
EmailRevertRequest,
ForgotPasswordRequest,
HandoffCompleteRequest,
HandoffCompleteResponse,
HandoffInfoResponse,
HandoffInitiateResponse,
HandoffStatusResponse,
@@ -141,6 +142,7 @@ interface AuthLogoutAuthSessionsRequest {
interface AuthHandoffInitiateRequest {
request: Request;
returnUri?: string | null;
}
interface AuthHandoffInfoRequest {
@@ -152,6 +154,7 @@ interface AuthHandoffStatusRequest {
code: string;
clientIp: string;
pollSecret?: string;
grant?: string;
}
interface AuthHandoffCancelRequest {
@@ -339,16 +342,18 @@ export class AuthRequestService {
return {available: !(await isUsernameTaken(this.apiContext.services.users, username))};
}
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
async initiateHandoff({request, returnUri}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
const result = await this.desktopHandoffService.initiateHandoff({
origin,
initiatorOrigin: request.headers.get('origin'),
returnUri,
});
return {
code: result.code,
expires_at: result.expiresAt.toISOString(),
poll_secret: result.pollSecret,
return_method: result.returnMethod,
};
}
@@ -376,16 +381,26 @@ export class AuthRequestService {
country: geo.countryName,
},
},
return_method: info.returnMethod,
};
}
async completeHandoff({data, clientIp, authToken, approverOrigin}: AuthHandoffCompleteRequest): Promise<void> {
async denyHandoff({code, clientIp}: AuthHandoffInfoRequest): Promise<void> {
await this.desktopHandoffService.denyHandoff(code, clientIp);
}
async completeHandoff({
data,
clientIp,
authToken,
approverOrigin,
}: AuthHandoffCompleteRequest): Promise<HandoffCompleteResponse | null> {
const sessionToken = data.token ?? authToken;
if (!sessionToken) {
throw new UnauthorizedError();
}
let createdToken: string | null = null;
const {initiatorOrigin} = await this.desktopHandoffService.completeHandoff(
const {initiatorOrigin, returnUrl} = await this.desktopHandoffService.completeHandoff(
data.code,
async (origin) => {
const created = await AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
@@ -397,10 +412,12 @@ export class AuthRequestService {
return created;
},
clientIp,
data.return_method,
);
if (createdToken !== null) {
await this.recordPushSessionPredecessor(createdToken, sessionToken, initiatorOrigin, approverOrigin);
}
return returnUrl ? {return_url: returnUrl} : null;
}
private async recordPushSessionPredecessor(
@@ -428,8 +445,13 @@ export class AuthRequestService {
}
}
async getHandoffStatus({code, clientIp, pollSecret}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret);
async getHandoffStatus({
code,
clientIp,
pollSecret,
grant,
}: AuthHandoffStatusRequest): Promise<HandoffStatusResponse> {
const result = await this.desktopHandoffService.getHandoffStatus(code, clientIp, pollSecret, grant);
return {
status: result.status,
token: result.token,
@@ -21,6 +21,7 @@ const MAX_FAILED_ATTEMPTS = 5;
const ATTEMPT_TTL_SECONDS = 900;
const MAX_INFO_LOOKUPS = 3;
const POLL_SECRET_BYTES = 32;
const GRANT_BYTES = 32;
interface HandoffData {
createdAt: number;
@@ -28,14 +29,19 @@ interface HandoffData {
initiatorOrigin?: string | null;
infoLookupCount: number;
pollSecretHash: string;
returnUri?: string | null;
denied?: boolean;
}
interface HandoffTokenData {
token: string;
userId: string;
pollSecretHash: string;
grantHash?: string;
}
export type DesktopHandoffReturnMethod = 'deep_link' | 'code';
interface HandoffApproverData {
approvedAt: number;
}
@@ -62,15 +68,19 @@ function requireNormalizedHandoffCode(code: string): string {
return normalized;
}
function generatePollSecret(): string {
return randomBytes(POLL_SECRET_BYTES).toString('base64url');
function generateSecret(byteLength: number): string {
return randomBytes(byteLength).toString('base64url');
}
function hashPollSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}
function pollSecretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
function resolveReturnMethod(handoffData: HandoffData): DesktopHandoffReturnMethod {
return handoffData.returnUri ? 'deep_link' : 'code';
}
function secretMatches(presented: string | undefined, storedHash: string | undefined): boolean {
if (!presented || !storedHash) {
return false;
}
@@ -85,32 +95,44 @@ function pollSecretMatches(presented: string | undefined, storedHash: string | u
export class DesktopHandoffService {
constructor(private readonly apiContext: ApiContext) {}
async initiateHandoff(args: {origin: SessionOrigin; initiatorOrigin?: string | null}): Promise<{
async initiateHandoff(args: {
origin: SessionOrigin;
initiatorOrigin?: string | null;
returnUri?: string | null;
}): Promise<{
code: string;
expiresAt: Date;
pollSecret: string;
returnMethod: DesktopHandoffReturnMethod;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = generateNormalizedHandoffCode();
const pollSecret = generatePollSecret();
const pollSecret = generateSecret(POLL_SECRET_BYTES);
const handoffData: HandoffData = {
createdAt: Date.now(),
origin: args.origin,
initiatorOrigin: args.initiatorOrigin ?? null,
infoLookupCount: 0,
pollSecretHash: hashPollSecret(pollSecret),
returnUri: args.returnUri ?? null,
};
const expirySeconds = seconds('5 minutes');
await cache.set(`${HANDOFF_CODE_PREFIX}${normalizedCode}`, handoffData, expirySeconds);
const expiresAt = new Date(Date.now() + ms('5 minutes'));
return {code: formatDesktopHandoffCode(normalizedCode), expiresAt, pollSecret};
return {
code: formatDesktopHandoffCode(normalizedCode),
expiresAt,
pollSecret,
returnMethod: resolveReturnMethod(handoffData),
};
}
async completeHandoff(
code: string,
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
approverIp: string,
): Promise<{initiatorOrigin: string | null}> {
returnMethod: DesktopHandoffReturnMethod = 'code',
): Promise<{initiatorOrigin: string | null; returnUrl: string | null}> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
@@ -120,10 +142,13 @@ export class DesktopHandoffService {
throw new InvalidHandoffCodeError();
}
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (!handoffData) {
if (!handoffData || handoffData.denied) {
await this.recordFailedAttempt(approverIp);
throw new InvalidHandoffCodeError();
}
if (returnMethod === 'deep_link' && !handoffData.returnUri) {
throw new InvalidHandoffCodeError();
}
const remainingSeconds = Math.max(
0,
seconds('5 minutes') - Math.floor((Date.now() - handoffData.createdAt) / 1000),
@@ -132,15 +157,22 @@ export class DesktopHandoffService {
throw new HandoffCodeExpiredError();
}
const {token, userId} = await createTokenData(handoffData.origin);
const grant = returnMethod === 'deep_link' ? generateSecret(GRANT_BYTES) : null;
const tokenData: HandoffTokenData = {
token,
userId,
pollSecretHash: handoffData.pollSecretHash,
...(grant ? {grantHash: hashPollSecret(grant)} : {}),
};
await cache.set(`${HANDOFF_TOKEN_PREFIX}${normalizedCode}`, tokenData, remainingSeconds);
await cache.delete(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
await cache.delete(`${HANDOFF_APPROVER_PREFIX}${normalizedCode}`);
return {initiatorOrigin: handoffData.initiatorOrigin ?? null};
let returnUrl: string | null = null;
if (grant && handoffData.returnUri) {
const params = new URLSearchParams({code: formatDesktopHandoffCode(normalizedCode), grant});
returnUrl = `${handoffData.returnUri}?${params.toString()}`;
}
return {initiatorOrigin: handoffData.initiatorOrigin ?? null, returnUrl};
}
async getHandoffInfo(
@@ -149,13 +181,14 @@ export class DesktopHandoffService {
): Promise<{
status: 'pending' | 'expired';
origin?: SessionOrigin;
returnMethod?: DesktopHandoffReturnMethod;
}> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const handoffData = await cache.get<HandoffData>(codeKey);
if (!handoffData) {
if (!handoffData || handoffData.denied) {
await this.recordFailedAttempt(approverIp);
return {status: 'expired'};
}
@@ -172,15 +205,35 @@ export class DesktopHandoffService {
{approvedAt: Date.now()},
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
);
return {status: 'pending', origin: handoffData.origin};
return {status: 'pending', origin: handoffData.origin, returnMethod: resolveReturnMethod(handoffData)};
}
async denyHandoff(code: string, approverIp: string): Promise<void> {
const {cache} = this.apiContext.services;
const normalizedCode = requireNormalizedHandoffCode(code);
await this.checkAttemptLimit(approverIp);
const approverKey = `${HANDOFF_APPROVER_PREFIX}${normalizedCode}`;
const codeKey = `${HANDOFF_CODE_PREFIX}${normalizedCode}`;
const storedApprover = await cache.get<HandoffApproverData>(approverKey);
const handoffData = await cache.get<HandoffData>(codeKey);
if (!storedApprover || !handoffData) {
await this.recordFailedAttempt(approverIp);
throw new InvalidHandoffCodeError();
}
const remainingTtl = await cache.ttl(codeKey);
if (remainingTtl > 0) {
await cache.set(codeKey, {...handoffData, denied: true}, remainingTtl);
}
await cache.delete(approverKey);
}
async getHandoffStatus(
code: string,
pollerIp: string,
pollSecret: string | undefined,
grant?: string,
): Promise<{
status: 'pending' | 'completed' | 'expired';
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string;
userId?: string;
}> {
@@ -190,10 +243,19 @@ export class DesktopHandoffService {
const tokenKey = `${HANDOFF_TOKEN_PREFIX}${normalizedCode}`;
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
if (tokenData) {
if (!pollSecretMatches(pollSecret, tokenData.pollSecretHash)) {
if (!secretMatches(pollSecret, tokenData.pollSecretHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
if (tokenData.grantHash) {
if (!grant) {
return {status: 'pending'};
}
if (!secretMatches(grant, tokenData.grantHash)) {
await this.recordFailedAttempt(pollerIp);
return {status: 'pending'};
}
}
await cache.delete(tokenKey);
return {
status: 'completed',
@@ -203,7 +265,7 @@ export class DesktopHandoffService {
}
const handoffData = await cache.get<HandoffData>(`${HANDOFF_CODE_PREFIX}${normalizedCode}`);
if (handoffData) {
return {status: 'pending'};
return {status: handoffData.denied ? 'denied' : 'pending'};
}
return {status: 'expired'};
}
@@ -216,7 +278,7 @@ export class DesktopHandoffService {
const handoffData = await cache.get<HandoffData>(codeKey);
const tokenData = await cache.get<HandoffTokenData>(tokenKey);
const storedHash = handoffData?.pollSecretHash ?? tokenData?.pollSecretHash;
if (!pollSecretMatches(pollSecret, storedHash)) {
if (!secretMatches(pollSecret, storedHash)) {
throw new InvalidHandoffCodeError();
}
await cache.delete(codeKey);
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createAuthHarness, createTestAccount, loginAccount} from '@app/api/auth/tests/AuthTestUtils';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface HandoffInitiateResponse {
code: string;
poll_secret: string;
return_method: 'deep_link' | 'code';
}
interface HandoffInfoResponse {
status: 'pending' | 'expired';
return_method?: 'deep_link' | 'code';
}
interface HandoffCompleteResponse {
return_url: string;
}
interface HandoffStatusResponse {
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string | null;
user_id?: string | null;
}
const RETURN_URI = 'fluxer-canary://handoff';
describe('Auth desktop handoff deep link return', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
async function initiate(body: Record<string, unknown> | null): Promise<HandoffInitiateResponse> {
return createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
.post('/auth/handoff/initiate')
.body(body)
.execute();
}
async function readStatus(code: string, body: Record<string, unknown>): Promise<HandoffStatusResponse> {
return createBuilderWithoutAuth<HandoffStatusResponse>(harness)
.post(`/auth/handoff/${code}/status`)
.body(body)
.execute();
}
it('releases the token only to the app that presents both the poll secret and the grant', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
expect(initResp.return_method).toBe('deep_link');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.return_method).toBe('deep_link');
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.expect(200)
.execute();
const returnUrl = new URL(completed.return_url);
expect(`${returnUrl.protocol}//${returnUrl.host}`).toBe(RETURN_URI);
expect(returnUrl.searchParams.get('code')).toBe(initResp.code);
const grant = returnUrl.searchParams.get('grant');
expect(grant).toBeTruthy();
const withoutGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(withoutGrant.status).toBe('pending');
expect(withoutGrant.token ?? null).toBeNull();
const wrongGrant = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: 'not-the-grant'});
expect(wrongGrant.status).toBe('pending');
const grantWithoutSecret = await readStatus(initResp.code, {poll_secret: 'not-the-secret', grant: grant!});
expect(grantWithoutSecret.status).toBe('pending');
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant: grant!});
expect(released.status).toBe('completed');
expect(released.token).toBeTruthy();
expect(released.user_id).toBe(login.userId);
});
it('keeps polling without a grant from counting as a failed attempt', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
const completed = await createBuilderWithoutAuth<HandoffCompleteResponse>(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.execute();
for (let i = 0; i < 8; i++) {
const pending = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(pending.status).toBe('pending');
}
const grant = new URL(completed.return_url).searchParams.get('grant')!;
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret, grant});
expect(released.status).toBe('completed');
});
it('still completes through the typed code when the app registered a deep link', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'code'})
.expect(204)
.execute();
const released = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(released.status).toBe('completed');
});
it('reports the code return method when the app did not register a deep link', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate(null);
expect(initResp.return_method).toBe('code');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.return_method).toBe('code');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId, return_method: 'deep_link'})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
it('rejects a return deep link outside the desktop app schemes', async () => {
for (const returnUri of ['https://evil.example/handoff', 'fluxer://handoff/extra', 'javascript://handoff']) {
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/initiate')
.body({return_uri: returnUri})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
}
});
it('tells the app when the browser declines the request and blocks later approval', async () => {
const account = await createTestAccount(harness);
const login = await loginAccount(harness, account);
const initResp = await initiate({return_uri: RETURN_URI});
await createBuilderWithoutAuth(harness)
.post(`/auth/handoff/${initResp.code}/deny`)
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
await createBuilderWithoutAuth(harness).get(`/auth/handoff/${initResp.code}/info`).execute();
await createBuilderWithoutAuth(harness).post(`/auth/handoff/${initResp.code}/deny`).expect(204).execute();
const denied = await readStatus(initResp.code, {poll_secret: initResp.poll_secret});
expect(denied.status).toBe('denied');
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
.get(`/auth/handoff/${initResp.code}/info`)
.execute();
expect(info.status).toBe('expired');
await createBuilderWithoutAuth(harness)
.post('/auth/handoff/complete')
.body({code: initResp.code, token: login.token, user_id: login.userId})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_HANDOFF_CODE)
.execute();
});
});
@@ -76,10 +76,9 @@ import type {
} from '@fluxer/schema/src/domains/channel/ForumRequestSchemas';
import type {ThreadChannelResponse} from '@fluxer/schema/src/domains/channel/ThreadRequestSchemas';
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets';
const THREAD_CREATED_MESSAGE_MIN_SOURCE_AGE_MS = 5 * 60 * 1000;
const THREAD_CREATED_MESSAGE_RECENT_WINDOW = 5;
function slowmodeThreadKey(parentId: ChannelID, userId: UserID): string {
return `slowmode-thread:${parentId}:${userId}`;
@@ -159,13 +158,22 @@ export class ThreadCreationService {
}
const starter = await this.buildStarterEvent(params.viewer, params.user.id, view, params.requestCache);
if (starter) events.push(starter);
if (Date.now() - snowflakeToDate(source.id).getTime() >= THREAD_CREATED_MESSAGE_MIN_SOURCE_AGE_MS) {
if (!(await this.isAmongRecentMessages(parentAuth.channel.id, source.id))) {
events.push(await this.createThreadCreatedMessage(parentAuth.channel, view, params.user.id));
}
await dispatchThreadEvents(this.ctx.gatewayService, view.state.guildId, events);
return mapThreadToResponse(view);
}
private async isAmongRecentMessages(channelId: ChannelID, messageId: MessageID): Promise<boolean> {
const recent = await this.ctx.channelRepository.messages.listMessages(
channelId,
undefined,
THREAD_CREATED_MESSAGE_RECENT_WINDOW,
);
return recent.some((message) => message.id === messageId);
}
async authenticateParent(viewer: ThreadViewer, userId: UserID, channelId: ChannelID): Promise<AuthenticatedChannel> {
return this.ctx.channelAuth.getChannelAuthenticated({userId, channelId, viewer});
}
@@ -101,12 +101,11 @@ describe('DM Privacy Bidirectional Enforcement', () => {
.expect(HTTP_STATUS.OK)
.execute();
});
it('blocks message from a non-friend guild member to a new account by default', async () => {
it('allows message from a non-friend guild member to a new account by default', async () => {
const sender = await createTestAccount(harness);
const target = await createTestAccount(harness);
await ensureSessionStarted(harness, sender.token);
await ensureSessionStarted(harness, target.token);
await updateUserSettings(harness, sender.token, {default_guilds_restricted: false});
await createFriendship(harness, sender, target);
const guild = await createGuild(harness, sender.token, 'Default Community');
const systemChannel = await getChannel(harness, sender.token, guild.system_channel_id!);
@@ -116,8 +115,8 @@ describe('DM Privacy Bidirectional Enforcement', () => {
await removeRelationship(harness, sender.token, target.userId);
await createBuilder(harness, sender.token)
.post(`/channels/${channel.id}/messages`)
.body({content: 'default restricted target'})
.expect(HTTP_STATUS.BAD_REQUEST, 'CANNOT_SEND_MESSAGES_TO_USER')
.body({content: 'default open target'})
.expect(HTTP_STATUS.OK)
.execute();
});
it('blocks message when sender restricts the only mutual guild', async () => {
@@ -268,25 +268,28 @@ describe('thread routes', () => {
expect(member?.flags).toBe(ThreadMemberFlags.HAS_INTERACTED);
});
it('announces threads started from older messages in the parent without moving its last message', async () => {
it('announces threads started from messages above the latest five without moving its last message', async () => {
const s = await setup();
const message = await sendMessage(harness, s.owner.token, s.channelId, 'old news');
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now + 6 * 60 * 1000);
const pushedUp = await sendMessage(harness, s.owner.token, s.channelId, 'old news');
const nearBottom = await sendMessage(harness, s.owner.token, s.channelId, 'recent news');
let latest = nearBottom;
for (let i = 0; i < 4; i++) latest = await sendMessage(harness, s.owner.token, s.channelId, `filler ${i}`);
const dispatch = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
await threadsRequest(harness, s.owner.token)
.post(`/channels/${s.channelId}/messages/${message.id}/threads`)
.body({name: 'Later'})
.expect(201)
.execute();
for (const message of [nearBottom, pushedUp]) {
await threadsRequest(harness, s.owner.token)
.post(`/channels/${s.channelId}/messages/${message.id}/threads`)
.body({name: message.content})
.expect(201)
.execute();
}
const announced = dispatch.mock.calls
.map(([params]) => params.data as MessageResponse)
.filter((data) => data.type === MessageTypes.THREAD_CREATED);
expect(announced).toHaveLength(1);
expect(announced[0]).toMatchObject({channel_id: s.channelId, content: 'Later'});
expect(announced[0]!.message_reference?.channel_id).toBe(message.id);
expect(announced[0]).toMatchObject({channel_id: s.channelId, content: 'old news'});
expect(announced[0]!.message_reference?.channel_id).toBe(pushedUp.id);
const parent = await repository.findUnique(createChannelID(BigInt(s.channelId)));
expect(parent?.lastMessageId?.toString()).toBe(message.id);
expect(parent?.lastMessageId?.toString()).toBe(latest.id);
});
it('refuses a second thread on the same message', async () => {
@@ -274,6 +274,8 @@ export interface UserSettingsRow {
synced_preferences: Nullish<string>;
profile_privacy: Nullish<number>;
default_share_voice_activity: Nullish<boolean>;
privacy_setup_version: Nullish<number>;
privacy_setup_completed_at: Nullish<Date>;
version: number;
}
@@ -513,6 +515,8 @@ export const USER_SETTINGS_COLUMNS = [
'synced_preferences',
'profile_privacy',
'default_share_voice_activity',
'privacy_setup_version',
'privacy_setup_completed_at',
'version',
] as const satisfies ReadonlyArray<keyof UserSettingsRow>;
export const USER_GUILD_SETTINGS_COLUMNS = [
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const BRANDING_ASSET_FIELDS = ['icon_url', 'symbol_url', 'logo_url', 'wordmark_url', 'favicon_url'] as const;
export type BrandingAssetField = (typeof BRANDING_ASSET_FIELDS)[number];
const BRANDING_ASSET_REFERENCE = /^branding\/\d{1,20}\/(?:a_)?[0-9a-f]{8}\.[a-z0-9]{2,5}$/;
const BRANDING_ASSET_PATH = /\/(branding\/\d{1,20}\/(?:a_)?[0-9a-f]{8}\.[a-z0-9]{2,5})$/;
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/u, '');
}
export function isBrandingAssetReference(value: string): boolean {
return BRANDING_ASSET_REFERENCE.test(value);
}
export function brandingAssetReferenceFromOwnUrl(value: string, mediaEndpoint: string): string | null {
const prefix = `${trimTrailingSlashes(mediaEndpoint)}/`;
if (!value.startsWith(prefix)) return null;
const remainder = value.slice(prefix.length);
return isBrandingAssetReference(remainder) ? remainder : null;
}
export function brandingAssetReferenceFromAnyUrl(value: string): string | null {
let url: URL;
try {
url = new URL(value);
} catch {
return null;
}
if ((url.protocol !== 'https:' && url.protocol !== 'http:') || url.search !== '' || url.hash !== '') return null;
return BRANDING_ASSET_PATH.exec(url.pathname)?.[1] ?? null;
}
export function brandingAssetStorageKey(reference: string): string {
const slash = reference.lastIndexOf('/');
const file = reference.slice(slash + 1);
const hash = file.slice(0, file.indexOf('.')).replace(/^a_/u, '');
return `${reference.slice(0, slash)}/${hash}`;
}
export function toStoredBrandingAsset(
value: string | null | undefined,
mediaEndpoint: string,
): string | null | undefined {
if (value === undefined || value === null) return value;
return brandingAssetReferenceFromOwnUrl(value, mediaEndpoint) ?? value;
}
export function resolveBrandingAsset(value: string | null, mediaEndpoint: string): string | null {
if (value === null || !isBrandingAssetReference(value)) return value;
return `${trimTrailingSlashes(mediaEndpoint)}/${value}`;
}
export function mapBrandingAssets<T extends Partial<Record<BrandingAssetField, string | null | undefined>>>(
branding: T,
map: (value: string | null | undefined) => string | null | undefined,
): T {
const next = {...branding};
for (const field of BRANDING_ASSET_FIELDS) {
if (field in next) {
next[field] = map(next[field]) as T[BrandingAssetField];
}
}
return next;
}
@@ -3,6 +3,7 @@
import {spawnSync} from 'node:child_process';
import {createHash} from 'node:crypto';
import {createServer} from 'node:net';
import {Config} from '@app/api/Config';
import type {CassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
@@ -222,6 +223,55 @@ describe('InstanceConfigRepository', () => {
expect(config.branding.product_name).toBe('Kept');
});
it('stores uploaded branding assets as references and resolves them against the current media endpoint', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
const media = Config.endpoints.media;
const foreign = 'https://cdn.example.com/favicon.ico';
await repository.setAppPublicConfig({
branding: {favicon_url: `${media}/branding/0/0123abcd.png`, logo_url: foreign},
});
const stored = JSON.parse((await repository.getConfig(APP_PUBLIC_CONFIG_KEY)) ?? '{}');
expect(stored.branding.favicon_url).toBe('branding/0/0123abcd.png');
expect(stored.branding.logo_url).toBe(foreign);
Config.endpoints.media = 'https://media.moved.example';
try {
const config = await repository.getAppPublicConfig();
expect(config.branding.favicon_url).toBe('https://media.moved.example/branding/0/0123abcd.png');
expect(config.branding.logo_url).toBe(foreign);
} finally {
Config.endpoints.media = media;
}
});
it('normalises legacy branding URLs from an old domain only when the object is ours', async () => {
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
const repository = createRepository(new MockKVProvider());
await repository.setConfig(
APP_PUBLIC_CONFIG_KEY,
JSON.stringify({
branding: {
favicon_url: 'https://old.example/media/branding/0/a_0123abcd.gif',
icon_url: 'https://other.example/branding/0/89abcdef.png',
},
}),
);
const storage = {
getObjectMetadata: vi.fn(async (_bucket: string, key: string) =>
key === 'branding/0/0123abcd' ? {contentLength: 1, contentType: 'image/gif'} : null,
),
};
expect(await repository.normalizeStoredBrandingAssets(storage as never)).toBe(1);
expect(await repository.normalizeStoredBrandingAssets(storage as never)).toBe(0);
const config = await repository.getAppPublicConfig();
expect(config.branding.favicon_url).toBe(`${Config.endpoints.media}/branding/0/a_0123abcd.gif`);
expect(config.branding.icon_url).toBe('https://other.example/branding/0/89abcdef.png');
});
it('keeps valid stored instance policy flags when one field is invalid', async () => {
const executor = new CountingInMemoryCassandraQueryExecutor();
setCassandraQueryExecutorForTesting(executor);
@@ -7,12 +7,23 @@ import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/datab
import {Db, type PreparedQuery} from '@app/api/database/CassandraTypes';
import type {InstanceConfigurationRow} from '@app/api/database/types/InstanceConfigTypes';
import {syncChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import {
type AccountIdentity,
resolveAccountIdentity,
type StoredAccountIdentity,
setCachedAccountIdentity,
} from '@app/api/instance/AccountIdentityModeCache';
import {
BRANDING_ASSET_FIELDS,
type BrandingAssetField,
brandingAssetReferenceFromAnyUrl,
brandingAssetStorageKey,
isBrandingAssetReference,
mapBrandingAssets,
resolveBrandingAsset,
toStoredBrandingAsset,
} from '@app/api/instance/BrandingAssetReferences';
import {
getDefaultDateOfBirthCollection,
setCachedDateOfBirthCollection,
@@ -684,11 +695,26 @@ const StoredInstanceAppPublicSchema = InstanceAppPublicSchema.extend({
});
function parseStoredAppPublicConfig(raw: string | null): InstanceAppPublicConfig {
return buildAppPublicConfig(
salvageStoredConfig(StoredInstanceAppPublicSchema, readStoredConfigValue(raw, 'app public'), 'app public'),
return resolveAppPublicConfig(
buildAppPublicConfig(
salvageStoredConfig(StoredInstanceAppPublicSchema, readStoredConfigValue(raw, 'app public'), 'app public'),
),
);
}
function resolveAppPublicConfig(config: InstanceAppPublicConfig): InstanceAppPublicConfig {
return {
...config,
branding: mapBrandingAssets(config.branding, (value) =>
resolveBrandingAsset(value ?? null, Config.endpoints.media),
),
};
}
function toStoredBranding<T extends Partial<Record<BrandingAssetField, string | null>>>(branding: T): T {
return mapBrandingAssets(branding, (value) => toStoredBrandingAsset(value, Config.endpoints.media));
}
function buildAppPublicConfig(config: z.infer<typeof StoredInstanceAppPublicSchema>): InstanceAppPublicConfig {
const defaults = getDefaultAppPublicConfig();
const {branding = {}, setup = {}, legal = {}, registration = {}} = config;
@@ -1658,8 +1684,8 @@ export class InstanceConfigRepository {
StoredInstanceAppPublicSchema,
{
branding: {
...current.branding,
...(config.branding ?? {}),
...toStoredBranding(current.branding),
...toStoredBranding(config.branding ?? {}),
premium_product_name: premiumProductName,
},
setup: {
@@ -1677,13 +1703,68 @@ export class InstanceConfigRepository {
},
'app public',
);
return {value: JSON.stringify(merged), result: buildAppPublicConfig(merged)};
return {value: JSON.stringify(merged), result: resolveAppPublicConfig(buildAppPublicConfig(merged))};
});
await this.publishRefresh(cache.sourceId);
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
return next;
}
async normalizeStoredBrandingAssets(storageService: IStorageService): Promise<number> {
const cache = this.configCache;
await cache.getSnapshot();
cache.assertActive();
const raw = await this.fetchConfigFromDatabase(APP_PUBLIC_CONFIG_KEY);
if (raw === null) return 0;
const stored = salvageStoredConfig(
StoredInstanceAppPublicSchema,
readStoredConfigValue(raw, 'app public'),
'app public',
);
const rewrites = new Map<string, string>();
for (const field of BRANDING_ASSET_FIELDS) {
const value = stored.branding?.[field];
if (typeof value !== 'string' || isBrandingAssetReference(value) || rewrites.has(value)) continue;
const reference = toStoredBrandingAsset(value, Config.endpoints.media);
if (reference !== value && typeof reference === 'string') {
rewrites.set(value, reference);
continue;
}
const candidate = brandingAssetReferenceFromAnyUrl(value);
if (candidate === null) continue;
const metadata = await storageService.getObjectMetadata(
Config.s3.buckets.cdn,
brandingAssetStorageKey(candidate),
);
if (metadata !== null) {
rewrites.set(value, candidate);
}
}
if (rewrites.size === 0) return 0;
const {result} = await this.compareAndSetStoredValue(cache, APP_PUBLIC_CONFIG_KEY, (current) => {
const latest = salvageStoredConfig(
StoredInstanceAppPublicSchema,
readStoredConfigValue(current, 'app public'),
'app public',
);
if (!latest.branding) return {value: null, result: 0};
let changed = 0;
const branding = mapBrandingAssets(latest.branding, (value) => {
const rewrite = typeof value === 'string' ? rewrites.get(value) : undefined;
if (rewrite === undefined) return value;
changed++;
return rewrite;
});
if (changed === 0) return {value: null, result: 0};
const next = validateStoredConfig(StoredInstanceAppPublicSchema, {...latest, branding}, 'app public');
return {value: JSON.stringify(next), result: changed};
});
if (result > 0) {
await this.publishRefresh(cache.sourceId);
}
return result;
}
async getInstancePolicyConfig(): Promise<InstancePolicyConfig> {
const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY);
return parseStoredInstancePolicyConfig(raw);
+9 -1
View File
@@ -68,6 +68,8 @@ export class UserSettings {
readonly syncedPreferences: string;
readonly profilePrivacy: ProfilePrivacyLevel;
readonly defaultShareVoiceActivity: boolean;
readonly privacySetupVersion: number;
readonly privacySetupCompletedAt: Date | null;
readonly version: number;
constructor(row: UserSettingsRow) {
@@ -115,6 +117,8 @@ export class UserSettings {
this.syncedPreferences = normalizeStoredSyncedPreferences(row.synced_preferences);
this.profilePrivacy = (row.profile_privacy ?? ProfilePrivacyLevels.ALL_GUILDS) as ProfilePrivacyLevel;
this.defaultShareVoiceActivity = row.default_share_voice_activity ?? true;
this.privacySetupVersion = row.privacy_setup_version ?? 0;
this.privacySetupCompletedAt = row.privacy_setup_completed_at ?? null;
this.version = row.version;
}
@@ -171,6 +175,8 @@ export class UserSettings {
synced_preferences: this.syncedPreferences === '' ? null : this.syncedPreferences,
profile_privacy: this.profilePrivacy,
default_share_voice_activity: this.defaultShareVoiceActivity,
privacy_setup_version: this.privacySetupVersion,
privacy_setup_completed_at: this.privacySetupCompletedAt,
version: this.version,
};
}
@@ -216,7 +222,7 @@ export class UserSettings {
friend_source_flags: friendSourceFlags,
incoming_call_flags: IncomingCallFlags.FRIENDS_ONLY,
group_dm_add_permission_flags: GroupDmAddPermissionFlags.FRIENDS_ONLY,
default_guilds_restricted: true,
default_guilds_restricted: false,
bot_default_guilds_restricted: false,
restricted_guilds: new Set(),
bot_restricted_guilds: new Set(),
@@ -244,6 +250,8 @@ export class UserSettings {
synced_preferences: null,
profile_privacy: ProfilePrivacyLevels.ALL_GUILDS,
default_share_voice_activity: true,
privacy_setup_version: 0,
privacy_setup_completed_at: null,
version: 1,
};
}
+135 -6
View File
@@ -308,6 +308,14 @@
"summary": "Complete handoff",
"tags": ["Auth"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {"anyOf": [{"$ref": "#/components/schemas/HandoffCompleteResponse"}, {"type": "null"}]}
}
}
},
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
@@ -340,7 +348,7 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Complete the handoff process and authenticate on the target device using the handoff code.",
"description": "Complete the handoff process and authenticate on the target device using the handoff code. With the deep_link return method, responds with the deep link that hands the one-time grant back to the initiating app. Otherwise responds with no content.",
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HandoffCompleteRequest"}}}
@@ -388,7 +396,11 @@
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking."
"description": "Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking. A desktop app that registers a return deep link can be signed in without the user typing the code.",
"requestBody": {
"required": false,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/HandoffInitiateRequest"}}}
}
}
},
"/auth/handoff/{code}": {
@@ -445,6 +457,56 @@
}
}
},
"/auth/handoff/{code}/deny": {
"post": {
"operationId": "deny_handoff",
"summary": "Deny handoff",
"tags": ["Auth"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Decline a handoff request after looking it up. The initiating device sees the denied status and the code can no longer be approved.",
"parameters": [
{
"name": "code",
"in": "path",
"required": true,
"schema": {"description": "The handoff code", "type": "string"},
"description": "The handoff code"
}
]
}
},
"/auth/handoff/{code}/info": {
"get": {
"operationId": "get_handoff_info",
@@ -24921,6 +24983,12 @@
"type": "boolean",
"description": "Default share_voice_activity applied to new friend relationships"
},
"privacy_setup_version": {
"type": "integer",
"minimum": 1,
"maximum": 1000,
"description": "Version of the privacy setup the user just reviewed. The server also records privacy_setup_completed_at as the current time."
},
"synced_preferences": {
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it.",
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}]
@@ -25074,6 +25142,23 @@
"default_share_voice_activity": {
"type": "boolean",
"description": "Default value of share_voice_activity applied to newly accepted friend relationships. Read-only here; mutated via PUT /users/@me/settings/voice-activity-sharing."
},
"privacy_setup_version": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"description": "Latest privacy setup version the user has reviewed, 0 if never reviewed"
},
"privacy_setup_completed_at": {
"anyOf": [
{
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
},
{"type": "null"}
],
"description": "When the user last completed the privacy setup, or null if never"
}
},
"required": [
@@ -25111,7 +25196,9 @@
"staff_dm_access_user_ids",
"synced_preferences",
"profile_privacy",
"default_share_voice_activity"
"default_share_voice_activity",
"privacy_setup_version",
"privacy_setup_completed_at"
],
"additionalProperties": false
},
@@ -32794,14 +32881,18 @@
"HandoffStatusRequest": {
"type": "object",
"properties": {
"poll_secret": {"description": "The poll secret issued when the handoff was initiated", "type": "string"}
"poll_secret": {"description": "The poll secret issued when the handoff was initiated", "type": "string"},
"grant": {"description": "The one-time grant delivered to the app through the deep link", "type": "string"}
},
"required": ["poll_secret"]
},
"HandoffStatusResponse": {
"type": "object",
"properties": {
"status": {"type": "string", "description": "Current status of the handoff (pending, completed, expired)"},
"status": {
"type": "string",
"description": "Current status of the handoff (pending, completed, denied, expired)"
},
"token": {"description": "Authentication token if handoff is complete", "type": ["string", "null"]},
"user_id": {
"description": "User ID if handoff is complete",
@@ -32842,6 +32933,10 @@
},
{"type": "null"}
]
},
"return_method": {
"description": "How the approving browser hands the sign-in back. deep_link opens the initiating app, code relies on the user comparing the code",
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
}
},
"required": ["status"],
@@ -32854,6 +32949,20 @@
},
"required": ["poll_secret"]
},
"HandoffInitiateRequest": {
"anyOf": [
{
"type": "object",
"properties": {
"return_uri": {
"description": "Deep link the approving browser opens to return the sign-in to the initiating desktop app",
"type": "string"
}
}
},
{"type": "null"}
]
},
"HandoffInitiateResponse": {
"type": "object",
"properties": {
@@ -32867,6 +32976,10 @@
"poll_secret": {
"description": "Secret the initiating device must present to retrieve the token",
"type": "string"
},
"return_method": {
"description": "deep_link when the approving browser will hand the sign-in back through the return deep link, code otherwise",
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
}
},
"required": ["code", "expires_at"],
@@ -32877,10 +32990,25 @@
"properties": {
"code": {"description": "The handoff code from the initiating session", "type": "string"},
"token": {"description": "The authentication token to transfer", "type": "string"},
"user_id": {"description": "The user ID associated with the authenticated session", "type": "string"}
"user_id": {"description": "The user ID associated with the authenticated session", "type": "string"},
"return_method": {
"description": "deep_link returns a one-time grant the initiating app must present, code releases the token to the poll secret alone",
"$ref": "#/components/schemas/DesktopHandoffReturnMethod"
}
},
"required": ["code", "user_id"]
},
"HandoffCompleteResponse": {
"type": "object",
"properties": {
"return_url": {
"type": "string",
"description": "Deep link that returns the sign-in to the initiating app, with its one-time grant"
}
},
"required": ["return_url"],
"additionalProperties": false
},
"ForgotPasswordRequest": {
"type": "object",
"properties": {
@@ -33609,6 +33737,7 @@
},
"PasswordType": {"type": "string"},
"EmailType": {"type": "string"},
"DesktopHandoffReturnMethod": {"type": "string", "enum": ["deep_link", "code"]},
"AuthSessionLocation": {
"type": "object",
"properties": {
@@ -132,6 +132,10 @@ export const AuthRateLimitConfigs = {
bucket: 'auth:handoff:status',
config: {limit: 60, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
AUTH_HANDOFF_DENY: {
bucket: 'auth:handoff:deny',
config: {limit: 10, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
AUTH_HANDOFF_CANCEL: {
bucket: 'auth:handoff:cancel',
config: {limit: 10, windowMs: ms('1 minute')},
+2
View File
@@ -353,6 +353,8 @@ export function mapUserSettingsToResponse(params: {settings: UserSettings}): Use
synced_preferences: settings.syncedPreferences,
profile_privacy: settings.profilePrivacy,
default_share_voice_activity: settings.defaultShareVoiceActivity,
privacy_setup_version: settings.privacySetupVersion,
privacy_setup_completed_at: settings.privacySetupCompletedAt?.toISOString() ?? null,
};
}
@@ -137,6 +137,10 @@ export class UserAccountSettingsService {
if (data.bot_default_guilds_restricted !== undefined) {
updatedRowData.bot_default_guilds_restricted = data.bot_default_guilds_restricted;
}
if (data.privacy_setup_version !== undefined) {
updatedRowData.privacy_setup_version = data.privacy_setup_version;
updatedRowData.privacy_setup_completed_at = new Date();
}
if (data.inline_attachment_media !== undefined) {
updatedRowData.inline_attachment_media = data.inline_attachment_media;
}
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {fetchUserSettings, updateUserSettings} from '@app/api/user/tests/UserTestUtils';
import {beforeEach, describe, expect, test} from 'vitest';
describe('User settings privacy setup', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
test('new accounts allow community DMs and have not reviewed the privacy setup', async () => {
const account = await createTestAccount(harness);
const {json} = await fetchUserSettings(harness, account.token);
expect(json.default_guilds_restricted).toBe(false);
expect(json.privacy_setup_version).toBe(0);
expect(json.privacy_setup_completed_at).toBeNull();
});
test('completing the privacy setup stores the version, the time and the chosen setting in one update', async () => {
const account = await createTestAccount(harness);
const before = Date.now();
const {json} = await updateUserSettings(harness, account.token, {
default_guilds_restricted: true,
privacy_setup_version: 1,
});
expect(json.default_guilds_restricted).toBe(true);
expect(json.privacy_setup_version).toBe(1);
expect(Date.parse(json.privacy_setup_completed_at ?? '')).toBeGreaterThanOrEqual(before - 1000);
const {json: reread} = await fetchUserSettings(harness, account.token);
expect(reread.privacy_setup_version).toBe(1);
expect(reread.privacy_setup_completed_at).toBe(json.privacy_setup_completed_at);
await createBuilder(harness, account.token)
.patch('/users/@me/settings')
.body({privacy_setup_version: 0})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
+8
View File
@@ -294,6 +294,14 @@ export async function startWorkerMain(): Promise<void> {
await startContentBlocklistCaches({kvClient: dependencies.kvClient, storageService: dependencies.storageService});
Logger.info('Content blocklist caches initialised for worker backend');
await queueBlocklistFeedStartupJobs(dependencies.kvClient, workerService, Config.blocklistFeeds.enabled);
try {
const normalized = await getInstanceConfigRepository().normalizeStoredBrandingAssets(dependencies.storageService);
if (normalized > 0) {
Logger.info({normalized}, 'Normalised stored instance branding assets to references');
}
} catch (error) {
Logger.warn({err: error}, 'Failed to normalise stored instance branding assets');
}
setActivityProcessChannel('worker');
await startActivityEvents({
publisher: jetStreamActivityPublisher(jsConnectionManager.getJetStreamClient()),
@@ -569,6 +569,8 @@ export function buildUserDataJson(params: UserDataJsonParams) {
staff_dm_access_user_ids: Array.from(userSettings.staffDmAccessUserIds).map((id) => id.toString()),
synced_preferences: syncedPreferencesToJson(decodeSyncedPreferencesLenient(userSettings.syncedPreferences)),
profile_privacy: userSettings.profilePrivacy,
privacy_setup_version: userSettings.privacySetupVersion,
privacy_setup_completed_at: userSettings.privacySetupCompletedAt?.toISOString() ?? null,
}
: null,
guild_memberships: guildMemberships
+1
View File
@@ -671,6 +671,7 @@ export default () => {
wasmCratesDir: path.join(ROOT_DIR, 'rust'),
}),
new DefinePlugin({
define: 'undefined',
__FLUXER_PRECACHE_MANIFEST__: JSON.stringify([]),
__FLUXER_SW_VERSION__: JSON.stringify(publicValues.PUBLIC_BUILD_VERSION || 'dev'),
'process.env.NODE_ENV': JSON.stringify(mode),
+3 -4
View File
@@ -26,6 +26,7 @@ import GatewayConnection from '@app/features/gateway/transport/GatewayConnection
import GatewaySessions from '@app/features/gateway/transport/GatewaySessionPool';
import {AppI18nProvider} from '@app/features/i18n/components/AppI18nProvider';
import MemberSidebar from '@app/features/member/state/MemberSidebar';
import {attachExternalLinkInterceptor} from '@app/features/messaging/utils/ExternalLinkUtils';
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
import SessionManager from '@app/features/platform/state/AuthSession';
@@ -50,7 +51,7 @@ import MobileLayout from '@app/features/ui/state/MobileLayout';
import Modal from '@app/features/ui/state/Modal';
import Popout from '@app/features/ui/state/Popout';
import {getDesktopWindowBehaviorSettings} from '@app/features/ui/utils/DesktopWindowBehaviorUtils';
import {attachExternalLinkInterceptor, isDesktop} from '@app/features/ui/utils/NativeUtils';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import {UNFOCUSED_FULLY_INTERACTIVE_CLASS} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import UserSettings from '@app/features/user/state/UserSettings';
import {IncomingCallManager} from '@app/features/voice/components/IncomingCallManager';
@@ -93,9 +94,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
() => ({variant: layoutVariant, setVariant: setLayoutVariant}),
[layoutVariant],
);
const popouts = Popout.getPopouts();
const topPopout = popouts.length ? popouts[popouts.length - 1] : null;
const topPopoutRequiresBackdrop = Boolean(topPopout && !topPopout.disableBackdrop);
const topPopoutRequiresBackdrop = Popout.requiresBackdrop();
const hasBlockingModal = Modal.hasModalOpen();
const room = MediaEngine.room;
const ringsContainerRef = useRef<HTMLDivElement>(null);
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {getDocumentFaviconUrl} from '@app/features/app/state/RuntimeDocumentBranding';
import DesktopBackgroundGateway from '@app/features/gateway/transport/DesktopBackgroundGateway';
import GuildReadState from '@app/features/guild/state/GuildReadState';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -18,14 +19,40 @@ const logger = new Logger('AppBadge');
const UNREAD_INDICATOR = -1;
let favico: Favico | null = null;
let favicoLink: HTMLLinkElement | null = null;
let favicoLinkHref: string | null = null;
let favicoSource: string | null = null;
const findIconLink = (): HTMLLinkElement | null => {
const links = document.head.querySelectorAll<HTMLLinkElement>('link');
for (let index = links.length - 1; index >= 0; index--) {
if (/(^|\s)icon(\s|$)/i.test(links[index].getAttribute('rel') ?? '')) {
return links[index];
}
}
return null;
};
const releaseFavico = (): void => {
if (favicoLink !== null && favicoLinkHref !== null && favicoLink.getAttribute('href')?.startsWith('data:')) {
favicoLink.setAttribute('href', favicoLinkHref);
}
favico = null;
favicoLink = null;
favicoLinkHref = null;
};
const initFavico = (): Favico | null => {
if (favico) return favico;
try {
favico = new Favico({animation: 'none'});
const link = findIconLink();
favicoLink = link;
favicoLinkHref = link?.getAttribute('href') ?? null;
favico = new Favico(link === null ? {animation: 'none'} : {animation: 'none', element: link});
return favico;
} catch (e) {
logger.warn('Failed to initialize Favico', e);
releaseFavico();
return null;
}
};
@@ -39,7 +66,11 @@ const setElectronBadge = (badge: number): void => {
logger.warn('Failed to set Electron badge', e);
}
};
const setFaviconBadge = (badge: number): void => {
const setFaviconBadge = (badge: number, source: string | null): void => {
if (source !== favicoSource) {
releaseFavico();
favicoSource = source;
}
if (badge === 0 && favico === null) return;
const fav = initFavico();
if (!fav) return;
@@ -69,9 +100,9 @@ const setPwaBadge = (badge: number): void => {
logger.warn('Failed to set PWA badge', e);
}
};
const setBadge = (badge: number): void => {
const setBadge = (badge: number, faviconSource: string | null): void => {
setElectronBadge(badge);
setFaviconBadge(badge);
setFaviconBadge(badge, faviconSource);
setPwaBadge(badge);
};
export const AppBadge: React.FC = observer(() => {
@@ -89,15 +120,16 @@ export const AppBadge: React.FC = observer(() => {
} else if (hasUnread && unreadMessageBadgeEnabled) {
badge = UNREAD_INDICATOR;
}
const faviconSource = getDocumentFaviconUrl(RuntimeConfig.getSnapshotOrNull()?.appPublic ?? null);
useEffect(() => {
setBadge(badge);
}, [badge]);
setBadge(badge, faviconSource);
}, [badge, faviconSource]);
useEffect(() => {
updateDocumentTitleBadge(totalCount, hasUnread && unreadMessageBadgeEnabled);
}, [totalCount, hasUnread, unreadMessageBadgeEnabled]);
useEffect(() => {
return () => {
setBadge(0);
setBadge(0, favicoSource);
updateDocumentTitleBadge(0, false);
};
}, []);
@@ -139,8 +139,10 @@ export const FeatureComparisonTable = observer(() => {
<ComparisonCheckRow
key={perk.id}
feature={label}
restrictedHas={perk.restrictedValue}
stockHas={perk.stockValue}
restrictedHas={
perk.limitKey ? Limits.hasRestrictedFeature(perk.limitKey, perk.restrictedValue) : perk.restrictedValue
}
stockHas={perk.limitKey ? Limits.hasStockFeature(perk.limitKey, perk.stockValue) : perk.stockValue}
data-flx="app.feature-comparison-table.render-perk-row.comparison-check-row"
/>
);
@@ -157,9 +159,11 @@ export const FeatureComparisonTable = observer(() => {
);
}
if (isTextTierPerk(perk)) {
const restrictedLabel =
const lowLabel =
perkLabels[perk.restrictedValueI18nKey as keyof typeof perkLabels] || perk.restrictedValueI18nKey;
const stockLabel = perkLabels[perk.stockValueI18nKey as keyof typeof perkLabels] || perk.stockValueI18nKey;
const highLabel = perkLabels[perk.stockValueI18nKey as keyof typeof perkLabels] || perk.stockValueI18nKey;
const restrictedLabel = perk.limitKey && Limits.hasRestrictedFeature(perk.limitKey, false) ? highLabel : lowLabel;
const stockLabel = !perk.limitKey || Limits.hasStockFeature(perk.limitKey, true) ? highLabel : lowLabel;
return (
<ComparisonRow
key={perk.id}
@@ -31,6 +31,25 @@
line-height: 0.875rem;
}
.instanceHeadingMark {
display: inline-flex;
flex: 0 0 auto;
width: 0.75rem;
height: 0.75rem;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 0.1875rem;
}
.instanceHeadingMark svg,
.instanceHeadingMark img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.instanceHeadingLabel {
min-width: 0;
overflow: hidden;
@@ -9,6 +9,9 @@ import {
} from '@app/features/auth/components/accounts/AccountIdentity';
import {getAccountMentionCount} from '@app/features/auth/components/accounts/account_switcher_modal/AccountMentionCounts';
import {SESSION_EXPIRED_DESCRIPTOR} from '@app/features/auth/components/accounts/account_switcher_modal/AccountPickerShared';
import {InstanceBrandMark} from '@app/features/auth/components/InstanceBrandMark';
import {resolveInstanceLabel} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
import {resolveInstanceBrandIconUrl, resolveInstanceProductName} from '@app/features/auth/InstanceBranding';
import {getAccountKey} from '@app/features/auth/state/AccountStorageKey';
import {ACTIVE_ACCOUNT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import type {Account} from '@app/features/platform/state/AuthSession';
@@ -18,6 +21,7 @@ import {MentionBadge} from '@app/features/ui/components/MentionBadge';
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {flxElementClassName} from '@app/lib/react';
import {isOfficialInstanceHost} from '@fluxer/instance_bootstrap/src/OfficialInstance';
import {Trans, useLingui} from '@lingui/react/macro';
import {CheckIcon, GearIcon} from '@phosphor-icons/react';
import clsx from 'clsx';
@@ -30,6 +34,8 @@ const UNKNOWN_INSTANCE_GROUP_KEY = 'unknown';
interface AccountInstanceGroup {
readonly groupKey: string;
readonly instanceLabel: string | null;
readonly isOfficial: boolean;
readonly iconUrl: string | null;
readonly accounts: Array<Account>;
}
@@ -46,7 +52,13 @@ function createAccountInstanceGroups(accounts: ReadonlyArray<Account>): Array<Ac
const instanceKey = resolveAccountInstanceKey(account);
if (instanceKey == null) {
if (unknownGroup == null) {
unknownGroup = {groupKey: UNKNOWN_INSTANCE_GROUP_KEY, instanceLabel: null, accounts: []};
unknownGroup = {
groupKey: UNKNOWN_INSTANCE_GROUP_KEY,
instanceLabel: null,
isOfficial: false,
iconUrl: null,
accounts: [],
};
}
unknownGroup.accounts.push(account);
continue;
@@ -54,7 +66,16 @@ function createAccountInstanceGroups(accounts: ReadonlyArray<Account>): Array<Ac
const groupKey = `instance:${instanceKey}`;
let group = groupsByKey.get(groupKey);
if (group == null) {
group = {groupKey, instanceLabel: resolveAccountInstanceLabel(account), accounts: []};
const domain = resolveAccountInstanceLabel(account);
const isOfficial = isOfficialInstanceHost(instanceKey);
const productName = isOfficial ? null : resolveInstanceProductName(account.instance);
group = {
groupKey,
instanceLabel: domain == null ? null : resolveInstanceLabel(productName, domain),
isOfficial,
iconUrl: isOfficial ? null : resolveInstanceBrandIconUrl(account.instance),
accounts: [],
};
groupsByKey.set(groupKey, group);
groups.push(group);
}
@@ -202,6 +223,17 @@ export const UserAreaAccountSwitchMenu = observer(
className={flxElementClassName(styles.instanceHeading)}
data-flx="app.floating.user-area-account-switch-menu.instance-heading"
>
<span
className={styles.instanceHeadingMark}
data-flx="app.floating.user-area-account-switch-menu.instance-heading-mark"
>
<InstanceBrandMark
isOfficial={group.isOfficial}
iconUrl={group.iconUrl}
size={12}
data-flx="app.floating.user-area-account-switch-menu.instance-brand-mark"
/>
</span>
<span
className={styles.instanceHeadingLabel}
data-flx="app.floating.user-area-account-switch-menu.instance-heading-label"
@@ -16,6 +16,7 @@ import {GuildHeaderBottomSheet} from '@app/features/guild/components/bottomsheet
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
import {GuildHeaderPopout} from '@app/features/guild/components/popouts/GuildHeaderPopout';
import type {Guild} from '@app/features/guild/models/Guild';
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import MobileLayout from '@app/features/ui/state/MobileLayout';
@@ -74,6 +75,7 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui
[guild],
);
const guildNameRef = useRef<HTMLSpanElement | null>(null);
const bannerImageURL = useRecoveringBackgroundImageURL(banner.imageUrl ?? '');
const badgeVisible = resolveGuildBadgeVisible(guild.features);
const bannerPlacement = resolveSkeletonBannerPlacement(banner.hasBanner, banner.isDetached);
useSkeletonLayoutReport(() => {
@@ -98,7 +100,7 @@ export const GuildHeader = observer(({guild, banner}: {guild: Guild; banner: Gui
style={{
height: banner.bannerHeight,
y: banner.imageShift,
backgroundImage: `url(${banner.imageUrl})`,
backgroundImage: bannerImageURL == null ? undefined : `url(${bannerImageURL})`,
}}
data-flx="app.guild-header.banner-image"
/>
@@ -160,14 +160,9 @@
align-items: flex-end;
padding: 0 var(--footer-box-inset-inline) var(--user-area-box-inset-block-end);
background-color: var(--background-secondary);
pointer-events: none;
z-index: var(--z-index-elevated-1);
}
.userAreaWrapper > * {
pointer-events: auto;
}
.guildListItem {
position: relative;
display: flex;
@@ -206,6 +201,7 @@
color 70ms ease-out;
container-type: size;
--initials-font-size: clamp(0.85rem, 45cqi, 1.35rem);
--initials-fit-inline-size: 92cqi;
--initials-letter-spacing: 0px;
--initials-scale: 1;
}
@@ -259,7 +255,10 @@
line-height: 1;
color: inherit;
font-weight: 600;
font-size: var(--initials-font-size);
font-size: min(
var(--initials-font-size),
calc(var(--initials-fit-inline-size) / (var(--initials-width-em, 0.01) * var(--initials-scale)))
);
letter-spacing: var(--initials-letter-spacing);
transform: scale(var(--initials-scale));
transform-origin: center;
@@ -271,12 +270,6 @@
--initials-scale: 0.92;
}
.guildIcon[data-initials-length='long'] .guildIconInitials {
--initials-font-size: clamp(0.85rem, 32cqi, 0.87rem);
--initials-letter-spacing: 0px;
--initials-scale: 0.78;
}
.guildIndicator {
position: absolute;
left: var(--guild-list-indicator-inset);
@@ -1278,11 +1278,11 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
[NagbarType.LEGACY_PRICE_OPT_IN]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.DESKTOP_DOWNLOAD]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.DESKTOP_UPDATE_READY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.GUILD_MEMBERSHIP_CTA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.VISIONARY_MFA]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.VOICE_SESSION_RESTORE]: {tone: SkeletonNagbarTone.VOICE, hasActions: true},
[NagbarType.TERMS_ACCEPTANCE]: {tone: SkeletonNagbarTone.LEGAL, hasActions: true},
[NagbarType.PRIVACY_SETUP]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.SOFTWARE_ENCODER]: {tone: SkeletonNagbarTone.ENCODER, hasActions: true},
[NagbarType.STREAMER_MODE]: {tone: SkeletonNagbarTone.STREAMER, hasActions: true},
[NagbarType.DOMAIN_MOVED]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import {CHANNEL_TEXTAREA_SELECTOR} from '@app/features/app/keybindings/utils/EditableElement';
import {
CHANNEL_TEXTAREA_SELECTOR,
isChannelTextareaElement,
isEditableElement,
} from '@app/features/app/keybindings/utils/EditableElement';
import {useLocation} from '@app/features/platform/components/router/RouterReact';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
@@ -29,6 +33,7 @@ const canRedirectTabToComposer = (composer: HTMLTextAreaElement | null): compose
if (composer == null || composer.disabled || composer.getAttribute('aria-disabled') === 'true') return false;
if (isElementInert(composer)) return false;
const active = document.activeElement;
if (isEditableElement(active) && !isChannelTextareaElement(active)) return false;
return !(active instanceof Element && active.closest(FOCUS_TRAPPING_OVERLAY_SELECTOR) != null);
};
@@ -12,7 +12,6 @@ import DomainMovedNotice from '@app/features/app/domain_migration/DomainMovedNot
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 Updater from '@app/features/app/state/Updater';
import Accounts from '@app/features/auth/state/Accounts';
import Authentication from '@app/features/auth/state/Authentication';
import Channels from '@app/features/channel/state/Channels';
@@ -31,7 +30,9 @@ import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
import Nagbar from '@app/features/ui/state/Nagbar';
import {hasUnavailableElectronNativeContext, isDesktop} from '@app/features/ui/utils/NativeUtils';
import {isStandalonePwa} from '@app/features/ui/utils/PwaUtils';
import {PRIVACY_SETUP_VERSION} from '@app/features/user/constants/PrivacySetupConstants';
import StatusPage from '@app/features/user/state/StatusPage';
import UserSettings from '@app/features/user/state/UserSettings';
import Users from '@app/features/user/state/Users';
import MediaEngine, {useVoiceEngineV2Model} from '@app/features/voice/engine/MediaEngineFacade';
import {selectVoiceEngineV2AppConnectionWithFallback} from '@app/features/voice/engine/v2/VoiceEngineV2AppSelectors';
@@ -259,7 +260,6 @@ export const useNagbarConditions = (): NagbarConditions => {
})();
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
const canShowDesktopUpdateReady = Updater.shouldShowUpdateReadyNagbar;
const canShowDomainMoved = nagbarState.forceHideDomainMoved
? false
: nagbarState.forceDomainMoved
@@ -287,6 +287,13 @@ export const useNagbarConditions = (): NagbarConditions => {
(!user.privacyAgreedAt || user.privacyAgreedAt.toISOString() < PRIVACY_POLICY_LAST_UPDATED);
return termsOutdated || privacyOutdated;
})();
const needsPrivacySetup = (() => {
if (nagbarState.forceHidePrivacySetup) return false;
if (nagbarState.forcePrivacySetup) return true;
if (!user || !UserSettings.isHydrated()) return false;
const privacySetupVersion = UserSettings.getPrivacySetupVersion();
return privacySetupVersion !== null && privacySetupVersion < PRIVACY_SETUP_VERSION;
})();
return {
canShowBuildEnvironment,
canShowConnection,
@@ -327,9 +334,9 @@ export const useNagbarConditions = (): NagbarConditions => {
canShowVisionaryMfa,
canShowVoiceSessionRestore,
needsTermsAcceptance,
needsPrivacySetup,
canShowSoftwareEncoder,
canShowStreamerMode,
canShowDesktopUpdateReady,
canShowDomainMoved,
};
};
@@ -360,6 +367,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
visible: conditions.needsTermsAcceptance,
dismissible: false,
},
{
type: NagbarType.PRIVACY_SETUP,
priority: -2.75,
visible: conditions.needsPrivacySetup,
dismissible: false,
},
{
type: NagbarType.SCHEDULED_MAINTENANCE,
priority: -1,
@@ -462,12 +475,6 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
visible: conditions.canShowStreamerMode,
dismissible: true,
},
{
type: NagbarType.DESKTOP_UPDATE_READY,
priority: -1.5,
visible: conditions.canShowDesktopUpdateReady,
dismissible: true,
},
{
type: NagbarType.DOMAIN_MOVED,
priority: 3,
@@ -18,11 +18,11 @@ export const NagbarType = {
LEGACY_PRICE_OPT_IN: 'legacy-price-opt-in',
GIFT_INVENTORY: 'gift-inventory',
DESKTOP_DOWNLOAD: 'desktop-download',
DESKTOP_UPDATE_READY: 'desktop-update-ready',
GUILD_MEMBERSHIP_CTA: 'guild-membership-cta',
VISIONARY_MFA: 'visionary-mfa',
VOICE_SESSION_RESTORE: 'voice-session-restore',
TERMS_ACCEPTANCE: 'terms-acceptance',
PRIVACY_SETUP: 'privacy-setup',
SOFTWARE_ENCODER: 'software-encoder',
STREAMER_MODE: 'streamer-mode',
DOMAIN_MOVED: 'domain-moved',
@@ -57,11 +57,11 @@ export interface NagbarConditions {
canShowLegacyPriceOptIn: boolean;
canShowGiftInventory: boolean;
canShowDesktopDownload: boolean;
canShowDesktopUpdateReady: boolean;
canShowGuildMembershipCta: boolean;
canShowVisionaryMfa: boolean;
canShowVoiceSessionRestore: boolean;
needsTermsAcceptance: boolean;
needsPrivacySetup: boolean;
canShowSoftwareEncoder: boolean;
canShowStreamerMode: boolean;
canShowDomainMoved: boolean;
@@ -8,7 +8,6 @@ import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/n
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';
import {DesktopUpdateReadyNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopUpdateReadyNagbar';
import {DomainMovedNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DomainMovedNagbar';
import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
@@ -18,6 +17,7 @@ import {PremiumExpiredNagbar} from '@app/features/app/components/layout/app_layo
import {PremiumGracePeriodNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumGracePeriodNagbar';
import {PremiumOnboardingNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumOnboardingNagbar';
import {PriceAnnouncementNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PriceAnnouncementNagbar';
import {PrivacySetupNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PrivacySetupNagbar';
import {ScheduledMaintenanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ScheduledMaintenanceNagbar';
import {StreamerModeNagbar} from '@app/features/app/components/layout/app_layout/nagbars/StreamerModeNagbar';
import {TermsAcceptanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/TermsAcceptanceNagbar';
@@ -81,6 +81,14 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
data-flx="app.app-layout.nagbar-container.terms-acceptance-nagbar"
/>
);
case NagbarType.PRIVACY_SETUP:
return (
<PrivacySetupNagbar
key={nagbar.type}
isMobile={mobileLayout.enabled}
data-flx="app.app-layout.nagbar-container.privacy-setup-nagbar"
/>
);
case NagbarType.SCHEDULED_MAINTENANCE:
return (
<ScheduledMaintenanceNagbar
@@ -183,14 +191,6 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
data-flx="app.app-layout.nagbar-container.desktop-download-nagbar"
/>
);
case NagbarType.DESKTOP_UPDATE_READY:
return (
<DesktopUpdateReadyNagbar
key={nagbar.type}
isMobile={mobileLayout.enabled}
data-flx="app.app-layout.nagbar-container.desktop-update-ready-nagbar"
/>
);
case NagbarType.GUILD_MEMBERSHIP_CTA:
return (
<GuildMembershipCtaNagbar
@@ -1,50 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
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 Updater from '@app/features/app/state/Updater';
import {
DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
RESTART_FLUXER_DESCRIPTOR,
THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR,
} from '@app/features/updater/commands/UpdaterModalCommands';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
export const DesktopUpdateReadyNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const {i18n} = useLingui();
const version = Updater.updateInfo.native.version;
return (
<Nagbar
isMobile={isMobile}
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
dismissible
onDismiss={Updater.dismissUpdateReadyNagbar}
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar"
>
<NagbarContent
isMobile={isMobile}
onDismiss={Updater.dismissUpdateReadyNagbar}
message={
version
? i18n._(DESKTOP_VERSION_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {version, productName: PRODUCT_NAME})
: i18n._(THE_DESKTOP_UPDATE_HAS_BEEN_DOWNLOADED_DESCRIPTOR, {productName: PRODUCT_NAME})
}
actions={
<NagbarButton
isMobile={isMobile}
onClick={() => void Updater.applyUpdate()}
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.restart-button"
>
{i18n._(RESTART_FLUXER_DESCRIPTOR, {productName: PRODUCT_NAME})}
</NagbarButton>
}
data-flx="app.app-layout.nagbars.desktop-update-ready-nagbar.nagbar-content"
/>
</Nagbar>
);
});
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
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 * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {PrivacySetupModal} from '@app/features/user/components/modals/PrivacySetupModal';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback} from 'react';
const PRIVACY_SETUP_MESSAGE_DESCRIPTOR = msg({
message: 'Choose who can send you direct messages.',
comment: 'Nagbar body asking the user to review who can send them direct messages.',
});
const REVIEW_PRIVACY_SETTINGS_DESCRIPTOR = msg({
message: 'Review privacy settings',
comment: 'Button label on the privacy setup nagbar. Opens the privacy setup modal.',
});
export const PrivacySetupNagbar = observer(({isMobile}: {isMobile: boolean}) => {
const {i18n} = useLingui();
const handleOpen = useCallback(() => {
ModalCommands.push(
modal(() => (
<PrivacySetupModal data-flx="app.app-layout.nagbars.privacy-setup-nagbar.handle-open.privacy-setup-modal" />
)),
);
}, []);
return (
<Nagbar
isMobile={isMobile}
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
data-flx="app.app-layout.nagbars.privacy-setup-nagbar.nagbar"
>
<NagbarContent
isMobile={isMobile}
message={i18n._(PRIVACY_SETUP_MESSAGE_DESCRIPTOR)}
actions={
<NagbarButton
isMobile={isMobile}
onClick={handleOpen}
data-flx="app.app-layout.nagbars.privacy-setup-nagbar.nagbar-button.open"
>
{i18n._(REVIEW_PRIVACY_SETTINGS_DESCRIPTOR)}
</NagbarButton>
}
data-flx="app.app-layout.nagbars.privacy-setup-nagbar.nagbar-content"
/>
</Nagbar>
);
});
@@ -29,6 +29,7 @@ import GuildReadState from '@app/features/guild/state/GuildReadState';
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
import {MENTION_COUNT_ARIA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
import {useLocation} from '@app/features/platform/components/router/RouterReact';
import Theme from '@app/features/theme/state/Theme';
import {Edge} from '@app/features/ui/AxisOrientation';
@@ -766,8 +767,8 @@ interface MiniGuildIconProps {
}
const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false);
if (iconURL !== '') {
const iconURL = useRecoveringBackgroundImageURL(AvatarSourceUtils.getGuildIconURL(guild, false));
if (iconURL != null) {
return (
<flx-app-mini-guild-icon
className={flxElementClassName(styles.miniGuildIcon)}
@@ -14,6 +14,7 @@ import Guilds from '@app/features/guild/state/Guilds';
import {
type GuildInitialsLength,
getGuildIconDisplayInitials,
getGuildInitialsFitStyle,
getInitialsFromName,
getInitialsLength,
truncateInitials,
@@ -57,11 +58,11 @@ function resolveGuildListItemMotion(useReducedMotion: boolean): GuildListItemMot
return STANDARD_GUILD_LIST_ITEM_MOTION;
}
function resolveInitialsLength(rawInitials: string): GuildInitialsLength | null {
if (rawInitials.length === 0) {
function resolveInitialsLength(displayInitials: string): GuildInitialsLength | null {
if (displayInitials.length === 0) {
return null;
}
return getInitialsLength(rawInitials);
return getInitialsLength(displayInitials);
}
function resolveDraggingCursor(dragAndDrop: GuildListItemDragAndDrop | null): 'grabbing' | null {
@@ -303,8 +304,8 @@ export const GuildListItemPresentation = forwardRef<
forwardedRef,
) {
const rootRef = useMergeRefs([surfaceRef, forwardedRef]);
const rawInitials = getInitialsFromName(guild.name);
const initialsLength = resolveInitialsLength(rawInitials);
const displayInitials = getGuildIconDisplayInitials(getInitialsFromName(guild.name));
const initialsLength = resolveInitialsLength(displayInitials);
const dropIndicator = resolveDropIndicator(dragAndDrop);
const motion = resolveGuildListItemMotion(Accessibility.useReducedMotion);
const draggingCursor = resolveDraggingCursor(dragAndDrop);
@@ -378,9 +379,10 @@ export const GuildListItemPresentation = forwardRef<
{!hasPaintedIcon && (
<span
className={styles.guildIconInitials}
style={getGuildInitialsFitStyle(displayInitials)}
data-flx="app.sidebar-nav.guild-list-item-presentation.guild-icon-initials"
>
{getGuildIconDisplayInitials(rawInitials)}
{displayInitials}
</span>
)}
</GuildListItemIcon>
@@ -6,6 +6,7 @@ import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHo
import {useHover} from '@app/features/app/hooks/useHover';
import type {Guild} from '@app/features/guild/models/Guild';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import {useRecoveringBackgroundImageURL} from '@app/features/messaging/hooks/useImageRecovery';
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
@@ -48,8 +49,8 @@ function resolveDisplayedGuildIconURL(query: DisplayedGuildIconURLQuery): string
return query.iconURL;
}
function resolveGuildIconBackgroundImage(iconURL: string, displayedIconURL: string): string | null {
if (iconURL.length === 0) {
function resolveGuildIconBackgroundImage(paintableIconURL: string | null, displayedIconURL: string): string | null {
if (paintableIconURL == null) {
return null;
}
return `url(${displayedIconURL})`;
@@ -75,7 +76,7 @@ export function useGuildListItemInteraction({
const isAnimatableIcon = hoverIconURL !== iconURL;
const [loadedAnimatedURL, setLoadedAnimatedURL] = useState<string | null>(null);
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
useEffect(() => ImageCacheUtils.pinImage(iconURL), [iconURL]);
const paintableIconURL = useRecoveringBackgroundImageURL(iconURL);
useEffect(() => {
if (!isAnimatableIcon) return;
if (!isHovering && !contextMenuOpen) return;
@@ -121,7 +122,7 @@ export function useGuildListItemInteraction({
const handleCloseBottomSheet = useCallback(() => setBottomSheetOpen(false), []);
const shouldPlayAnimated = isAnimatableIcon && (isHovering || contextMenuOpen) && loadedAnimatedURL === hoverIconURL;
const displayedIconURL = resolveDisplayedGuildIconURL({hoverIconURL, iconURL, shouldPlayAnimated});
const backgroundImage = resolveGuildIconBackgroundImage(iconURL, displayedIconURL);
const backgroundImage = resolveGuildIconBackgroundImage(paintableIconURL, displayedIconURL);
return {
hoverRef,
isHovering,
@@ -22,6 +22,7 @@ export const Endpoints = {
AUTH_HANDOFF_INFO: (code: string) => `/auth/handoff/${code}/info`,
AUTH_HANDOFF_STATUS: (code: string) => `/auth/handoff/${code}/status`,
AUTH_HANDOFF_CANCEL: (code: string) => `/auth/handoff/${code}`,
AUTH_HANDOFF_DENY: (code: string) => `/auth/handoff/${code}/deny`,
AUTH_PASSKEY_BRIDGE: '/auth/passkey-bridge',
AUTH_PASSKEY_BRIDGE_OPTIONS: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/options`,
AUTH_PASSKEY_BRIDGE_COMPLETE: (ceremonyId: string) => `/auth/passkey-bridge/${ceremonyId}/complete`,
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/ProductConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
export function getActiveInstanceProductName(): string {
return RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.product_name?.trim() || PRODUCT_NAME;
}
@@ -1,14 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DEFAULT_APP_SHELL_BRANDING} from '@app/features/app/state/AppShellBranding';
import {setDocumentTitleProductName} from '@app/features/window/hooks/useFluxerDocumentTitle';
import type {InstanceAppPublic} from '@fluxer/instance_bootstrap/src/Types';
let lastProductName = DEFAULT_APP_SHELL_BRANDING.productName;
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
}
function brandedLink(rel: string): HTMLLinkElement | null {
return document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"][data-fluxer-branding="true"]`);
}
@@ -28,6 +23,22 @@ function removeLink(rel: string): void {
brandedLink(rel)?.remove();
}
function suspendDefaultLinks(rel: string): void {
for (const link of document.head.querySelectorAll<HTMLLinkElement>(
`link[rel="${rel}"]:not([data-fluxer-branding])`,
)) {
link.dataset.fluxerDefaultRel = rel;
link.rel = '';
}
}
function restoreDefaultLinks(rel: string): void {
for (const link of document.head.querySelectorAll<HTMLLinkElement>(`link[data-fluxer-default-rel="${rel}"]`)) {
link.rel = rel;
delete link.dataset.fluxerDefaultRel;
}
}
function brandedMeta(name: string): HTMLMetaElement | null {
return document.head.querySelector<HTMLMetaElement>(`meta[name="${name}"][data-fluxer-branding="true"]`);
}
@@ -47,25 +58,16 @@ function removeMeta(name: string): void {
brandedMeta(name)?.remove();
}
function replaceTitleProductName(nextProductName: string): void {
if (lastProductName === nextProductName) return;
const prefix = new RegExp(`^(\\(\\d+\\)\\s+|\\u2022\\s+)?${escapeRegExp(lastProductName)}(?=$| \\| )`, 'u');
if (prefix.test(document.title)) {
document.title = document.title.replace(prefix, (_match, notificationPrefix: string | undefined) => {
return `${notificationPrefix ?? ''}${nextProductName}`;
});
}
}
function applyDocumentBranding(productName: string, faviconUrl: string | null, themeColor: string | null): void {
if (typeof document === 'undefined') return;
replaceTitleProductName(productName);
lastProductName = productName;
setDocumentTitleProductName(productName);
setMeta('application-name', productName);
setMeta('apple-mobile-web-app-title', productName);
if (faviconUrl === null) {
removeLink('icon');
restoreDefaultLinks('icon');
} else {
suspendDefaultLinks('icon');
setLink('icon', faviconUrl);
}
if (themeColor === null) {
@@ -75,10 +77,15 @@ function applyDocumentBranding(productName: string, faviconUrl: string | null, t
}
}
export function getDocumentFaviconUrl(appPublic: InstanceAppPublic | null): string | null {
if (appPublic === null) return DEFAULT_APP_SHELL_BRANDING.faviconUrl;
return appPublic.branding.favicon_url ?? appPublic.branding.icon_url;
}
export function applyRuntimeDocumentBranding(appPublic: InstanceAppPublic): void {
applyDocumentBranding(
appPublic.branding.product_name,
appPublic.branding.favicon_url ?? appPublic.branding.icon_url,
getDocumentFaviconUrl(appPublic),
appPublic.branding.theme_color,
);
}
@@ -86,7 +93,7 @@ export function applyRuntimeDocumentBranding(appPublic: InstanceAppPublic): void
export function applyDefaultAppShellDocumentBranding(): void {
applyDocumentBranding(
DEFAULT_APP_SHELL_BRANDING.productName,
DEFAULT_APP_SHELL_BRANDING.faviconUrl,
getDocumentFaviconUrl(null),
DEFAULT_APP_SHELL_BRANDING.themeColor,
);
}
+120 -223
View File
@@ -2,15 +2,12 @@
import Config from '@app/features/app/config/Config';
import {DESKTOP_DOWNLOAD_URL} from '@app/features/app/config/I18nDisplayConstants';
import {
shouldShowNativeDesktopUpdateDownloadProgress,
shouldShowNativeDesktopUpdateInApp,
} from '@app/features/app/utils/UpdaterPlatformUtils';
import {WORKER_NAVIGATION_CACHE_PREFIX} from '@app/features/platform/service_worker/WorkerCacheCleanup';
import {getProtectedCacheStorage} from '@app/features/platform/state/ProtectedWebStorage';
import type {UpdaterContext, UpdaterDownloadOption, UpdaterEvent} from '@app/features/platform/types/Electron';
import {Logger} from '@app/features/platform/utils/AppLogger';
import {getClientInfo} from '@app/features/platform/utils/ClientInfo';
import {flushPendingPersistWrites} from '@app/features/platform/utils/MobXPersistence';
import {initializeStore} from '@app/features/platform/utils/StoreInitialization';
import {
downloadWithNative,
@@ -20,13 +17,12 @@ import {
openExternalUrl,
} from '@app/features/ui/utils/NativeUtils';
import {
pushDesktopUpdateAvailableModal,
pushDesktopUpdateDownloadFailedModal,
pushDesktopUpdateInstallFailedModal,
pushManualUpdateAvailableModal,
pushUnsupportedUpdateModal,
pushUpdateAvailableModal,
pushUpdateCheckFailedModal,
pushUpdateReadyModal,
pushUpToDateModal,
} from '@app/features/updater/commands/UpdaterModalCommands';
import {
@@ -35,7 +31,6 @@ import {
getUpdaterMachineStateValue,
getUpdaterUpdateType,
hasManualNativeDownload,
type NativeDownloadProgress,
type NativeUpdateInfo,
transitionUpdaterMachineSnapshot,
type UpdateInfo,
@@ -47,16 +42,9 @@ import {
} from '@app/features/updater/state/UpdaterStateMachine';
import {buildLinuxManualUpdateOptions} from '@app/features/updater/utils/LinuxManualUpdateOptions';
import type {UpdaterEvent as NativeUpdaterEvent} from '@app/types/electron.d';
import type {DesktopPendingModuleUpdate} from '@fluxer/desktop_ipc/src/ModuleContract';
import {msg} from '@lingui/core/macro';
import {makeAutoObservable, runInAction} from 'mobx';
export type {NativeDownloadProgress, NativeUpdateInfo, UpdateInfo, UpdaterState, UpdateType, WebUpdateInfo};
export const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
message: 'Downloading desktop update…',
comment: 'Short desktop updater status label.',
});
export type {NativeUpdateInfo, UpdateInfo, UpdaterState, UpdateType, WebUpdateInfo};
const logger = new Logger('Updater');
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
@@ -109,32 +97,15 @@ function normalizeUpdaterEvent(event: NativeUpdaterEvent): UpdaterEvent | null {
type: 'available',
context,
version: event.version ?? null,
downloadSize: event.downloadSize ?? null,
downloadStarted: event.downloadStarted ?? true,
downloadUrl: event.downloadUrl,
downloadOptions: event.downloadOptions,
};
case 'not-available':
return {type: 'not-available', context};
case 'downloaded':
return {type: 'downloaded', context, version: event.version ?? null};
return {type: 'downloaded', context};
case 'progress':
if (
typeof event.percent !== 'number' ||
typeof event.transferred !== 'number' ||
typeof event.total !== 'number' ||
typeof event.bytesPerSecond !== 'number'
) {
return null;
}
return {
type: 'progress',
context,
percent: event.percent,
transferred: event.transferred,
total: event.total,
bytesPerSecond: event.bytesPerSecond,
};
return null;
case 'error':
return {
type: 'error',
@@ -164,9 +135,12 @@ class Updater {
private backgroundCheckInterval: number | null = null;
private backgroundCheckCleanups: Array<() => void> = [];
private unsubscribeNativeEvents: (() => void) | null = null;
private unsubscribeModuleUpdates: (() => void) | null = null;
moduleUpdate: DesktopPendingModuleUpdate | null = null;
private updateReadyNagbarDismissedVersion: string | null = null;
private unsubscribeDesktopUpdate: (() => void) | null = null;
private desktopUpdateReported = false;
private olderShellModuleUpdateReady = false;
private olderShellUpdateDownloaded = false;
private olderShellInstallWhenDownloaded = false;
private desktopUpdateStarting = false;
private pendingManualDownloadRefreshes = 0;
private checkInProgress = false;
@@ -184,10 +158,6 @@ class Updater {
return this.snapshot.context.updateInfo;
}
get downloadProgress(): NativeDownloadProgress | null {
return this.snapshot.context.downloadProgress;
}
get lastCheckedAt(): number | null {
return this.snapshot.context.lastCheckedAt;
}
@@ -215,60 +185,28 @@ class Updater {
return this.snapshot.context.manualNativeDownloadInFlight;
}
get desktopUpdateAvailable(): boolean {
return this.desktopUpdateReported || this.olderShellModuleUpdateReady || this.olderShellUpdateAvailable;
}
private get olderShellUpdateAvailable(): boolean {
const electronApi = getElectronAPI();
if (electronApi == null || electronApi.desktopUpdate != null || electronApi.updaterInstall == null) {
return false;
}
return this.olderShellUpdateDownloaded || (this.updateInfo.native.available && !this.hasManualNativeDownload);
}
get hasUpdate(): boolean {
return this.moduleUpdateReady || this.updateInfo.native.available || this.updateInfo.web.available;
}
get moduleUpdateReady(): boolean {
return this.moduleUpdate != null;
}
get nativeUpdatePending(): boolean {
return this.updateInfo.native.available && !this.updateInfo.native.downloaded;
}
get nativeUpdateReady(): boolean {
return this.updateInfo.native.available && this.updateInfo.native.downloaded;
}
get shouldShowUpdateReadyNagbar(): boolean {
return (
this.nativeUpdateReady &&
!this.updateInfo.native.installing &&
this.updateReadyNagbarDismissedVersion !== this.updateReadyNagbarVersionKey
);
}
private get updateReadyNagbarVersionKey(): string {
return this.updateInfo.native.version ?? 'unknown';
}
get nativeDownloadInFlight(): boolean {
return this.updateInfo.native.downloading && !this.updateInfo.native.downloaded;
}
get nativeDownloadProgressSupported(): boolean {
return shouldShowNativeDesktopUpdateDownloadProgress(getElectronAPI()?.platform);
return this.desktopUpdateAvailable || this.updateInfo.native.available || this.updateInfo.web.available;
}
get hasManualNativeDownload(): boolean {
return hasManualNativeDownload(this.snapshot);
}
get nativeAwaitingDownload(): boolean {
if (this.hasManualNativeDownload) return false;
return (
this.updateInfo.native.available && !this.updateInfo.native.downloaded && !this.updateInfo.native.downloading
);
}
get nativeManualUpdateAvailable(): boolean {
return (
this.updateInfo.native.available &&
!this.updateInfo.native.downloaded &&
!this.updateInfo.native.downloading &&
this.hasManualNativeDownload
);
return this.updateInfo.native.available && this.hasManualNativeDownload;
}
get state(): UpdaterState {
@@ -295,7 +233,7 @@ class Updater {
}
if (this.isNative) {
await this.bootstrapNative();
await this.bootstrapModuleUpdates();
await this.bootstrapDesktopUpdate();
}
this.startBackgroundChecks();
void this.checkForUpdates(false);
@@ -315,43 +253,98 @@ class Updater {
this.subscribeToNativeEvents();
}
private async bootstrapModuleUpdates(): Promise<void> {
private async bootstrapDesktopUpdate(): Promise<void> {
const electronApi = getElectronAPI();
const desktopUpdate = electronApi?.desktopUpdate;
if (desktopUpdate == null) {
await this.bootstrapOlderShellModuleUpdate();
return;
}
this.unsubscribeDesktopUpdate = desktopUpdate.onStateChanged((state) => {
runInAction(() => {
this.desktopUpdateReported = state.available;
});
});
await this.refreshDesktopUpdateState();
}
private async bootstrapOlderShellModuleUpdate(): Promise<void> {
const desktopModules = getElectronAPI()?.desktopModules;
const onPendingUpdateChanged = desktopModules?.onPendingUpdateChanged;
const pendingUpdate = desktopModules?.pendingUpdate;
if (onPendingUpdateChanged == null || pendingUpdate == null) return;
this.unsubscribeModuleUpdates = onPendingUpdateChanged((pending) => {
this.unsubscribeDesktopUpdate = onPendingUpdateChanged((pending) => {
runInAction(() => {
this.moduleUpdate = pending;
this.olderShellModuleUpdateReady = pending != null;
});
});
try {
const pending = await pendingUpdate();
runInAction(() => {
this.moduleUpdate = pending;
this.olderShellModuleUpdateReady = pending != null;
});
} catch (error) {
logger.warn('Failed to read the pending desktop module update', error);
}
}
private async applyModuleUpdate(): Promise<void> {
const applyPendingUpdate = getElectronAPI()?.desktopModules?.applyPendingUpdate;
if (applyPendingUpdate == null) return;
private async refreshDesktopUpdateState(): Promise<void> {
const desktopUpdate = getElectronAPI()?.desktopUpdate;
if (desktopUpdate == null) return;
try {
await applyPendingUpdate();
const state = await desktopUpdate.state();
runInAction(() => {
this.desktopUpdateReported = state.available;
});
} catch (error) {
logger.warn('Failed to apply the pending desktop module update', error);
logger.warn('Failed to read the desktop update state', error);
}
}
private async startDesktopUpdate(): Promise<void> {
const electronApi = getElectronAPI();
if (electronApi == null || this.desktopUpdateStarting) return;
this.desktopUpdateStarting = true;
try {
flushPendingPersistWrites();
if (electronApi.desktopUpdate != null) {
await electronApi.desktopUpdate.start();
} else if (this.olderShellModuleUpdateReady) {
await electronApi.desktopModules?.applyPendingUpdate?.();
} else if (this.olderShellUpdateDownloaded) {
await electronApi.updaterInstall?.();
} else {
this.olderShellInstallWhenDownloaded = true;
await electronApi.updaterDownload?.('user');
}
} catch (error) {
this.olderShellInstallWhenDownloaded = false;
logger.warn('Failed to start the desktop update', error);
} finally {
this.desktopUpdateStarting = false;
}
}
private handleOlderShellUpdateDownloaded(): void {
this.olderShellUpdateDownloaded = true;
if (!this.olderShellInstallWhenDownloaded) return;
this.olderShellInstallWhenDownloaded = false;
void getElectronAPI()
?.updaterInstall?.()
.catch((error: unknown) => {
logger.warn('Failed to install the downloaded desktop update', error);
});
}
private subscribeToNativeEvents(): void {
const electronApi = getElectronAPI();
if (!electronApi) return;
this.unsubscribeNativeEvents = electronApi.onUpdaterEvent((event) => {
const updaterEvent = normalizeUpdaterEvent(event);
if (!updaterEvent) {
logger.warn('Ignored malformed native updater event', {event});
if (event.type !== 'progress') {
logger.warn('Ignored malformed native updater event', {event});
}
return;
}
this.handleNativeEvent(updaterEvent);
@@ -370,35 +363,17 @@ class Updater {
this.pendingManualDownloadRefreshes -= 1;
}
const isUserCheck = event.context === 'user' && !isManualDownloadRefreshResult;
const isBackgroundOrFocusCheck = event.context === 'background' || event.context === 'focus';
const shouldSurfaceNativeDesktopUpdate = this.shouldSurfaceNativeDesktopUpdate();
const shouldShowImmediateUserResult = isUserCheck && !this.checkInProgress;
switch (event.type) {
case 'checking':
this.transition({type: 'check.started'});
break;
case 'available': {
const downloadStarted = event.downloadStarted ?? true;
const manualDownloadOptions = this.resolveManualNativeDownloadOptions(event, event.downloadOptions ?? []);
const manualDownloadUrl = manualDownloadOptions[0]?.url ?? event.downloadUrl ?? null;
if (!shouldSurfaceNativeDesktopUpdate) {
this.transition({
type: 'native.hidden',
reason: 'platform',
downloadUrl: manualDownloadUrl,
now: Date.now(),
});
if (isUserCheck) {
pushUnsupportedUpdateModal('platform', manualDownloadUrl ?? DESKTOP_DOWNLOAD_URL);
}
break;
}
this.transition({
type: 'native.available',
version: event.version ?? null,
downloadSize: event.downloadSize ?? null,
downloadStarted,
downloadUrl: manualDownloadUrl,
downloadUrl: manualDownloadOptions[0]?.url ?? event.downloadUrl ?? null,
downloadOptions: manualDownloadOptions,
});
if (shouldShowImmediateUserResult) {
@@ -408,55 +383,25 @@ class Updater {
}
case 'not-available':
this.transition({type: 'native.notAvailable', now: Date.now()});
if (isUserCheck && !this.checkInProgress) {
if (shouldShowImmediateUserResult) {
this.showCurrentUpdateState();
}
break;
case 'error': {
const phase = event.phase ?? 'check';
if (isBackgroundOrFocusCheck) {
logger.debug('Background update failed silently:', event.message);
case 'downloaded':
this.handleOlderShellUpdateDownloaded();
break;
case 'error':
this.olderShellInstallWhenDownloaded = false;
if (isUserCheck) {
logger.warn('Update check error:', event.message);
} else {
logger.warn(`Update ${phase} error:`, event.message);
logger.debug('Background update check failed silently:', event.message);
}
this.transition({type: 'native.error'});
if (isUserCheck) {
if (phase === 'download') {
pushDesktopUpdateDownloadFailedModal();
} else if (phase === 'install') {
pushDesktopUpdateInstallFailedModal();
} else {
pushUpdateCheckFailedModal();
}
pushUpdateCheckFailedModal();
}
break;
}
case 'downloaded':
if (!shouldSurfaceNativeDesktopUpdate) {
this.transition({
type: 'native.hidden',
reason: 'platform',
downloadUrl: null,
now: Date.now(),
});
break;
}
this.transition({type: 'native.downloaded', version: event.version ?? null});
break;
case 'progress':
if (!shouldSurfaceNativeDesktopUpdate || !this.nativeDownloadProgressSupported) {
break;
}
this.transition({
type: 'native.progress',
progress: {
percent: event.percent,
transferred: event.transferred,
total: event.total,
bytesPerSecond: event.bytesPerSecond,
},
});
break;
case 'unsupported':
this.transition({
type: 'native.unsupported',
@@ -464,7 +409,7 @@ class Updater {
downloadUrl: event.downloadUrl ?? null,
now: Date.now(),
});
if (isUserCheck) {
if (shouldShowImmediateUserResult) {
pushUnsupportedUpdateModal(event.reason ?? 'platform', event.downloadUrl ?? null);
}
break;
@@ -475,7 +420,7 @@ class Updater {
event: Extract<UpdaterEvent, {type: 'available'}>,
options: ReadonlyArray<UpdaterDownloadOption>,
): ReadonlyArray<UpdaterDownloadOption> {
if ((event.downloadStarted ?? true) || getElectronAPI()?.platform !== 'linux') {
if (getElectronAPI()?.platform !== 'linux') {
return options;
}
return buildLinuxManualUpdateOptions({
@@ -521,15 +466,10 @@ class Updater {
private shouldRunNativeCheck(userInitiated: boolean): boolean {
if (!this.isNative) return false;
if (this.nativeUnsupported && !userInitiated) return false;
if (this.nativeDownloadInFlight || this.updateInfo.native.installing) return false;
if (userInitiated) return true;
return !this.updateInfo.native.available;
}
private shouldSurfaceNativeDesktopUpdate(): boolean {
return shouldShowNativeDesktopUpdateInApp(getElectronAPI()?.platform);
}
async checkForUpdates(force = false, userInitiated = false): Promise<void> {
if (this.checkInProgress) {
return;
@@ -555,6 +495,9 @@ class Updater {
if (webResult) {
this.transition({type: 'web.checked', available: webResult.available, version: webResult.version});
}
if (userInitiated && shouldCheckNative) {
await this.refreshDesktopUpdateState();
}
if (userInitiated && (!shouldCheckNative || (!this.isChecking && !this.nativeCheckFailed))) {
this.showCurrentUpdateState();
}
@@ -628,32 +571,8 @@ class Updater {
async applyUpdate(): Promise<void> {
if (!this.hasUpdate) return;
if (this.moduleUpdateReady) {
await this.applyModuleUpdate();
return;
}
const electronApi = getElectronAPI();
if (this.isNative && this.updateInfo.native.downloaded && electronApi) {
if (this.updateInfo.native.installing) {
logger.debug('Install already in progress; ignoring duplicate click.');
return;
}
this.transition({type: 'native.install.started'});
logger.info('Installing downloaded native update...');
try {
await electronApi.updaterInstall();
} catch (error) {
logger.warn('Native update install failed', error);
this.transition({type: 'native.install.failed'});
pushDesktopUpdateInstallFailedModal();
}
return;
}
if (this.isNative && this.nativeAwaitingDownload && electronApi?.updaterDownload) {
await this.startNativeDownload();
return;
}
if (this.isNative && this.nativeDownloadInFlight) {
if (this.desktopUpdateAvailable) {
await this.startDesktopUpdate();
return;
}
if (this.isNative && this.nativeUnsupported?.reason === 'managed-package' && !this.updateInfo.web.available) {
@@ -683,34 +602,13 @@ class Updater {
}
}
async startNativeDownload(userInitiated = true): Promise<void> {
const electronApi = getElectronAPI();
if (!electronApi?.updaterDownload) return;
if (!this.updateInfo.native.available) return;
if (this.updateInfo.native.downloading || this.updateInfo.native.downloaded) return;
this.transition({
type: 'native.download.started',
progressSupported: this.nativeDownloadProgressSupported,
total: this.updateInfo.native.downloadSize,
});
try {
await electronApi.updaterDownload(userInitiated ? 'user' : 'background');
} catch (error) {
logger.warn('Native update download failed', error);
this.transition({type: 'native.download.failed'});
if (userInitiated) {
pushDesktopUpdateDownloadFailedModal();
}
}
}
private showCurrentUpdateState(): void {
if (this.nativeManualUpdateAvailable) {
this.showManualNativeUpdateModal();
if (this.desktopUpdateAvailable) {
pushDesktopUpdateAvailableModal(() => this.startDesktopUpdate());
return;
}
if (this.nativeUpdateReady) {
pushUpdateReadyModal(this.updateInfo.native.version, this.applyUpdate);
if (this.nativeManualUpdateAvailable) {
this.showManualNativeUpdateModal();
return;
}
if (this.hasUpdate) {
@@ -816,10 +714,6 @@ class Updater {
}
}
dismissUpdateReadyNagbar(): void {
this.updateReadyNagbarDismissedVersion = this.updateReadyNagbarVersionKey;
}
reset(): void {
this.transition({type: 'reset'});
}
@@ -829,11 +723,14 @@ class Updater {
this.unsubscribeNativeEvents();
this.unsubscribeNativeEvents = null;
}
if (this.unsubscribeModuleUpdates) {
this.unsubscribeModuleUpdates();
this.unsubscribeModuleUpdates = null;
if (this.unsubscribeDesktopUpdate) {
this.unsubscribeDesktopUpdate();
this.unsubscribeDesktopUpdate = null;
}
this.moduleUpdate = null;
this.desktopUpdateReported = false;
this.olderShellModuleUpdateReady = false;
this.olderShellUpdateDownloaded = false;
this.olderShellInstallWhenDownloaded = false;
if (this.backgroundCheckInterval != null) {
window.clearInterval(this.backgroundCheckInterval);
this.backgroundCheckInterval = null;
@@ -1,9 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export function shouldShowNativeDesktopUpdateInApp(platform: string | null | undefined): boolean {
return platform === 'darwin' || platform === 'win32' || platform === 'linux';
}
export function shouldShowNativeDesktopUpdateDownloadProgress(platform: string | null | undefined): boolean {
return platform === 'win32' || platform === 'linux';
}
@@ -102,6 +102,14 @@ class LimitsClass {
return LimitResolver.resolveFree(key, fallback);
}
hasStockFeature(key: LimitKey, fallback: boolean): boolean {
return this.getStockValue(key, fallback ? 1 : 0) > 0;
}
hasRestrictedFeature(key: LimitKey, fallback: boolean): boolean {
return this.getRestrictedValue(key, fallback ? 1 : 0) > 0;
}
getPremiumValue(key: LimitKey, fallback: number): number {
return this.getStockValue(key, fallback);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {runtimeInstanceKey} from '@app/features/app/state/InstanceSnapshotStore';
import type {Account} from '@app/features/platform/state/AuthSession';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import {getDefaultAvatarAssetURL} from '@app/features/user/utils/DefaultAvatars';
@@ -16,12 +17,25 @@ function defaultAccountAvatarURL(account: Account, size?: MediaProxyImageSize):
function resolveAccountAvatarURL(account: Account, size?: MediaProxyImageSize): string {
const avatar = account.userData?.avatar ?? null;
const mediaEndpoint = account.instance?.mediaEndpoint ?? null;
if (avatar == null || avatar.length === 0 || mediaEndpoint == null || mediaEndpoint.length === 0) {
const instance = account.instance ?? null;
const mediaEndpoint = instance?.mediaEndpoint ?? null;
const instanceKey = instance == null ? null : runtimeInstanceKey(instance);
if (
avatar == null ||
avatar.length === 0 ||
mediaEndpoint == null ||
mediaEndpoint.length === 0 ||
instanceKey == null
) {
return defaultAccountAvatarURL(account, size);
}
try {
return AvatarUtils.getUserAvatarURLWithProxy({id: account.userId, avatar}, mediaEndpoint, false, size);
return AvatarUtils.getUserAvatarURLForInstance(
{id: account.userId, avatar},
{mediaEndpoint, instanceKey},
false,
size,
);
} catch {
return defaultAccountAvatarURL(account, size);
}
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfigSnapshot';
import {
resolveInstanceAssetUrl,
resolveInstanceBrandIconUrl,
resolveInstanceProductName,
} from '@app/features/auth/InstanceBranding';
import {describe, expect, test} from 'vitest';
type Branding = RuntimeConfigSnapshot['appPublic']['branding'];
function snapshot(branding: Partial<Branding>, origin = 'https://chat.example.org') {
return {
apiEndpoint: `${origin}/api`,
apiPublicEndpoint: `${origin}/api`,
mediaEndpoint: `${origin}/media`,
staticCdnEndpoint: origin,
webAppEndpoint: origin,
appPublic: {
branding: {
product_name: 'Example Chat',
icon_url: null,
symbol_url: null,
logo_url: null,
wordmark_url: null,
favicon_url: null,
theme_color: null,
status_page_url: null,
status_page_incident_history_url: null,
premium_product_name: 'Premium',
premium_info_url: null,
...branding,
},
setup: {configured: true, admin_url: null},
legal: {terms_url: null, privacy_url: null},
registration: {collect_date_of_birth: false},
},
};
}
describe('instance branding', () => {
test('prefers the symbol, then the icon, then the logo', () => {
const base = 'https://chat.example.org/media/branding';
expect(
resolveInstanceBrandIconUrl(
snapshot({symbol_url: `${base}/s.png`, icon_url: `${base}/i.png`, logo_url: `${base}/l.png`}),
),
).toBe(`${base}/s.png`);
expect(resolveInstanceBrandIconUrl(snapshot({icon_url: `${base}/i.png`, logo_url: `${base}/l.png`}))).toBe(
`${base}/i.png`,
);
expect(resolveInstanceBrandIconUrl(snapshot({logo_url: `${base}/l.png`}))).toBe(`${base}/l.png`);
expect(resolveInstanceBrandIconUrl(snapshot({}))).toBeNull();
});
test('only accepts assets served by the instance itself', () => {
const s = snapshot({});
expect(resolveInstanceAssetUrl(s, '/media/branding/icon.png')).toBe(
'https://chat.example.org/media/branding/icon.png',
);
expect(resolveInstanceAssetUrl(s, 'https://tracker.example.net/icon.png')).toBeNull();
expect(resolveInstanceAssetUrl(s, 'javascript:alert(1)')).toBeNull();
expect(resolveInstanceAssetUrl(s, 'https://user:[email protected]/icon.png')).toBeNull();
expect(resolveInstanceAssetUrl(s, 'http://chat.example.org/icon.png')).toBeNull();
});
test('a cleartext instance may serve its own assets over http', () => {
const s = snapshot({icon_url: 'http://192.168.1.20:8080/media/icon.png'}, 'http://192.168.1.20:8080');
expect(resolveInstanceBrandIconUrl(s)).toBe('http://192.168.1.20:8080/media/icon.png');
});
test('product name ignores blank values', () => {
expect(resolveInstanceProductName(snapshot({product_name: ' Example Chat '}))).toBe('Example Chat');
expect(resolveInstanceProductName(snapshot({product_name: ' '}))).toBeNull();
expect(resolveInstanceProductName(null)).toBeNull();
});
});
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfigSnapshot';
type BrandingSnapshot = Pick<
RuntimeConfigSnapshot,
'apiEndpoint' | 'apiPublicEndpoint' | 'mediaEndpoint' | 'staticCdnEndpoint' | 'webAppEndpoint' | 'appPublic'
>;
const ASSET_PROTOCOLS: ReadonlySet<string> = new Set(['https:', 'http:']);
function originOf(value: string | null | undefined): string | null {
if (value == null || value.length === 0) {
return null;
}
try {
return new URL(value).origin;
} catch {
return null;
}
}
function instanceAssetOrigins(snapshot: BrandingSnapshot): ReadonlySet<string> {
const origins = new Set<string>();
for (const endpoint of [
snapshot.mediaEndpoint,
snapshot.staticCdnEndpoint,
snapshot.webAppEndpoint,
snapshot.apiEndpoint,
snapshot.apiPublicEndpoint,
]) {
const origin = originOf(endpoint);
if (origin != null && origin !== 'null') {
origins.add(origin);
}
}
return origins;
}
export function resolveInstanceAssetUrl(
snapshot: BrandingSnapshot,
candidate: string | null | undefined,
): string | null {
const trimmed = candidate?.trim();
if (trimmed == null || trimmed.length === 0) {
return null;
}
const base = snapshot.mediaEndpoint || snapshot.webAppEndpoint;
let url: URL;
try {
url = new URL(trimmed, base);
} catch {
return null;
}
if (!ASSET_PROTOCOLS.has(url.protocol) || url.username !== '' || url.password !== '') {
return null;
}
if (!instanceAssetOrigins(snapshot).has(url.origin)) {
return null;
}
return url.href;
}
export function resolveInstanceBrandIconUrl(snapshot: BrandingSnapshot | null | undefined): string | null {
const branding = snapshot?.appPublic?.branding;
if (snapshot == null || branding == null) {
return null;
}
for (const candidate of [branding.symbol_url, branding.icon_url, branding.logo_url]) {
const url = resolveInstanceAssetUrl(snapshot, candidate);
if (url != null) {
return url;
}
}
return null;
}
export function resolveInstanceProductName(snapshot: BrandingSnapshot | null | undefined): string | null {
const name = snapshot?.appPublic?.branding?.product_name?.trim();
return name == null || name.length === 0 ? null : name;
}
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import InstanceSnapshotStore from '@app/features/app/state/InstanceSnapshotStore';
import type {RuntimeConfigSnapshot} from '@app/features/app/state/RuntimeConfigSnapshot';
import {resolveAccountInstanceKey} from '@app/features/auth/AccountDisplayUtils';
import Accounts from '@app/features/auth/state/Accounts';
export function findInstanceSnapshot(instanceKey: string): RuntimeConfigSnapshot | null {
const cached = InstanceSnapshotStore.get(instanceKey);
if (cached != null) {
return cached;
}
for (const account of Accounts.orderedAccounts) {
if (account.instance != null && resolveAccountInstanceKey(account) === instanceKey) {
return account.instance;
}
}
return null;
}
@@ -131,14 +131,17 @@ interface RecoveryKitIssued {
export type RecoverAccountResponse = (AuthTokenResponse | MfaLoginResponse) & RecoveryKitIssued;
export type DesktopHandoffReturnMethod = 'deep_link' | 'code';
interface DesktopHandoffInitiateResponse {
code: string;
expires_at: string;
poll_secret?: string;
return_method?: DesktopHandoffReturnMethod;
}
interface DesktopHandoffStatusResponse {
status: 'pending' | 'completed' | 'expired';
status: 'pending' | 'completed' | 'denied' | 'expired';
token?: string;
user_id?: string;
user?: AuthResponseUser | null;
@@ -147,6 +150,7 @@ interface DesktopHandoffStatusResponse {
interface DesktopHandoffInfoClientInfo {
platform?: string | null;
os?: string | null;
device?: 'mobile' | 'desktop';
location?: {
city?: string | null;
region?: string | null;
@@ -157,6 +161,11 @@ interface DesktopHandoffInfoClientInfo {
export interface DesktopHandoffInfoResponse {
status: 'pending' | 'expired';
client_info?: DesktopHandoffInfoClientInfo | null;
return_method?: DesktopHandoffReturnMethod;
}
interface DesktopHandoffCompleteResponse {
return_url?: string;
}
export type LoginIdentifier = {email: string; login?: undefined} | {login: string; email?: undefined};
@@ -730,21 +739,34 @@ export async function completeDesktopHandoff({
code,
token,
userId,
returnMethod,
target,
}: {
code: string;
token: string;
userId: string;
returnMethod: DesktopHandoffReturnMethod;
target: InstanceHTTPTarget;
}): Promise<void> {
await instanceRequest({
}): Promise<string | null> {
const response = await instanceRequest<DesktopHandoffCompleteResponse | null>({
method: 'POST',
path: Endpoints.AUTH_HANDOFF_COMPLETE,
target,
body: {code, user_id: userId},
body: {code, user_id: userId, return_method: returnMethod},
headers: withAuthLocaleHeader({Authorization: token}),
auth: 'none',
});
return response.body?.return_url ?? null;
}
export async function denyDesktopHandoff({code, target}: {code: string; target: InstanceHTTPTarget}): Promise<void> {
await instanceRequest({
method: 'POST',
path: Endpoints.AUTH_HANDOFF_DENY(code),
target,
headers: withAuthLocaleHeader(),
auth: 'none',
});
}
export async function fetchDesktopHandoffInfo({
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react';
import {GlobeIcon} from '@phosphor-icons/react';
import type React from 'react';
import {useState} from 'react';
interface InstanceBrandMarkProps {
readonly isOfficial: boolean;
readonly iconUrl: string | null;
readonly size: number;
readonly globeWeight?: 'regular' | 'bold';
readonly 'data-flx'?: string;
}
function InstanceBrandImage({
url,
size,
fallback,
}: {
readonly url: string;
readonly size: number;
readonly fallback: React.ReactElement;
}): React.ReactElement {
const [failed, setFailed] = useState(false);
if (failed) {
return fallback;
}
return (
<img
src={url}
alt=""
width={size}
height={size}
draggable={false}
decoding="async"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
data-flx="auth.components.instance-brand-mark.instance-brand-image.image"
/>
);
}
export function InstanceBrandMark({
isOfficial,
iconUrl,
size,
globeWeight = 'regular',
'data-flx': dataFlx,
}: InstanceBrandMarkProps): React.ReactElement {
if (isOfficial) {
return <FluxerLogoAsset aria-hidden="true" data-flx={dataFlx} />;
}
const globe = <GlobeIcon size={remFromPx(size)} weight={globeWeight} aria-hidden="true" data-flx={dataFlx} />;
if (iconUrl == null) {
return globe;
}
return <InstanceBrandImage key={iconUrl} url={iconUrl} size={size} fallback={globe} />;
}
@@ -2,11 +2,10 @@
import styles from '@app/features/auth/components/accounts/account_switcher_modal/AccountProfilePicker.module.css';
import type {AccountInstanceBadgeInfo} from '@app/features/auth/components/accounts/account_switcher_modal/useAccountInstanceBadge';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {InstanceBrandMark} from '@app/features/auth/components/InstanceBrandMark';
import {instanceDomainHost} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
import {VerifiedConnectionIcon} from '@app/features/ui/components/icons/VerifiedConnectionIcon';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react';
import {GlobeIcon} from '@phosphor-icons/react';
import type React from 'react';
interface AccountInstanceBadgeProps {
@@ -22,11 +21,15 @@ export function AccountInstanceBadge({
}: AccountInstanceBadgeProps): React.ReactElement {
let domainLabel = fallbackLabel;
let isOfficial = false;
let iconUrl: string | null = null;
let instanceName: string | null = null;
if (badge != null) {
domainLabel = badge.title;
isOfficial = badge.isOfficial;
iconUrl = badge.iconUrl;
instanceName = badge.label !== instanceDomainHost(badge.title) ? badge.label : null;
}
const ariaLabels = [domainLabel];
const ariaLabels = instanceName == null ? [domainLabel] : [instanceName, domainLabel];
if (isOfficial) {
ariaLabels.push(officialLabel);
}
@@ -39,7 +42,7 @@ export function AccountInstanceBadge({
className={styles.instanceTooltipLabel}
data-flx="auth.accounts.account-switcher-modal.account-instance-badge.render-tooltip.instance-tooltip-label"
>
{domainLabel}
{instanceName == null ? domainLabel : `${instanceName} (${domainLabel})`}
</span>
{isOfficial && (
<span
@@ -69,15 +72,13 @@ export function AccountInstanceBadge({
aria-label={ariaLabels.join(', ')}
data-flx="auth.accounts.account-switcher-modal.account-instance-badge.instance-badge"
>
{isOfficial ? (
<FluxerLogoAsset data-flx="auth.accounts.account-switcher-modal.account-instance-badge.fluxer-logo-asset" />
) : (
<GlobeIcon
size={remFromPx(15)}
weight="bold"
data-flx="auth.accounts.account-switcher-modal.account-instance-badge.globe-icon"
/>
)}
<InstanceBrandMark
isOfficial={isOfficial}
iconUrl={iconUrl}
size={15}
globeWeight="bold"
data-flx="auth.accounts.account-switcher-modal.account-instance-badge.instance-brand-mark"
/>
</span>
</Tooltip>
);
@@ -281,14 +281,20 @@
box-shadow: 0 0.25rem 0.75rem rgb(0 0 0 / 0.28);
}
.instanceBadge svg,
.instanceBadge img {
.instanceBadge svg {
display: block;
width: 72%;
height: 72%;
object-fit: cover;
}
.instanceBadge img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.instanceTooltip {
display: inline-flex;
align-items: center;
@@ -6,6 +6,7 @@ import {
normalizeInstanceName,
resolveInstanceLabel,
} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
import {resolveInstanceBrandIconUrl} from '@app/features/auth/InstanceBranding';
import type {Account} from '@app/features/platform/state/AuthSession';
import {isOfficialInstanceHost, OFFICIAL_INSTANCE_NAME} from '@fluxer/instance_bootstrap/src/OfficialInstance';
import {useMemo} from 'react';
@@ -14,6 +15,7 @@ export interface AccountInstanceBadgeInfo {
readonly label: string;
readonly title: string;
readonly isOfficial: boolean;
readonly iconUrl: string | null;
}
function findKnownInstance(instanceKey: string, knownInstances: ReadonlyArray<InstanceInfo>): InstanceInfo | null {
@@ -44,10 +46,15 @@ function getAccountInstanceBadgeInfo(
}
const isOfficial = isOfficialInstanceHost(instanceKey);
if (isOfficial) {
return {label: OFFICIAL_INSTANCE_NAME, title: domain, isOfficial};
return {label: OFFICIAL_INSTANCE_NAME, title: domain, isOfficial, iconUrl: null};
}
const knownInstance = findKnownInstance(instanceKey, knownInstances);
return {label: resolveInstanceName(account, knownInstance, domain), title: domain, isOfficial};
return {
label: resolveInstanceName(account, knownInstance, domain),
title: domain,
isOfficial,
iconUrl: resolveInstanceBrandIconUrl(account.instance),
};
}
export function useAccountInstanceBadge(
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {getActiveInstanceProductName} from '@app/features/app/state/ActiveInstanceProductName';
import {useAuthorizeParams} from '@app/features/auth/components/pages/oauth_authorize_page/hooks/useAuthorizeParams';
import {
type BotInviteDestinationOption,
@@ -459,7 +459,8 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
}
}, [params, onCancelOverride, publicAppState.data, i18n]);
const sessionExpiredMessage = useMemo(
() => i18n._(YOUR_PRODUCT_SESSION_EXPIRED_SIGN_IN_AGAIN_TO_DESCRIPTOR, {productName: PRODUCT_NAME}),
() =>
i18n._(YOUR_PRODUCT_SESSION_EXPIRED_SIGN_IN_AGAIN_TO_DESCRIPTOR, {productName: getActiveInstanceProductName()}),
[i18n.locale],
);
return {
@@ -44,7 +44,7 @@ import {AuthClientPreferencesStep} from '@app/features/auth/flow/client_intro/Au
import {AuthClientWelcomeStep} from '@app/features/auth/flow/client_intro/AuthClientWelcomeStep';
import {useDesktopClientIntroFlow} from '@app/features/auth/flow/client_intro/useDesktopClientIntroFlow';
import DesktopHandoffAccountSelector from '@app/features/auth/flow/DesktopHandoffAccountSelector';
import {ConnectedHandoffApprovalFlow} from '@app/features/auth/flow/HandoffApprovalFlow';
import {HandoffApprovalFlow} from '@app/features/auth/flow/HandoffApprovalFlow';
import IpAuthorizationScreen from '@app/features/auth/flow/IpAuthorizationScreen';
import {useAuthPresentation} from '@app/features/auth/flow/useAuthPresentation';
import {getAuthErrorMessage} from '@app/features/auth/hooks/useAuthForm';
@@ -992,12 +992,7 @@ export const AuthLoginLayout = observer(function AuthLoginLayout({
);
}
if (authLoginStep === AuthLoginStep.DESKTOP_HANDOFF_APPROVAL) {
return (
<ConnectedHandoffApprovalFlow
handoff={handoff}
data-flx="auth.flow.auth-login-layout.connected-handoff-approval-flow"
/>
);
return <HandoffApprovalFlow handoff={handoff} data-flx="auth.flow.auth-login-layout.handoff-approval-flow" />;
}
if (authLoginStep === AuthLoginStep.IP_AUTHORIZATION && ipAuthChallenge != null) {
const runtimeSnapshot = requireAuthInstanceSnapshot(resolvedAuthRuntimeSnapshot);
@@ -50,7 +50,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
if (!token) {
throw new Error('Failed to generate token');
}
onAccountSelected({token, userId, runtimeSnapshot});
onAccountSelected({token, userId, runtimeSnapshot, userData: account.userData});
} catch (err) {
if (err instanceof SessionExpiredError) {
onReLoginAccount(Accounts.getAccount(account.storageKey) ?? account);
@@ -74,13 +74,7 @@ export const DesktopHandoffMfaStep = observer(function DesktopHandoffMfaStep({
if (isHandoff && isApprovalFlowMode(handoff.mode)) {
return (
<HandoffApprovalFlow
mode={handoff.mode}
error={handoff.error}
clientInfo={handoff.clientInfo}
onProceedToCodeInput={handoff.proceedToCodeInput}
onSubmitCode={handoff.submitCode}
onApprove={handoff.approve}
onDeny={handoff.deny}
handoff={handoff}
onRetry={handleHandoffRetry}
data-flx="auth.flow.desktop-handoff-mfa-step.handoff-approval-flow"
/>
@@ -25,39 +25,83 @@
line-height: 1.5;
}
.warningBox {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 1.125rem;
background-color: hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.06);
border: 0.125rem solid hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.25);
border-radius: 0.625rem;
margin-bottom: 1rem;
}
.warningIcon {
color: var(--status-warning);
}
.warningTitle {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.warningDescription {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
.codeInputSection {
margin-bottom: 0.75rem;
width: 100%;
text-align: left;
}
.codeInput {
font-family: var(--font-mono);
font-size: 1.125rem;
letter-spacing: 0.08em;
text-align: center;
text-transform: uppercase;
}
.code {
display: inline-block;
padding: 0.5rem 0.75rem;
border: 0.0625rem solid var(--background-modifier-accent);
border-radius: 0.5rem;
background: var(--background-tertiary);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.1;
overflow-wrap: anywhere;
}
.matchCheck {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
margin: 0.25rem 0 0.75rem;
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.4;
}
.accountLine {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.25rem 0.5rem;
width: 100%;
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.wrongAccount {
width: 100%;
margin: 0;
padding: 0.625rem 0.75rem;
border: 0.0625rem solid hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.25);
border-radius: 0.5rem;
background-color: hsla(40, calc(100% * var(--saturation-factor)), 55%, 0.06);
color: var(--text-primary);
line-height: 1.4;
}
.textButton {
padding: 0.125rem 0.25rem;
border: none;
border-radius: 0.25rem;
background: none;
color: var(--text-link);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.textButton:hover {
text-decoration: underline;
}
.deviceCard {
@@ -129,3 +173,7 @@
.successIcon {
color: var(--status-online);
}
.deniedIcon {
color: var(--text-tertiary);
}
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
import type {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/AuthLoginStepTypes';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/AuthLoginStepTypes';
import type {DesktopHandoffFlow} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
import {
CANCEL_DESCRIPTOR,
@@ -12,6 +13,7 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {Button} from '@app/features/ui/button/Button';
import {Input} from '@app/features/ui/components/form/FormInput';
import {Spinner} from '@app/features/ui/components/Spinner';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {
formatDesktopHandoffCode,
isDesktopHandoffCode,
@@ -19,13 +21,18 @@ import {
} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {CheckCircleIcon, ShieldWarningIcon} from '@phosphor-icons/react';
import {CheckCircleIcon, ProhibitIcon} from '@phosphor-icons/react';
import type React from 'react';
import {useCallback, useState} from 'react';
const SIGN_IN_CODE_DESCRIPTOR = msg({
message: 'Sign-in code',
comment: 'Short label in the authentication handoff approval flow. Keep the tone plain and specific.',
const APP_CODE_LABEL_DESCRIPTOR = msg({
message: 'Code from the {productName} app',
comment:
'Label of the field in the browser where the user types the 12-character code shown in the desktop or mobile app. productName is the app name, such as Fluxer.',
});
const SIGN_IN_DESCRIPTOR = msg({
message: 'Sign in',
comment: 'Button in the browser that approves signing in to the desktop or mobile app that asked for it.',
});
function formatLocation(location: {
@@ -37,85 +44,159 @@ function formatLocation(location: {
return parts.length > 0 ? parts.join(', ') : null;
}
interface HandoffApprovalFlowProps {
mode: DesktopHandoffMode;
error: string | null;
clientInfo: DesktopHandoffInfoResponse['client_info'];
onProceedToCodeInput: () => void;
onSubmitCode: (code: string) => void;
onApprove: () => void;
onDeny: () => void;
onRetry: () => void;
function HandoffCode({code}: {code: string}) {
return (
<span className={styles.code} data-flx="auth.flow.handoff-approval-flow.code">
{formatDesktopHandoffCode(code)}
</span>
);
}
export function HandoffApprovalFlow({
mode,
error,
clientInfo,
onProceedToCodeInput,
onSubmitCode,
onApprove,
onDeny,
onRetry,
}: HandoffApprovalFlowProps) {
function ApprovalTitle({handoff}: {handoff: DesktopHandoffFlow}) {
if (handoff.returnMethod === 'deep_link' || handoff.clientInfo?.device === 'desktop') {
return (
<Trans comment="Title of the browser page where the user approves signing in to the desktop app that opened it. PRODUCT_NAME is the app name, such as Fluxer.">
Sign in to the {PRODUCT_NAME} desktop app
</Trans>
);
}
if (handoff.clientInfo?.device === 'mobile') {
return (
<Trans comment="Title of the browser page where the user approves signing in to the mobile app that asked for it. PRODUCT_NAME is the app name, such as Fluxer.">
Sign in to the {PRODUCT_NAME} mobile app
</Trans>
);
}
return (
<Trans comment="Title of the browser page where the user approves signing in to an app that asked for it. PRODUCT_NAME is the app name, such as Fluxer.">
Sign in to the {PRODUCT_NAME} app
</Trans>
);
}
function DeviceCard({clientInfo}: {clientInfo: DesktopHandoffFlow['clientInfo']}) {
const platform = clientInfo?.platform ?? null;
const os = clientInfo?.os ?? null;
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
if (!platform && !os && !location) {
return null;
}
return (
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
{platform ? (
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
<Trans>Platform</Trans>
</span>
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
{platform}
</span>
</div>
) : null}
{os ? (
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--2">
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--2">
<Trans>Operating system</Trans>
</span>
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--2">
{os}
</span>
</div>
) : null}
{location ? (
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--3">
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--3">
<Trans>Location</Trans>
</span>
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--3">
{location}
</span>
</div>
) : null}
</div>
);
}
function AccountLine({handoff, onUseAnotherAccount}: {handoff: DesktopHandoffFlow; onUseAnotherAccount: () => void}) {
const account = handoff.account;
if (account == null) {
return null;
}
const name = account.globalName ?? account.username;
const requestedIdentifier = handoff.requestedIdentifier;
return (
<div className={styles.accountLine} data-flx="auth.flow.handoff-approval-flow.account-line">
{requestedIdentifier != null ? (
<p className={styles.wrongAccount} role="alert" data-flx="auth.flow.handoff-approval-flow.wrong-account">
<Trans comment="Warning on the browser approval page when the app asked for a different account than the one chosen. requestedIdentifier is the email or username the app asked for, name is the chosen account's display name.">
The {PRODUCT_NAME} app asked to sign in as {requestedIdentifier}, but you chose {name}.
</Trans>
</p>
) : (
<span data-flx="auth.flow.handoff-approval-flow.account-name">
<Trans comment="Shown on the browser approval page above the buttons. name is the display name of the account that will be signed in to the app.">
Signing in as {name}
</Trans>
</span>
)}
<FocusRing offset={-2} data-flx="auth.flow.handoff-approval-flow.focus-ring.use-another-account">
<button
type="button"
className={styles.textButton}
onClick={onUseAnotherAccount}
data-flx="auth.flow.handoff-approval-flow.button.use-another-account"
>
<Trans comment="Text button on the browser approval page that goes back to choosing which account signs in to the app.">
Use a different account
</Trans>
</button>
</FocusRing>
</div>
);
}
interface HandoffApprovalFlowProps {
handoff: DesktopHandoffFlow;
onRetry?: () => void;
}
export function HandoffApprovalFlow({handoff, onRetry}: HandoffApprovalFlowProps) {
const {i18n} = useLingui();
const [codeInput, setCodeInput] = useState('');
const retry = onRetry ?? handoff.retry;
const {submitCode} = handoff;
const handleCodeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const rawCode = parseDesktopHandoffCodeInput(e.target.value);
setCodeInput(rawCode);
if (isDesktopHandoffCode(rawCode)) {
onSubmitCode(rawCode);
void submitCode(rawCode);
}
},
[onSubmitCode],
[submitCode],
);
if (mode === 'warning') {
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container">
<div className={styles.warningBox} data-flx="auth.flow.handoff-approval-flow.warning-box">
<ShieldWarningIcon
size={remFromPx(32)}
weight="fill"
className={styles.warningIcon}
data-flx="auth.flow.handoff-approval-flow.warning-icon"
/>
<h1 className={styles.warningTitle} data-flx="auth.flow.handoff-approval-flow.warning-title">
<Trans>Sign-in request</Trans>
</h1>
<p className={styles.warningDescription} data-flx="auth.flow.handoff-approval-flow.warning-description">
<Trans>
You are about to authorize an app to sign in to your account. Only continue if you initiated this request
from a device you control.
</Trans>
</p>
</div>
<Button
onClick={onProceedToCodeInput}
variant="primary"
fitContainer
data-flx="auth.flow.handoff-approval-flow.button.proceed-to-code-input"
>
<Trans>Continue</Trans>
</Button>
</div>
);
}
if (mode === 'code_input') {
if (handoff.mode === DesktopHandoffMode.CODE_INPUT) {
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--2">
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title">
<Trans>Enter the code</Trans>
<Trans comment="Title of the browser page where the user types the 12-character code the app shows under Signing in on another device. PRODUCT_NAME is the app name, such as Fluxer.">
Enter the code from the {PRODUCT_NAME} app
</Trans>
</h1>
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description">
<Trans>Enter the code shown in the app to continue.</Trans>
<Trans comment="Explains where the code comes from, under the title of the browser page where it is typed. Signing in on another device is the label of a button in the app and must match its translation. PRODUCT_NAME is the app name, such as Fluxer.">
The {PRODUCT_NAME} app on your other device shows it under Signing in on another device. Only enter a code
from a device you control.
</Trans>
</p>
<div className={styles.codeInputSection} data-flx="auth.flow.handoff-approval-flow.code-input-section">
<Input
aria-label={i18n._(SIGN_IN_CODE_DESCRIPTOR)}
label={i18n._(APP_CODE_LABEL_DESCRIPTOR, {productName: PRODUCT_NAME})}
name="desktop_handoff_code"
className={styles.codeInput}
value={formatDesktopHandoffCode(codeInput)}
onChange={handleCodeChange}
placeholder="XXXXXX-XXXXXX"
autoComplete="off"
autoCapitalize="characters"
autoFocus
@@ -125,7 +206,7 @@ export function HandoffApprovalFlow({
/>
</div>
<Button
onClick={onRetry}
onClick={retry}
variant="secondary"
fitContainer
data-flx="auth.flow.handoff-approval-flow.button.cancel"
@@ -135,11 +216,17 @@ export function HandoffApprovalFlow({
</div>
);
}
if (mode === 'fetching_info') {
if (handoff.mode === DesktopHandoffMode.FETCHING_INFO || handoff.mode === DesktopHandoffMode.COMPLETING) {
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--3">
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--2">
<Trans>Verifying code…</Trans>
{handoff.mode === DesktopHandoffMode.FETCHING_INFO ? (
<Trans comment="Title while the browser looks up the sign-in request from the app.">
Checking the sign-in request…
</Trans>
) : (
<Trans comment="Title while the browser approves the sign-in to the app.">Signing in…</Trans>
)}
</h1>
<div className={styles.spinner} data-flx="auth.flow.handoff-approval-flow.spinner">
<Spinner data-flx="auth.flow.handoff-approval-flow.spinner-icon" />
@@ -147,81 +234,42 @@ export function HandoffApprovalFlow({
</div>
);
}
if (mode === 'approving') {
const platform = clientInfo?.platform ?? null;
const os = clientInfo?.os ?? null;
const location = clientInfo?.location ? formatLocation(clientInfo.location) : null;
const hasAnyDeviceInfo = Boolean(platform || os || location);
if (handoff.mode === DesktopHandoffMode.APPROVING) {
const matchCode = handoff.codeSource === 'link' && handoff.returnMethod === 'code' ? handoff.handoffCode : null;
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--4">
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--3">
<Trans>Was this you?</Trans>
<ApprovalTitle handoff={handoff} />
</h1>
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description--2">
{hasAnyDeviceInfo ? (
<Trans>A sign-in request was made from the following device. Only approve if you recognize it.</Trans>
) : (
<Trans>A sign-in request was made for your account. Only approve if you initiated it.</Trans>
)}
<Trans comment="Warning under the title of the browser approval page, so people do not approve a sign-in someone else started.">
Only continue if you started this sign-in on a device you control.
</Trans>
</p>
{hasAnyDeviceInfo ? (
<div className={styles.deviceCard} data-flx="auth.flow.handoff-approval-flow.device-card">
{platform ? (
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row">
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label">
<Trans>Platform</Trans>
</span>
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value">
{platform}
</span>
</div>
) : null}
{os ? (
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--2">
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--2">
<Trans>Operating system</Trans>
</span>
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--2">
{os}
</span>
</div>
) : null}
{location ? (
<div className={styles.deviceRow} data-flx="auth.flow.handoff-approval-flow.device-row--3">
<span className={styles.deviceLabel} data-flx="auth.flow.handoff-approval-flow.device-label--3">
<Trans>Location</Trans>
</span>
<span className={styles.deviceValue} data-flx="auth.flow.handoff-approval-flow.device-value--3">
{location}
</span>
</div>
) : null}
<AccountLine handoff={handoff} onUseAnotherAccount={retry} />
{matchCode != null ? (
<div className={styles.matchCheck} data-flx="auth.flow.handoff-approval-flow.match-check">
<span data-flx="auth.flow.handoff-approval-flow.match-check-label">
<Trans comment="Shown above the code on the browser approval page when the app cannot be signed in automatically. PRODUCT_NAME is the app name, such as Fluxer.">
Make sure this code matches the one in the {PRODUCT_NAME} app.
</Trans>
</span>
<HandoffCode code={matchCode} />
</div>
) : null}
<DeviceCard clientInfo={handoff.clientInfo} />
<div className={styles.buttonRow} data-flx="auth.flow.handoff-approval-flow.button-row">
<Button onClick={onDeny} variant="secondary" data-flx="auth.flow.handoff-approval-flow.button.deny">
<Trans>Deny</Trans>
<Button onClick={handoff.deny} variant="secondary" data-flx="auth.flow.handoff-approval-flow.button.deny">
{i18n._(CANCEL_DESCRIPTOR)}
</Button>
<Button onClick={onApprove} variant="primary" data-flx="auth.flow.handoff-approval-flow.button.approve">
<Trans>Approve</Trans>
<Button onClick={handoff.approve} variant="primary" data-flx="auth.flow.handoff-approval-flow.button.approve">
{i18n._(SIGN_IN_DESCRIPTOR)}
</Button>
</div>
</div>
);
}
if (mode === 'completing') {
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--5">
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--4">
<Trans>Completing sign-in…</Trans>
</h1>
<div className={styles.spinner} data-flx="auth.flow.handoff-approval-flow.spinner--2">
<Spinner data-flx="auth.flow.handoff-approval-flow.spinner-icon--2" />
</div>
</div>
);
}
if (mode === 'done') {
if (handoff.mode === DesktopHandoffMode.DONE) {
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--6">
<CheckCircleIcon
@@ -231,26 +279,80 @@ export function HandoffApprovalFlow({
data-flx="auth.flow.handoff-approval-flow.success-icon"
/>
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--5">
<Trans>Sign-in approved</Trans>
<Trans comment="Title of the browser page after the user approved signing in to the app.">
You're signed in
</Trans>
</h1>
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description--3">
<Trans>The app should now be signed in to your account.</Trans>
{handoff.returnUrl != null ? (
<Trans comment="Shown after approving, when the browser has already handed the sign-in back to the desktop app. PRODUCT_NAME is the app name, such as Fluxer.">
Go back to the {PRODUCT_NAME} app. You can close this tab.
</Trans>
) : (
<Trans comment="Shown after approving a sign-in for an app on another device, which signs in by itself shortly. PRODUCT_NAME is the app name, such as Fluxer.">
Go back to the {PRODUCT_NAME} app. It finishes signing in on its own.
</Trans>
)}
</p>
{handoff.returnUrl != null ? (
<Button
onClick={handoff.reopenApp}
variant="secondary"
fitContainer
data-flx="auth.flow.handoff-approval-flow.button.open-app"
>
<Trans comment="Button on the browser page after approving that opens the desktop app again if it did not come to the front. PRODUCT_NAME is the app name, such as Fluxer.">
Open {PRODUCT_NAME}
</Trans>
</Button>
) : null}
</div>
);
}
if (mode === 'error') {
if (handoff.mode === DesktopHandoffMode.DENIED) {
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--8">
<ProhibitIcon
size={remFromPx(48)}
weight="regular"
className={styles.deniedIcon}
data-flx="auth.flow.handoff-approval-flow.denied-icon"
/>
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--7">
<Trans comment="Title of the browser page after the user cancelled the sign-in request from the app.">
Sign-in cancelled
</Trans>
</h1>
<p className={styles.description} data-flx="auth.flow.handoff-approval-flow.description--4">
<Trans comment="Shown after the user cancelled the sign-in request in the browser. PRODUCT_NAME is the app name, such as Fluxer.">
The {PRODUCT_NAME} app was not signed in. You can close this tab.
</Trans>
</p>
<Button
onClick={retry}
variant="secondary"
fitContainer
data-flx="auth.flow.handoff-approval-flow.button.restart"
>
<Trans comment="Button on the browser page after cancelling that goes back to choosing an account.">
Start over
</Trans>
</Button>
</div>
);
}
if (handoff.mode === DesktopHandoffMode.ERROR) {
return (
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--7">
<h1 className={styles.title} data-flx="auth.flow.handoff-approval-flow.title--6">
{i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR)}
</h1>
{error ? (
{handoff.error ? (
<p className={styles.error} role="alert" data-flx="auth.flow.handoff-approval-flow.error">
{error}
{handoff.error}
</p>
) : null}
<Button onClick={onRetry} fitContainer data-flx="auth.flow.handoff-approval-flow.button.retry">
<Button onClick={retry} fitContainer data-flx="auth.flow.handoff-approval-flow.button.retry">
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
</div>
@@ -258,32 +360,3 @@ export function HandoffApprovalFlow({
}
return null;
}
interface ConnectedHandoffApprovalFlowProps {
handoff: {
mode: DesktopHandoffMode;
error: string | null;
clientInfo: DesktopHandoffInfoResponse['client_info'];
proceedToCodeInput: () => void;
submitCode: (code: string) => void;
approve: () => void;
deny: () => void;
retry: () => void;
};
}
export function ConnectedHandoffApprovalFlow({handoff}: ConnectedHandoffApprovalFlowProps) {
return (
<HandoffApprovalFlow
mode={handoff.mode}
error={handoff.error}
clientInfo={handoff.clientInfo}
onProceedToCodeInput={handoff.proceedToCodeInput}
onSubmitCode={handoff.submitCode}
onApprove={handoff.approve}
onDeny={handoff.deny}
onRetry={handoff.retry}
data-flx="auth.flow.handoff-approval-flow.connected-handoff-approval-flow.handoff-approval-flow"
/>
);
}
@@ -162,6 +162,14 @@
object-fit: contain;
}
.instanceLogo img {
display: block;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
object-fit: cover;
}
.instanceMeta {
display: grid;
gap: 0.125rem;
@@ -45,6 +45,14 @@
object-fit: contain;
}
.browserInstanceLogo img {
display: block;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
object-fit: cover;
}
.browserInstanceMeta {
display: grid;
gap: 0.125rem;
@@ -100,6 +108,13 @@
text-align: center;
}
.browserStatusTitle {
color: var(--text-primary);
font-size: 1rem;
font-weight: 700;
line-height: 1.25rem;
}
.browserStatusText {
color: var(--text-secondary);
font-size: 0.875rem;
@@ -116,7 +131,7 @@
padding: 1rem 0.75rem;
}
.browserManualCode {
.browserCode {
max-width: 100%;
padding: 0.75rem 0.875rem;
border: 0.0625rem solid var(--background-modifier-accent);
@@ -140,10 +155,25 @@
font-variant-numeric: tabular-nums;
}
.browserTimerExpired {
color: var(--status-danger);
font-size: 0.8125rem;
.browserTextButton {
padding: 0.125rem 0.25rem;
border: none;
border-radius: 0.25rem;
background: none;
color: var(--text-link);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.browserTextButton:hover:not(:disabled) {
text-decoration: underline;
}
.browserTextButton:disabled {
cursor: default;
opacity: 0.5;
}
.browserFooter {
@@ -2,15 +2,12 @@
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {resolveSnapshotInstanceDomain} from '@app/features/auth/AccountDisplayUtils';
import {InstanceBrandMark} from '@app/features/auth/components/InstanceBrandMark';
import loginStyles from '@app/features/auth/components/pages/LoginPage.module.css';
import styles from '@app/features/auth/flow/auth_login_core/AuthLoginBrowserStep.module.css';
import {
DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR,
DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR,
} from '@app/features/auth/flow/browser_handoff/BrowserHandoffDescriptors';
import {
BrowserLoginHandoffAction,
type BrowserLoginHandoffPendingAction,
type BrowserLoginHandoffController,
useBrowserLoginHandoff,
} from '@app/features/auth/flow/browser_handoff/useBrowserLoginHandoff';
import {
@@ -19,9 +16,11 @@ import {
resolveInstanceLabel,
} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
import {useKnownInstances} from '@app/features/auth/flow/instance_selector/useKnownInstances';
import {resolveInstanceBrandIconUrl} from '@app/features/auth/InstanceBranding';
import type {LoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
import {
BACK_DESCRIPTOR,
CANCEL_DESCRIPTOR,
CHANGE_INSTANCE_DESCRIPTOR,
COPIED_DESCRIPTOR,
COPY_CODE_DESCRIPTOR,
@@ -32,12 +31,12 @@ import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {Button, ButtonVariant} from '@app/features/ui/button/Button';
import {VerifiedConnectionIcon} from '@app/features/ui/components/icons/VerifiedConnectionIcon';
import {Spinner, SpinnerSize} from '@app/features/ui/components/Spinner';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {SteppedCarousel, SteppedCarouselActions} from '@app/features/ui/stepped_carousel/SteppedCarousel';
import {isDesktop} from '@app/features/ui/utils/NativeUtils';
import {flxElementClassName} from '@app/lib/react';
import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react';
import {DesktopHandoffReturnMethod} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {isOfficialInstanceHost, OFFICIAL_INSTANCE_NAME} from '@fluxer/instance_bootstrap/src/OfficialInstance';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {
@@ -46,28 +45,14 @@ import {
ArrowSquareOutIcon,
CheckCircleIcon,
ClipboardIcon,
GlobeIcon,
PasswordIcon,
WarningCircleIcon,
} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
import {type ReactElement, type ReactNode, useCallback, useEffect, useRef, useState} from 'react';
const OPEN_BROWSER_DESCRIPTOR = msg({
message: 'Open browser',
comment: 'Button label that opens the browser sign-in handoff page.',
});
const SHOW_CODE_DESCRIPTOR = msg({
message: 'Show code',
comment: 'Button label that opens the manual browser handoff code fallback.',
});
const GENERATE_NEW_CODE_DESCRIPTOR = msg({
message: 'Generate new code',
comment: 'Button label that replaces an expired browser sign-in code with a fresh one.',
});
const BROWSER_CODE_EXPIRED_DESCRIPTOR = msg({
message: 'This code expired.',
comment: 'Status shown under the browser sign-in code once its countdown reaches zero.',
const OPEN_BROWSER_AGAIN_DESCRIPTOR = msg({
message: 'Open browser again',
comment: 'Button in the desktop app that reopens the browser sign-in page while the app waits for the browser.',
});
const BROWSER_WAIT_STOPPED_DESCRIPTOR = msg({
message: "Fluxer stopped checking whether the browser approved this sign-in. It didn't lose your code.",
@@ -78,6 +63,10 @@ const BROWSER_HANDOFF_UNSUPPORTED_INSTANCE_DESCRIPTOR = msg({
comment:
'Explanation shown when the selected instance advertises no web app endpoint. Instance name is interpolated.',
});
const BROWSER_HANDOFF_FAILED_DESCRIPTOR = msg({
message: "Couldn't start signing in with your browser.",
comment: 'Shown in the desktop app when the browser sign-in request could not be created.',
});
const logger = new Logger('AuthLoginBrowserStep');
@@ -86,25 +75,26 @@ export function resolveShouldOfferBrowserStep(): boolean {
}
export const BrowserHandoffStep = Object.freeze({
READY: 'ready',
WAITING: 'waiting',
MANUAL: 'manual',
OTHER_DEVICE: 'other_device',
PROBLEM: 'problem',
} as const);
export type BrowserHandoffStep = (typeof BrowserHandoffStep)[keyof typeof BrowserHandoffStep];
const BROWSER_STEPS: ReadonlyArray<BrowserHandoffStep> = Object.freeze([
BrowserHandoffStep.READY,
BrowserHandoffStep.WAITING,
BrowserHandoffStep.MANUAL,
BrowserHandoffStep.OTHER_DEVICE,
BrowserHandoffStep.PROBLEM,
]);
type ManualReturnStep = typeof BrowserHandoffStep.READY | typeof BrowserHandoffStep.WAITING;
type RetryStep = typeof BrowserHandoffStep.WAITING | typeof BrowserHandoffStep.OTHER_DEVICE;
interface BrowserInstanceIdentity {
readonly domain: string;
readonly isOfficial: boolean;
readonly name: string;
readonly iconUrl: string | null;
}
function findKnownInstanceName(domain: string, knownInstances: ReadonlyArray<InstanceInfo>): string | null {
@@ -129,340 +119,194 @@ function resolveBrowserInstanceIdentity({
const domain = resolveSnapshotInstanceDomain(snapshot) ?? apiEndpoint;
const isOfficial = isOfficialInstanceHost(apiEndpoint);
if (isOfficial) {
return {domain, isOfficial, name: OFFICIAL_INSTANCE_NAME};
return {domain, isOfficial, name: OFFICIAL_INSTANCE_NAME, iconUrl: null};
}
const iconUrl = resolveInstanceBrandIconUrl(snapshot);
const productLabel = resolveInstanceLabel(snapshot.appPublic?.branding?.product_name, domain);
if (productLabel !== instanceDomainHost(domain)) {
return {domain, isOfficial, name: productLabel};
return {domain, isOfficial, name: productLabel, iconUrl};
}
return {domain, isOfficial, name: resolveInstanceLabel(findKnownInstanceName(domain, knownInstances), domain)};
}
function resolveManualReturnStep(step: BrowserHandoffStep): ManualReturnStep {
if (step === BrowserHandoffStep.WAITING) {
return BrowserHandoffStep.WAITING;
}
return BrowserHandoffStep.READY;
}
function renderCopyCodeIcon(copied: boolean): ReactNode {
if (copied) {
return (
<CheckCircleIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.render-copy-code-icon.check-circle-icon"
/>
);
}
return (
<ClipboardIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.render-copy-code-icon.clipboard-icon"
/>
);
}
function resolveCopyCodeLabel(i18n: I18n, copied: boolean): string {
if (copied) {
return i18n._(COPIED_DESCRIPTOR);
}
return i18n._(COPY_CODE_DESCRIPTOR);
}
interface BrowserStepActionsProps {
readonly activeBackAction: () => void;
readonly canStart: boolean;
readonly copied: boolean;
readonly copyCode: () => void;
readonly hasCode: boolean;
readonly hasStoppedWaiting: boolean;
readonly isExpired: boolean;
readonly isGenerating: boolean;
readonly onOpenBrowser: () => void;
readonly onRegenerateCode: () => void;
readonly onResumeWaiting: () => void;
readonly onShowCode: () => void;
readonly pendingAction: BrowserLoginHandoffPendingAction;
readonly showBackButton: boolean;
readonly step: BrowserHandoffStep;
}
function BrowserStepActions({
activeBackAction,
canStart,
copied,
copyCode,
hasCode,
hasStoppedWaiting,
isExpired,
isGenerating,
onOpenBrowser,
onRegenerateCode,
onResumeWaiting,
onShowCode,
pendingAction,
showBackButton,
step,
}: BrowserStepActionsProps): ReactElement {
const {i18n} = useLingui();
const handoffButtonState = (action: BrowserLoginHandoffAction): {disabled: boolean; submitting: boolean} => ({
disabled: !canStart || (isGenerating && pendingAction !== action),
submitting: isGenerating && pendingAction === action,
});
const regenerateButton = (
<Button
onClick={onRegenerateCode}
{...handoffButtonState(BrowserLoginHandoffAction.SHOW_CODE)}
leftIcon={
<ArrowClockwiseIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.arrow-clockwise-icon"
/>
}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.generate-new-code"
>
{i18n._(GENERATE_NEW_CODE_DESCRIPTOR)}
</Button>
);
const showCodeButton = (
<Button
variant={ButtonVariant.SECONDARY}
onClick={onShowCode}
{...handoffButtonState(BrowserLoginHandoffAction.SHOW_CODE)}
leftIcon={
<PasswordIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.password-icon"
/>
}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.show-code"
>
{i18n._(SHOW_CODE_DESCRIPTOR)}
</Button>
);
const resumeButton = hasStoppedWaiting ? (
<Button
onClick={onResumeWaiting}
leftIcon={
<ArrowClockwiseIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.resume-icon"
/>
}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.resume-waiting"
>
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
) : null;
let primaryActions: ReactNode;
if (step === BrowserHandoffStep.MANUAL) {
primaryActions = (
<>
{isExpired ? (
regenerateButton
) : (
<Button
onClick={copyCode}
disabled={!hasCode}
leftIcon={renderCopyCodeIcon(copied)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.copy-code"
>
{resolveCopyCodeLabel(i18n, copied)}
</Button>
)}
{resumeButton}
</>
);
} else if (step === BrowserHandoffStep.WAITING) {
primaryActions = (
<>
{showCodeButton}
{resumeButton}
</>
);
} else {
primaryActions = (
<>
{showCodeButton}
<Button
onClick={onOpenBrowser}
{...handoffButtonState(BrowserLoginHandoffAction.OPEN_BROWSER)}
leftIcon={
<ArrowSquareOutIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.arrow-square-out-icon"
/>
}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.open-browser"
>
{i18n._(OPEN_BROWSER_DESCRIPTOR)}
</Button>
</>
);
}
const renderBackAction = (): ReactNode => {
if (!showBackButton) {
return null;
}
return (
<Button
type="button"
variant={ButtonVariant.SECONDARY}
leftIcon={
<ArrowLeftIcon
size={remFromPx(16)}
weight="bold"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.render-back-action.arrow-left-icon"
/>
}
onClick={activeBackAction}
disabled={isGenerating}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.back"
>
{i18n._(BACK_DESCRIPTOR)}
</Button>
);
return {
domain,
isOfficial,
name: resolveInstanceLabel(findKnownInstanceName(domain, knownInstances), domain),
iconUrl,
};
}
function resolveSignInAddress(webAppEndpoint: string): string {
try {
const url = new URL(webAppEndpoint);
return `${url.host}${url.pathname.replace(/\/+$/u, '')}/login?handoff=1`;
} catch {
return `${webAppEndpoint}/login?handoff=1`;
}
}
function HandoffCode({code}: {code: string}): ReactElement {
return (
<SteppedCarouselActions
className={styles.browserFooter}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions.browser-footer"
<flx-auth-login-browser-step-code
className={flxElementClassName(styles.browserCode)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.code"
>
{renderBackAction()}
{primaryActions}
</SteppedCarouselActions>
{code}
</flx-auth-login-browser-step-code>
);
}
interface BrowserStepContentProps {
readonly canStart: boolean;
readonly displayCode: string;
readonly hasStoppedWaiting: boolean;
interface WaitingContentProps {
readonly handoff: BrowserLoginHandoffController;
readonly instanceName: string;
readonly isExpired: boolean;
readonly remainingSeconds: number | null;
readonly step: BrowserHandoffStep;
readonly onOtherDevice: () => void;
}
function renderManualCodeTimer(i18n: I18n, isExpired: boolean, remainingSeconds: number | null): ReactNode {
if (remainingSeconds == null) {
return null;
}
if (isExpired) {
return (
<flx-auth-login-browser-step-timer
className={flxElementClassName(styles.browserTimerExpired)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-timer-expired"
>
{i18n._(BROWSER_CODE_EXPIRED_DESCRIPTOR)}
</flx-auth-login-browser-step-timer>
);
}
function WaitingContent({handoff, instanceName, onOtherDevice}: WaitingContentProps): ReactElement {
const {i18n} = useLingui();
const showCode = handoff.returnMethod === DesktopHandoffReturnMethod.CODE && handoff.hasCode;
return (
<flx-auth-login-browser-step-timer
className={flxElementClassName(styles.browserTimer)}
aria-live="off"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-timer"
<flx-auth-login-browser-step-status-panel
className={flxElementClassName(styles.browserStatusPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-panel"
>
<Trans>Expires in {remainingSeconds}s</Trans>
</flx-auth-login-browser-step-timer>
{handoff.hasStoppedWaiting ? (
<WarningCircleIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.stopped-waiting-icon"
/>
) : (
<Spinner size={SpinnerSize.MEDIUM} data-flx="auth.flow.auth-login-core.auth-login-browser-step.spinner" />
)}
<flx-auth-login-browser-step-status-title
className={flxElementClassName(styles.browserStatusTitle)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-title"
>
<Trans comment="Title in the desktop app while it waits for the user to sign in on the browser page it opened.">
Continue in your browser
</Trans>
</flx-auth-login-browser-step-status-title>
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-text"
>
{handoff.hasStoppedWaiting ? (
i18n._(BROWSER_WAIT_STOPPED_DESCRIPTOR)
) : showCode ? (
<Trans comment="Shown in the desktop app above the code when the browser cannot return the sign-in automatically. instanceName is the server name, such as Fluxer.">
Sign in to {instanceName} in your browser, and check that it shows this code:
</Trans>
) : (
<Trans comment="Shown in the desktop app while it waits for the browser. instanceName is the server name, such as Fluxer.">
Sign in to {instanceName} in your browser and confirm. This app finishes signing in on its own.
</Trans>
)}
</flx-auth-login-browser-step-status-text>
{showCode && !handoff.hasStoppedWaiting ? <HandoffCode code={handoff.displayCode} /> : null}
<FocusRing offset={-2} data-flx="auth.flow.auth-login-core.auth-login-browser-step.focus-ring.other-device">
<button
type="button"
className={styles.browserTextButton}
onClick={onOtherDevice}
disabled={!handoff.canStart}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.other-device"
>
<Trans comment="Text button in the desktop app that shows a code to type into a browser on a different device.">
Signing in on another device?
</Trans>
</button>
</FocusRing>
</flx-auth-login-browser-step-status-panel>
);
}
function BrowserStepContent({
canStart,
displayCode,
hasStoppedWaiting,
instanceName,
isExpired,
remainingSeconds,
step,
}: BrowserStepContentProps): ReactElement {
function OtherDeviceContent({handoff, signInAddress}: {handoff: BrowserLoginHandoffController; signInAddress: string}) {
const {i18n} = useLingui();
if (step === BrowserHandoffStep.READY) {
return (
<flx-auth-login-browser-step-status-panel
className={flxElementClassName(styles.browserStatusPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-panel"
>
{canStart ? (
<ArrowSquareOutIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.arrow-square-out-icon"
/>
) : (
<WarningCircleIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.warning-circle-icon"
/>
)}
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-text"
>
{canStart ? (
<Trans>Open your browser to finish signing in.</Trans>
) : (
i18n._(BROWSER_HANDOFF_UNSUPPORTED_INSTANCE_DESCRIPTOR, {instanceName})
)}
</flx-auth-login-browser-step-status-text>
</flx-auth-login-browser-step-status-panel>
);
}
if (step === BrowserHandoffStep.WAITING) {
return (
<flx-auth-login-browser-step-status-panel
className={flxElementClassName(styles.browserStatusPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-panel--2"
>
{hasStoppedWaiting ? (
<WarningCircleIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.stopped-waiting-icon"
/>
) : (
<Spinner
size={SpinnerSize.MEDIUM}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.spinner"
/>
)}
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-status-text--2"
>
{hasStoppedWaiting ? i18n._(BROWSER_WAIT_STOPPED_DESCRIPTOR) : <Trans>Waiting for browser approval.</Trans>}
</flx-auth-login-browser-step-status-text>
</flx-auth-login-browser-step-status-panel>
);
}
const remainingSeconds = handoff.remainingSeconds;
return (
<flx-auth-login-browser-step-manual-panel
className={flxElementClassName(styles.browserManualPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-manual-panel"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-panel"
>
<flx-auth-login-browser-step-manual-code
className={flxElementClassName(styles.browserManualCode)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content.browser-manual-code"
<flx-auth-login-browser-step-status-title
className={flxElementClassName(styles.browserStatusTitle)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-title"
>
{displayCode}
</flx-auth-login-browser-step-manual-code>
{renderManualCodeTimer(i18n, isExpired, remainingSeconds)}
<Trans comment="Title in the desktop app above the code the user types into a browser on another device.">
Enter this code in your browser
</Trans>
</flx-auth-login-browser-step-status-title>
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-text"
>
<Trans comment="One line explanation in the desktop app above the code. signInAddress is the web address of the sign-in page, such as web.fluxer.app/login?handoff=1.">
On your other device, sign in at {signInAddress} and enter this code when asked.
</Trans>
</flx-auth-login-browser-step-status-text>
{handoff.hasCode ? (
<HandoffCode code={handoff.displayCode} />
) : (
<Spinner size={SpinnerSize.MEDIUM} data-flx="auth.flow.auth-login-core.auth-login-browser-step.code-spinner" />
)}
<flx-auth-login-browser-step-timer
className={flxElementClassName(styles.browserTimer)}
aria-live="off"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.timer"
>
{remainingSeconds == null ? ' ' : <Trans>Expires in {remainingSeconds}s</Trans>}
</flx-auth-login-browser-step-timer>
{handoff.hasStoppedWaiting ? (
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="status"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-stopped"
>
{i18n._(BROWSER_WAIT_STOPPED_DESCRIPTOR)}
</flx-auth-login-browser-step-status-text>
) : null}
</flx-auth-login-browser-step-manual-panel>
);
}
function ProblemContent({message}: {message: string}): ReactElement {
return (
<flx-auth-login-browser-step-status-panel
className={flxElementClassName(styles.browserStatusPanel)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-panel"
>
<WarningCircleIcon
size={remFromPx(28)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-icon"
/>
<flx-auth-login-browser-step-status-text
className={flxElementClassName(styles.browserStatusText)}
role="alert"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-text"
>
{message}
</flx-auth-login-browser-step-status-text>
</flx-auth-login-browser-step-status-panel>
);
}
function ButtonIcon({icon}: {icon: 'open' | 'retry' | 'back' | 'copy' | 'copied'}): ReactElement {
const props = {size: remFromPx(16), weight: 'bold' as const};
switch (icon) {
case 'open':
return <ArrowSquareOutIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.open" />;
case 'retry':
return <ArrowClockwiseIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.retry" />;
case 'back':
return <ArrowLeftIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.back" />;
case 'copy':
return <ClipboardIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.copy" />;
case 'copied':
return <CheckCircleIcon {...props} data-flx="auth.flow.auth-login-core.auth-login-browser-step.icon.copied" />;
}
}
interface AuthLoginBrowserStepProps {
readonly extraTopContent?: ReactNode;
readonly prefillIdentifier?: string | null;
@@ -489,89 +333,86 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
showBackButton = true,
}: AuthLoginBrowserStepProps) {
const {i18n} = useLingui();
const [step, setStep] = useState<BrowserHandoffStep>(BrowserHandoffStep.READY);
const [manualReturnStep, setManualReturnStep] = useState<ManualReturnStep>(BrowserHandoffStep.READY);
const [step, setStep] = useState<BrowserHandoffStep>(BrowserHandoffStep.WAITING);
const [retryStep, setRetryStep] = useState<RetryStep>(BrowserHandoffStep.WAITING);
const knownInstances = useKnownInstances();
const instanceIdentity = resolveBrowserInstanceIdentity({snapshot: runtimeSnapshot, knownInstances});
const handoffMessages = useMemo(
() => ({
desktopHandoffUnavailable: DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR,
expired: DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR,
}),
[],
);
const handleExpired = useCallback(() => {
setManualReturnStep(BrowserHandoffStep.READY);
setStep(BrowserHandoffStep.READY);
const handleEnded = useCallback(() => {
setStep(BrowserHandoffStep.PROBLEM);
}, []);
const {
canStart,
copied,
copyCode,
displayCode,
error,
expireCurrentSession,
hasCode,
hasStoppedWaiting,
isCurrentSessionExpired,
isExpired,
isGenerating,
openBrowser,
pendingAction,
regenerateCode,
remainingSeconds,
resumeWaiting,
showManualCode,
} = useBrowserLoginHandoff({
const handoff = useBrowserLoginHandoff({
runtimeSnapshot,
prefillIdentifier,
messages: handoffMessages,
onExpired: handleExpired,
onEnded: handleEnded,
onSuccess,
});
const {canStart, openBrowser, regenerateCode, reset, showManualCode} = handoff;
const showProblem = useCallback((from: RetryStep) => {
setRetryStep(from);
setStep(BrowserHandoffStep.PROBLEM);
}, []);
const handleOpenBrowser = useCallback(() => {
setStep(BrowserHandoffStep.WAITING);
openBrowser()
.then((opened) => {
if (opened) {
setStep(BrowserHandoffStep.WAITING);
if (!opened) {
showProblem(BrowserHandoffStep.WAITING);
}
})
.catch((caught) => {
logger.error('Failed to open the browser sign-in page', caught);
});
}, [openBrowser]);
const handleShowCode = useCallback(() => {
const returnStep = resolveManualReturnStep(step);
}, [openBrowser, showProblem]);
const handleOtherDevice = useCallback(() => {
setRetryStep(BrowserHandoffStep.OTHER_DEVICE);
setStep(BrowserHandoffStep.OTHER_DEVICE);
showManualCode()
.then((shown) => {
if (shown) {
setManualReturnStep(returnStep);
setStep(BrowserHandoffStep.MANUAL);
if (!shown) {
showProblem(BrowserHandoffStep.OTHER_DEVICE);
}
})
.catch((caught) => {
logger.error('Failed to reveal the browser sign-in code', caught);
logger.error('Failed to show the browser sign-in code', caught);
});
}, [showManualCode, step]);
const handleRegenerateCode = useCallback(() => {
regenerateCode()
.then((generated) => {
if (!generated) {
setStep(BrowserHandoffStep.READY);
}
})
.catch((caught) => {
logger.error('Failed to regenerate the browser sign-in code', caught);
});
}, [regenerateCode]);
const handleBackFromManualCode = useCallback(() => {
if (isCurrentSessionExpired()) {
expireCurrentSession();
}, [showManualCode, showProblem]);
const handleRetry = useCallback(() => {
if (retryStep === BrowserHandoffStep.OTHER_DEVICE) {
setStep(BrowserHandoffStep.OTHER_DEVICE);
regenerateCode()
.then((generated) => {
if (!generated) {
showProblem(BrowserHandoffStep.OTHER_DEVICE);
}
})
.catch((caught) => {
logger.error('Failed to create a new browser sign-in code', caught);
});
return;
}
setStep(manualReturnStep);
}, [expireCurrentSession, isCurrentSessionExpired, manualReturnStep]);
const activeBackAction = step === BrowserHandoffStep.MANUAL ? handleBackFromManualCode : onBack;
handleOpenBrowser();
}, [handleOpenBrowser, regenerateCode, retryStep, showProblem]);
const handleCancel = useCallback(() => {
reset();
onBack();
}, [onBack, reset]);
const handleBackToWaiting = useCallback(() => {
setRetryStep(BrowserHandoffStep.WAITING);
setStep(BrowserHandoffStep.WAITING);
}, []);
const startedRef = useRef(false);
useEffect(() => {
if (startedRef.current) {
return;
}
startedRef.current = true;
if (canStart) {
handleOpenBrowser();
} else {
setStep(BrowserHandoffStep.PROBLEM);
}
}, [canStart, handleOpenBrowser]);
const activeBackAction = step === BrowserHandoffStep.OTHER_DEVICE ? handleBackToWaiting : handleCancel;
useEffect(() => {
if (onBackActionChange == null) {
return;
@@ -579,6 +420,124 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
onBackActionChange(activeBackAction);
return () => onBackActionChange(null);
}, [activeBackAction, onBackActionChange]);
const problemMessage = canStart
? (handoff.error ?? i18n._(BROWSER_HANDOFF_FAILED_DESCRIPTOR))
: i18n._(BROWSER_HANDOFF_UNSUPPORTED_INSTANCE_DESCRIPTOR, {instanceName: instanceIdentity.name});
const isOpening = handoff.isGenerating && handoff.pendingAction === BrowserLoginHandoffAction.OPEN_BROWSER;
const isMakingCode = handoff.isGenerating && handoff.pendingAction === BrowserLoginHandoffAction.SHOW_CODE;
let content: ReactNode;
let actions: ReactNode;
if (step === BrowserHandoffStep.OTHER_DEVICE) {
content = (
<OtherDeviceContent
handoff={handoff}
signInAddress={resolveSignInAddress(runtimeSnapshot.webAppEndpoint)}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.other-device-content"
/>
);
actions = (
<>
{showBackButton ? (
<Button
variant={ButtonVariant.SECONDARY}
leftIcon={<ButtonIcon icon="back" />}
onClick={handleBackToWaiting}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.back-to-waiting"
>
{i18n._(BACK_DESCRIPTOR)}
</Button>
) : null}
{handoff.hasStoppedWaiting ? (
<Button
leftIcon={<ButtonIcon icon="retry" />}
onClick={handoff.resumeWaiting}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.resume-waiting"
>
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
) : (
<Button
leftIcon={<ButtonIcon icon={handoff.copied ? 'copied' : 'copy'} />}
onClick={handoff.copyCode}
disabled={!handoff.hasCode}
submitting={isMakingCode}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.copy-code"
>
{i18n._(handoff.copied ? COPIED_DESCRIPTOR : COPY_CODE_DESCRIPTOR)}
</Button>
)}
</>
);
} else if (step === BrowserHandoffStep.PROBLEM) {
content = (
<ProblemContent
message={problemMessage}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.problem-content"
/>
);
actions = (
<>
{showBackButton ? (
<Button
variant={ButtonVariant.SECONDARY}
leftIcon={<ButtonIcon icon="back" />}
onClick={handleCancel}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.back"
>
{i18n._(BACK_DESCRIPTOR)}
</Button>
) : null}
{canStart ? (
<Button
leftIcon={<ButtonIcon icon="retry" />}
onClick={handleRetry}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.retry"
>
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
) : null}
</>
);
} else {
content = (
<WaitingContent
handoff={handoff}
instanceName={instanceIdentity.name}
onOtherDevice={handleOtherDevice}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.waiting-content"
/>
);
actions = (
<>
<Button
variant={ButtonVariant.SECONDARY}
onClick={handleCancel}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.cancel"
>
{i18n._(CANCEL_DESCRIPTOR)}
</Button>
{handoff.hasStoppedWaiting ? (
<Button
leftIcon={<ButtonIcon icon="retry" />}
onClick={handoff.resumeWaiting}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.resume-waiting"
>
{i18n._(TRY_AGAIN_DESCRIPTOR)}
</Button>
) : (
<Button
leftIcon={<ButtonIcon icon="open" />}
onClick={handleOpenBrowser}
submitting={isOpening}
disabled={!canStart}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.button.open-browser"
>
{i18n._(OPEN_BROWSER_AGAIN_DESCRIPTOR)}
</Button>
)}
</>
);
}
return (
<flx-auth-login-browser-step
className="flx-element"
@@ -606,19 +565,12 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
className={styles.browserInstanceLogo}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-instance-logo"
>
{instanceIdentity.isOfficial ? (
<FluxerLogoAsset
role="img"
aria-label={OFFICIAL_INSTANCE_NAME}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.img"
/>
) : (
<GlobeIcon
size={remFromPx(20)}
weight="regular"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.globe-icon"
/>
)}
<InstanceBrandMark
isOfficial={instanceIdentity.isOfficial}
iconUrl={instanceIdentity.iconUrl}
size={20}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.instance-brand-mark"
/>
</span>
<span
className={styles.browserInstanceMeta}
@@ -673,49 +625,19 @@ export const AuthLoginBrowserStep = observer(function AuthLoginBrowserStep({
</Button>
)}
</flx-auth-login-browser-step-instance-row>
{error != null && error.length > 0 ? (
<div
className={loginStyles.loginNotice}
role="alert"
data-flx="auth.flow.auth-login-core.auth-login-browser-step.alert"
>
{error}
</div>
) : null}
<SteppedCarousel
step={step}
steps={BROWSER_STEPS}
focusOnStepChange
data-flx="auth.flow.auth-login-core.auth-login-browser-step.carousel"
>
<BrowserStepContent
canStart={canStart}
displayCode={displayCode}
hasStoppedWaiting={hasStoppedWaiting}
instanceName={instanceIdentity.name}
isExpired={isExpired}
remainingSeconds={remainingSeconds}
step={step}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-content"
/>
<BrowserStepActions
activeBackAction={activeBackAction}
canStart={canStart}
copied={copied}
copyCode={copyCode}
hasCode={hasCode}
hasStoppedWaiting={hasStoppedWaiting}
isExpired={isExpired}
isGenerating={isGenerating}
onOpenBrowser={handleOpenBrowser}
onRegenerateCode={handleRegenerateCode}
onResumeWaiting={resumeWaiting}
onShowCode={handleShowCode}
pendingAction={pendingAction}
showBackButton={showBackButton}
step={step}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-step-actions"
/>
{content}
<SteppedCarouselActions
className={styles.browserFooter}
data-flx="auth.flow.auth-login-core.auth-login-browser-step.browser-footer"
>
{actions}
</SteppedCarouselActions>
</SteppedCarousel>
</flx-auth-login-browser-step-pane>
</flx-auth-login-browser-step>
@@ -6,12 +6,12 @@ export const DesktopHandoffMode = Object.freeze({
IDLE: 'idle',
SELECTING: 'selecting',
LOGIN: 'login',
WARNING: 'warning',
CODE_INPUT: 'code_input',
FETCHING_INFO: 'fetching_info',
APPROVING: 'approving',
COMPLETING: 'completing',
DONE: 'done',
DENIED: 'denied',
ERROR: 'error',
} as const);
@@ -19,12 +19,12 @@ export type DesktopHandoffMode = (typeof DesktopHandoffMode)[keyof typeof Deskto
export function isApprovalFlowMode(mode: DesktopHandoffMode): boolean {
switch (mode) {
case DesktopHandoffMode.WARNING:
case DesktopHandoffMode.CODE_INPUT:
case DesktopHandoffMode.FETCHING_INFO:
case DesktopHandoffMode.APPROVING:
case DesktopHandoffMode.COMPLETING:
case DesktopHandoffMode.DONE:
case DesktopHandoffMode.DENIED:
case DesktopHandoffMode.ERROR:
return true;
case DesktopHandoffMode.IDLE:
@@ -1,18 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/AuthenticationCommands';
import type {
DesktopHandoffInfoResponse,
DesktopHandoffReturnMethod,
} from '@app/features/auth/commands/AuthenticationCommands';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/AuthLoginStepTypes';
import type {UserData} from '@app/features/auth/state/AccountStorage';
import {type InstanceHTTPTarget, instanceTargetFromSnapshot} from '@app/features/platform/transport/InstanceHTTP';
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as FormUtils from '@app/lib/forms';
import {formatDesktopHandoffCode, parseDesktopHandoffCode} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {useCallback, useMemo, useRef, useState} from 'react';
const INVALID_OR_EXPIRED_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Invalid or expired code. Try again.',
comment: 'Desktop handoff flow error shown when the entered pairing code is invalid or expired.',
const HANDOFF_REQUEST_EXPIRED_DESCRIPTOR = msg({
message: 'This sign-in request expired or was cancelled in the {productName} app. Start again from the app.',
comment:
'Error in the browser when the sign-in request from the desktop app no longer exists, because it expired or the app cancelled it. productName is the app name, such as Fluxer.',
});
const TYPED_CODE_NOT_FOUND_DESCRIPTOR = msg({
message: 'That code is not valid on {instanceHost}. Check the code in the {productName} app and try again.',
comment:
'Error in the browser when a code typed from the app is not found. instanceHost is the server the browser checked, such as api.fluxer.app. productName is the app name, such as Fluxer.',
});
const COULDN_T_VERIFY_THAT_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: "Couldn't verify that code. Try again.",
@@ -22,11 +35,66 @@ const COULDN_T_COMPLETE_SIGN_IN_PLEASE_TRY_AGAIN_DESCRIPTOR = msg({
message: "Couldn't complete sign-in. Try again.",
comment: 'Desktop handoff flow error shown when finalizing the sign-in fails after a valid code.',
});
const INSTANCE_MISMATCH_DESCRIPTOR = msg({
message:
'This account is on {accountHost}, but the {productName} app is signing in to {requestHost}. Choose an account on {requestHost}.',
comment:
'Error in the browser when the chosen account belongs to a different server than the app that asked to sign in. accountHost and requestHost are server addresses. productName is the app name, such as Fluxer.',
});
const logger = new Logger('useDesktopHandoffFlow');
export const DesktopHandoffCodeSource = Object.freeze({
LINK: 'link',
TYPED: 'typed',
} as const);
export type DesktopHandoffCodeSource = (typeof DesktopHandoffCodeSource)[keyof typeof DesktopHandoffCodeSource];
export function isHandoffRequest(params: URLSearchParams): boolean {
return params.get('handoff') === '1' || params.get('desktop_handoff') === '1';
}
export interface DesktopHandoffRequest {
readonly code: string | null;
readonly apiOrigin: string | null;
readonly identifier: string | null;
}
function readOrigin(value: string | null | undefined): string | null {
if (value == null || value.length === 0) {
return null;
}
try {
return new URL(value).origin;
} catch {
return null;
}
}
export function readDesktopHandoffRequest(search: string): DesktopHandoffRequest {
const params = new URLSearchParams(search);
const code = parseDesktopHandoffCode(params.get('code') ?? '');
return {
code: code == null ? null : formatDesktopHandoffCode(code),
apiOrigin: readOrigin(params.get('api')),
identifier: params.get('email') ?? params.get('login'),
};
}
function hostOf(endpoint: string): string {
try {
return new URL(endpoint).host;
} catch {
return endpoint;
}
}
function matchesIdentifier(identifier: string, userData: UserData): boolean {
const wanted = identifier.trim().toLowerCase();
return wanted === userData.username.toLowerCase() || wanted === userData.email?.toLowerCase();
}
interface Options {
enabled: boolean;
hasStoredAccounts: boolean;
@@ -37,6 +105,7 @@ export interface DesktopHandoffAccountCredentials {
readonly token: string;
readonly userId: string;
readonly runtimeSnapshot: RuntimeConfigSnapshot;
readonly userData?: UserData;
}
export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}: Options) {
@@ -46,35 +115,26 @@ export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}:
if (initialMode) return initialMode;
return hasStoredAccounts ? DesktopHandoffMode.SELECTING : DesktopHandoffMode.LOGIN;
}, [enabled, hasStoredAccounts, initialMode]);
const [request] = useState(() => readDesktopHandoffRequest(window.location.search));
const [mode, setMode] = useState<DesktopHandoffMode>(derivedInitial);
const [error, setError] = useState<string | null>(null);
const [clientInfo, setClientInfo] = useState<DesktopHandoffInfoResponse['client_info']>(null);
const [handoffCode, setHandoffCode] = useState<string | null>(null);
const [returnMethod, setReturnMethod] = useState<DesktopHandoffReturnMethod | null>(null);
const [codeSource, setCodeSource] = useState<DesktopHandoffCodeSource | null>(null);
const [returnUrl, setReturnUrl] = useState<string | null>(null);
const [account, setAccount] = useState<UserData | null>(null);
const tokenRef = useRef<string | null>(null);
const userIdRef = useRef<string | null>(null);
const operationRevisionRef = useRef(0);
const targetRef = useRef<InstanceHTTPTarget | null>(null);
const start = useCallback(
({token, userId, runtimeSnapshot}: DesktopHandoffAccountCredentials) => {
if (!enabled) return;
operationRevisionRef.current += 1;
tokenRef.current = token;
userIdRef.current = userId;
targetRef.current = instanceTargetFromSnapshot(runtimeSnapshot);
setError(null);
setHandoffCode(null);
setClientInfo(null);
setMode(DesktopHandoffMode.WARNING);
},
[enabled],
);
const proceedToCodeInput = useCallback(() => {
operationRevisionRef.current += 1;
setMode(DesktopHandoffMode.CODE_INPUT);
setError(null);
const linkCodeUsedRef = useRef(false);
const failWith = useCallback((message: string) => {
setMode(DesktopHandoffMode.ERROR);
setError(message);
}, []);
const submitCode = useCallback(
async (code: string) => {
const lookUpCode = useCallback(
async (code: string, source: DesktopHandoffCodeSource) => {
const token = tokenRef.current;
const target = targetRef.current;
if (token == null || token.length === 0 || target == null) return;
@@ -82,61 +142,118 @@ export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}:
setMode(DesktopHandoffMode.FETCHING_INFO);
setError(null);
setHandoffCode(code);
setCodeSource(source);
try {
const info = await AuthenticationCommands.fetchDesktopHandoffInfo({code, token, target});
if (operationRevision !== operationRevisionRef.current) return;
if (info.status === 'expired') {
setMode(DesktopHandoffMode.ERROR);
setError(i18n._(INVALID_OR_EXPIRED_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR));
failWith(
source === DesktopHandoffCodeSource.LINK
? i18n._(HANDOFF_REQUEST_EXPIRED_DESCRIPTOR, {productName: PRODUCT_NAME})
: i18n._(TYPED_CODE_NOT_FOUND_DESCRIPTOR, {
instanceHost: hostOf(target.apiEndpoint),
productName: PRODUCT_NAME,
}),
);
return;
}
setClientInfo(info.client_info);
setReturnMethod(
source === DesktopHandoffCodeSource.LINK && info.return_method === 'deep_link' ? 'deep_link' : 'code',
);
setMode(DesktopHandoffMode.APPROVING);
} catch (e) {
if (operationRevision !== operationRevisionRef.current) return;
setMode(DesktopHandoffMode.ERROR);
setError(
failWith(
e && typeof e === 'object' && 'body' in e
? FormUtils.extractErrorMessage(i18n, e)
: i18n._(COULDN_T_VERIFY_THAT_CODE_PLEASE_TRY_AGAIN_DESCRIPTOR),
);
}
},
[i18n],
[failWith, i18n],
);
const start = useCallback(
({token, userId, runtimeSnapshot, userData}: DesktopHandoffAccountCredentials) => {
if (!enabled) return;
operationRevisionRef.current += 1;
tokenRef.current = token;
userIdRef.current = userId;
targetRef.current = instanceTargetFromSnapshot(runtimeSnapshot);
setAccount(userData ?? null);
setError(null);
setHandoffCode(null);
setClientInfo(null);
setReturnMethod(null);
setReturnUrl(null);
const accountOrigin = readOrigin(runtimeSnapshot.apiEndpoint);
if (request.apiOrigin != null && accountOrigin != null && request.apiOrigin !== accountOrigin) {
failWith(
i18n._(INSTANCE_MISMATCH_DESCRIPTOR, {
accountHost: hostOf(accountOrigin),
requestHost: hostOf(request.apiOrigin),
productName: PRODUCT_NAME,
}),
);
return;
}
if (request.code != null && !linkCodeUsedRef.current) {
linkCodeUsedRef.current = true;
void lookUpCode(request.code, DesktopHandoffCodeSource.LINK);
return;
}
setMode(DesktopHandoffMode.CODE_INPUT);
},
[enabled, failWith, i18n, lookUpCode, request],
);
const submitCode = useCallback((code: string) => lookUpCode(code, DesktopHandoffCodeSource.TYPED), [lookUpCode]);
const approve = useCallback(async () => {
const token = tokenRef.current;
const userId = userIdRef.current;
const target = targetRef.current;
if (!handoffCode || !token || !userId || target == null) return;
if (!handoffCode || !token || !userId || target == null || returnMethod == null) return;
const operationRevision = ++operationRevisionRef.current;
setMode(DesktopHandoffMode.COMPLETING);
setError(null);
try {
await AuthenticationCommands.completeDesktopHandoff({
const completedReturnUrl = await AuthenticationCommands.completeDesktopHandoff({
code: handoffCode,
token,
userId,
returnMethod,
target,
});
if (operationRevision !== operationRevisionRef.current) return;
setReturnUrl(completedReturnUrl);
setMode(DesktopHandoffMode.DONE);
if (completedReturnUrl != null) {
window.location.assign(completedReturnUrl);
}
} catch (e) {
if (operationRevision !== operationRevisionRef.current) return;
setMode(DesktopHandoffMode.ERROR);
setError(
failWith(
e && typeof e === 'object' && 'body' in e
? FormUtils.extractErrorMessage(i18n, e)
: i18n._(COULDN_T_COMPLETE_SIGN_IN_PLEASE_TRY_AGAIN_DESCRIPTOR),
);
}
}, [handoffCode, i18n]);
}, [failWith, handoffCode, i18n, returnMethod]);
const deny = useCallback(() => {
const target = targetRef.current;
const code = handoffCode;
operationRevisionRef.current += 1;
setHandoffCode(null);
setClientInfo(null);
setMode(DesktopHandoffMode.CODE_INPUT);
}, []);
setMode(DesktopHandoffMode.DENIED);
if (code == null || target == null) return;
AuthenticationCommands.denyDesktopHandoff({code, target}).catch((caught) => {
logger.warn('Failed to tell the server that the sign-in request was declined', caught);
});
}, [handoffCode]);
const reopenApp = useCallback(() => {
if (returnUrl != null) {
window.location.assign(returnUrl);
}
}, [returnUrl]);
const switchToLogin = useCallback(() => {
operationRevisionRef.current += 1;
setMode(DesktopHandoffMode.LOGIN);
@@ -147,22 +264,34 @@ export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}:
setError(null);
setHandoffCode(null);
setClientInfo(null);
setReturnMethod(null);
setReturnUrl(null);
setAccount(null);
tokenRef.current = null;
userIdRef.current = null;
targetRef.current = null;
setMode(hasStoredAccounts ? DesktopHandoffMode.SELECTING : DesktopHandoffMode.LOGIN);
}, [hasStoredAccounts]);
const isWrongAccount =
request.identifier != null && account != null && !matchesIdentifier(request.identifier, account);
return {
mode,
error,
clientInfo,
handoffCode,
codeSource,
returnMethod,
returnUrl,
account,
requestedIdentifier: isWrongAccount ? request.identifier : null,
start,
proceedToCodeInput,
submitCode,
approve,
deny,
reopenApp,
switchToLogin,
retry,
};
}
export type DesktopHandoffFlow = ReturnType<typeof useDesktopHandoffFlow>;
@@ -7,6 +7,11 @@ export const DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR = msg({
comment: 'Error shown when the browser sign-in handoff cannot run in the current environment.',
});
export const DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR = msg({
message: 'That sign-in code expired. Try again.',
comment: 'Error shown when a browser sign-in handoff code expires before it is approved.',
message: 'The sign-in request expired before your browser approved it.',
comment:
'Error shown in the desktop app when a browser sign-in request expires before it is approved in the browser.',
});
export const DESKTOP_HANDOFF_DENIED_DESCRIPTOR = msg({
message: 'The sign-in was cancelled in your browser.',
comment: 'Error shown in the desktop app when the user pressed Cancel on the browser sign-in page.',
});
@@ -5,7 +5,7 @@ import type {UserData} from '@app/features/auth/state/AccountStorage';
import type {InstanceHTTPTarget} from '@app/features/platform/transport/InstanceHTTP';
import {getElectronAPI} from '@app/features/ui/utils/NativeUtils';
import type {DesktopHandoffAPI} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {DesktopHandoffReturnMethod, DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
export interface BrowserLoginHandoffTarget {
readonly webAppEndpoint: string;
@@ -16,6 +16,7 @@ export interface BrowserLoginHandoffSession {
readonly code: string;
readonly expiresAt: string;
readonly pollSecret: string | null;
readonly returnMethod: DesktopHandoffReturnMethod;
readonly target: BrowserLoginHandoffTarget;
}
@@ -47,6 +48,9 @@ function normalizeHandoffStatus(status: string): DesktopHandoffStatus {
if (status === DesktopHandoffStatus.EXPIRED) {
return DesktopHandoffStatus.EXPIRED;
}
if (status === DesktopHandoffStatus.DENIED) {
return DesktopHandoffStatus.DENIED;
}
return DesktopHandoffStatus.PENDING;
}
@@ -55,7 +59,13 @@ function createRendererHandoffTransport(): BrowserLoginHandoffTransport {
kind: BrowserLoginHandoffTransportKind.RENDERER,
initiate: async (target) => {
const result = await AuthenticationCommands.initiateDesktopHandoff(target.instance);
return {code: result.code, expiresAt: result.expires_at, pollSecret: result.poll_secret ?? null, target};
return {
code: result.code,
expiresAt: result.expires_at,
pollSecret: result.poll_secret ?? null,
returnMethod: DesktopHandoffReturnMethod.CODE,
target,
};
},
status: async (session) => {
const result = await AuthenticationCommands.pollDesktopHandoffStatus(
@@ -86,6 +96,10 @@ function createIpcHandoffTransport(api: DesktopHandoffAPI): BrowserLoginHandoffT
code: session.code,
expiresAt: session.expiresAt,
pollSecret: null,
returnMethod:
session.returnMethod === DesktopHandoffReturnMethod.DEEP_LINK
? DesktopHandoffReturnMethod.DEEP_LINK
: DesktopHandoffReturnMethod.CODE,
target: {webAppEndpoint: session.instance.webAppEndpoint, instance: target.instance},
};
},
@@ -88,6 +88,7 @@ function createDesktopHandoffAPI(): DesktopHandoffAPI & {
instance,
code: CODE,
expiresAt: new Date(Date.now() + 300_000).toISOString(),
returnMethod: 'deep_link',
};
return session;
},
@@ -107,17 +108,17 @@ let root: Root;
let controller: Controller | null = null;
function Probe({
onExpired,
onEnded,
onSuccess,
runtimeSnapshot,
}: {
onExpired: () => void;
onEnded: () => void;
onSuccess: (payload: {token: string; userId: string}) => Promise<void> | void;
runtimeSnapshot: typeof RUNTIME_SNAPSHOT;
}) {
controller = useBrowserLoginHandoff({
runtimeSnapshot,
onExpired,
onEnded,
onSuccess,
});
return null;
@@ -133,10 +134,10 @@ function requireController(): Controller {
async function mountProbe(
onSuccess: (payload: {token: string; userId: string}) => Promise<void> | void,
runtimeSnapshot: typeof RUNTIME_SNAPSHOT = RUNTIME_SNAPSHOT,
onExpired: () => void = () => {},
onEnded: () => void = () => {},
): Promise<void> {
await act(async () => {
root.render(createElement(Probe, {onExpired, onSuccess, runtimeSnapshot}));
root.render(createElement(Probe, {onEnded, onSuccess, runtimeSnapshot}));
});
}
@@ -217,7 +218,7 @@ describe('browser sign-in against a deployed desktop shell', () => {
expect(harness.initiateDesktopHandoff).toHaveBeenCalledTimes(1);
expect(harness.navigateToExternalURL).toHaveBeenCalledTimes(1);
expect(harness.navigateToExternalURL.mock.calls[0]?.[0]).toBe(
`https://self.hosted.example/login?handoff=1&code=${encodeURIComponent(CODE)}`,
`https://self.hosted.example/login?handoff=1&code=${encodeURIComponent(CODE)}&api=${encodeURIComponent('https://self.hosted.example')}`,
);
await advancePollInterval();
@@ -491,7 +492,7 @@ describe('when the instance reports the code as dead', () => {
expect(harness.pollDesktopHandoffStatus).toHaveBeenCalledTimes(1);
expect(requireController().session).toBeNull();
expect(requireController().code).toBeNull();
expect(requireController().error).toBe('That sign-in code expired. Try again.');
expect(requireController().error).toBe('The sign-in request expired before your browser approved it.');
await advancePollInterval();
await advancePollInterval();
@@ -2,6 +2,7 @@
import type {RuntimeConfigSnapshot} from '@app/features/app/state/InstanceSnapshotStore';
import {
DESKTOP_HANDOFF_DENIED_DESCRIPTOR,
DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR,
DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR,
} from '@app/features/auth/flow/browser_handoff/BrowserHandoffDescriptors';
@@ -17,9 +18,8 @@ import {instanceTargetFromSnapshot} from '@app/features/platform/transport/Insta
import {Logger} from '@app/features/platform/utils/AppLogger';
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
import {navigateToExternalURL} from '@app/features/ui/utils/NativeUtils';
import {DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {DesktopHandoffReturnMethod, DesktopHandoffStatus} from '@fluxer/desktop_ipc/src/BrowserHandoffContract';
import {formatDesktopHandoffCode} from '@fluxer/schema/src/domains/auth/DesktopHandoffCode';
import type {I18n, MessageDescriptor} from '@lingui/core';
import {useLingui} from '@lingui/react/macro';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
@@ -38,16 +38,10 @@ export const BrowserLoginHandoffAction = Object.freeze({
export type BrowserLoginHandoffAction = (typeof BrowserLoginHandoffAction)[keyof typeof BrowserLoginHandoffAction];
export type BrowserLoginHandoffPendingAction = BrowserLoginHandoffAction | null;
export interface BrowserLoginHandoffMessages {
readonly desktopHandoffUnavailable?: MessageDescriptor;
readonly expired?: MessageDescriptor;
}
export interface UseBrowserLoginHandoffOptions {
readonly runtimeSnapshot: RuntimeConfigSnapshot;
readonly prefillIdentifier?: string | null;
readonly messages?: BrowserLoginHandoffMessages;
readonly onExpired: () => void;
readonly onEnded: () => void;
readonly onSuccess: (payload: LoginSuccessPayload) => Promise<void> | void;
}
@@ -63,10 +57,9 @@ export interface BrowserLoginHandoffController {
readonly isGenerating: boolean;
readonly pendingAction: BrowserLoginHandoffPendingAction;
readonly remainingSeconds: number | null;
readonly returnMethod: DesktopHandoffReturnMethod;
readonly session: BrowserLoginHandoffSession | null;
readonly copyCode: () => void;
readonly expireCurrentSession: () => void;
readonly isCurrentSessionExpired: () => boolean;
readonly openBrowser: () => Promise<boolean>;
readonly regenerateCode: () => Promise<boolean>;
readonly reset: () => void;
@@ -97,7 +90,11 @@ function buildBrowserLoginHandoffURL({
prefillIdentifier,
prefillField,
}: BrowserLoginHandoffURLRequest): string {
const params = new URLSearchParams({handoff: '1', code: session.code});
const params = new URLSearchParams({
handoff: '1',
code: session.code,
api: new URL(session.target.instance.apiEndpoint).origin,
});
if (prefillIdentifier != null && prefillIdentifier.length > 0) {
params.set(prefillField, prefillIdentifier);
}
@@ -131,15 +128,10 @@ function useBrowserLoginHandoffCountdown(expiresAt: string | null): number | nul
return remaining;
}
function resolveMessage(i18n: I18n, descriptor: MessageDescriptor | undefined, fallback: MessageDescriptor): string {
return i18n._(descriptor ?? fallback);
}
export function useBrowserLoginHandoff({
runtimeSnapshot,
prefillIdentifier,
messages,
onExpired,
onEnded,
onSuccess,
}: UseBrowserLoginHandoffOptions): BrowserLoginHandoffController {
const {i18n} = useLingui();
@@ -155,10 +147,10 @@ export function useBrowserLoginHandoff({
const completedRef = useRef(false);
const copyResetRef = useRef<number | null>(null);
const onSuccessRef = useRef(onSuccess);
const onExpiredRef = useRef(onExpired);
const onEndedRef = useRef(onEnded);
const i18nRef = useRef(i18n);
onSuccessRef.current = onSuccess;
onExpiredRef.current = onExpired;
onEndedRef.current = onEnded;
i18nRef.current = i18n;
const target = resolveHandoffTarget(runtimeSnapshot);
const targetKey = `${target.instance.apiEndpoint}|${target.webAppEndpoint}`;
@@ -205,9 +197,7 @@ export function useBrowserLoginHandoff({
const requestTarget = targetRef.current;
try {
if (requestTarget.webAppEndpoint.length === 0) {
throw new BrowserLoginHandoffUnavailableError(
resolveMessage(i18n, messages?.desktopHandoffUnavailable, DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR),
);
throw new BrowserLoginHandoffUnavailableError(i18n._(DESKTOP_HANDOFF_UNAVAILABLE_DESCRIPTOR));
}
const nextSession = await transport.initiate(requestTarget);
if (generation !== generationRef.current) {
@@ -225,7 +215,7 @@ export function useBrowserLoginHandoff({
setIsGenerating(false);
}
}
}, [clearCopyFeedbackTimer, i18n, messages?.desktopHandoffUnavailable, transport]);
}, [clearCopyFeedbackTimer, i18n, transport]);
const getCurrentSession = useCallback(async (): Promise<BrowserLoginHandoffSession | null> => {
if (session != null && Date.parse(session.expiresAt) > Date.now()) {
return session;
@@ -233,17 +223,17 @@ export function useBrowserLoginHandoff({
return generateSession();
}, [generateSession, session]);
const expireCurrentSession = useCallback(() => {
clearHandoffSession(resolveMessage(i18n, messages?.expired, DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR));
onExpiredRef.current();
}, [clearHandoffSession, i18n, messages?.expired]);
clearHandoffSession(i18n._(DESKTOP_HANDOFF_EXPIRED_DESCRIPTOR));
onEndedRef.current();
}, [clearHandoffSession, i18n]);
const expireCurrentSessionRef = useRef(expireCurrentSession);
expireCurrentSessionRef.current = expireCurrentSession;
const isCurrentSessionExpired = useCallback(() => {
if (session == null) {
return false;
}
return Date.parse(session.expiresAt) <= Date.now();
}, [session]);
const endDeniedSession = useCallback(() => {
clearHandoffSession(i18n._(DESKTOP_HANDOFF_DENIED_DESCRIPTOR));
onEndedRef.current();
}, [clearHandoffSession, i18n]);
const endDeniedSessionRef = useRef(endDeniedSession);
endDeniedSessionRef.current = endDeniedSession;
const openBrowser = useCallback(async (): Promise<boolean> => {
setPendingAction(BrowserLoginHandoffAction.OPEN_BROWSER);
try {
@@ -362,6 +352,10 @@ export function useBrowserLoginHandoff({
keepPolling = false;
expireCurrentSessionRef.current();
}
if (result.status === DesktopHandoffStatus.DENIED) {
keepPolling = false;
endDeniedSessionRef.current();
}
} catch (caught) {
consecutiveErrors += 1;
if (!disposed) {
@@ -402,10 +396,9 @@ export function useBrowserLoginHandoff({
isGenerating,
pendingAction,
remainingSeconds,
returnMethod: session?.returnMethod ?? DesktopHandoffReturnMethod.CODE,
session,
copyCode,
expireCurrentSession,
isCurrentSessionExpired,
openBrowser,
regenerateCode,
reset,
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {InstanceBrandMark} from '@app/features/auth/components/InstanceBrandMark';
import styles from '@app/features/auth/flow/InstanceSelector.module.css';
import {
type InstanceInfo,
@@ -7,6 +8,8 @@ import {
resolveInstanceLabel,
} from '@app/features/auth/flow/instance_selector/InstanceDirectoryStorage';
import {InstanceDiscoveryStatus} from '@app/features/auth/flow/instance_selector/InstanceSelectorTypes';
import {resolveInstanceBrandIconUrl, resolveInstanceProductName} from '@app/features/auth/InstanceBranding';
import {findInstanceSnapshot} from '@app/features/auth/InstanceSnapshotLookup';
import {OFFICIAL_INSTANCE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {MenuItem} from '@app/features/ui/action_menu/MenuItem';
@@ -18,7 +21,7 @@ import FluxerLogoAsset from '@app/media/images/fluxer-logo-color.svg?react';
import {OFFICIAL_INSTANCE_DISPLAY_HOST, OFFICIAL_INSTANCE_NAME} from '@fluxer/instance_bootstrap/src/OfficialInstance';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowRightIcon, GlobeIcon, PlusIcon, TrashIcon} from '@phosphor-icons/react';
import {ArrowRightIcon, PlusIcon, TrashIcon} from '@phosphor-icons/react';
import {type MouseEvent, type ReactNode, useCallback} from 'react';
const USE_OFFICIAL_INSTANCE_DESCRIPTOR = msg({
@@ -86,7 +89,10 @@ export function InstancePickerRows({
[disabled, i18n, isDiscovering, onRemoveInstance],
);
const renderKnownInstance = (instance: InstanceInfo, removable: boolean): ReactNode => {
const name = resolveInstanceLabel(instance.name, instance.domain);
const snapshot = findInstanceSnapshot(instance.instanceKey);
const storedName = instance.name !== instance.domain ? instance.name : null;
const name = resolveInstanceLabel(storedName ?? resolveInstanceProductName(snapshot), instance.domain);
const iconUrl = resolveInstanceBrandIconUrl(snapshot);
return (
<div
key={instance.instanceKey}
@@ -111,10 +117,11 @@ export function InstancePickerRows({
className={styles.instanceLogo}
data-flx="auth.flow.instance-selector.instance-picker-rows.render-known-instance.instance-logo"
>
<GlobeIcon
size={remFromPx(20)}
weight="regular"
data-flx="auth.flow.instance-selector.instance-picker-rows.render-known-instance.globe-icon"
<InstanceBrandMark
isOfficial={false}
iconUrl={iconUrl}
size={20}
data-flx="auth.flow.instance-selector.instance-picker-rows.render-known-instance.instance-brand-mark"
/>
</span>
<span
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import accountStorage from '@app/features/auth/state/AccountStorage';
import type {UserData} from '@app/features/auth/state/AccountStorageContract';
import Accounts from '@app/features/auth/state/Accounts';
import {Logger} from '@app/features/platform/utils/AppLogger';
const logger = new Logger('AccountUserDataSync');
export interface AccountUserIdentity {
readonly username: string;
readonly discriminator: string;
readonly global_name?: string | null;
readonly email?: string | null;
readonly avatar?: string | null;
}
export function syncAccountUserData(accountKey: string, user: AccountUserIdentity): void {
const previous = Accounts.getAccount(accountKey)?.userData;
const userData: UserData = {
username: user.username,
discriminator: user.discriminator,
globalName: user.global_name === undefined ? previous?.globalName : user.global_name,
email: user.email === undefined ? previous?.email : (user.email ?? undefined),
avatar: user.avatar ?? undefined,
};
void accountStorage.updateAccountUserData(accountKey, userData).catch((error) => {
logger.warn(`Failed to persist account user data for ${accountKey}`, error);
});
Accounts.updateAccountUserData(accountKey, userData);
}
@@ -36,7 +36,12 @@ const mocks = vi.hoisted(() => ({
retireForAccountTransition: vi.fn<(reason: string) => Promise<void>>(() => Promise.resolve()),
gatewayLogout: vi.fn<() => void>(),
sendInvisiblePresence: vi.fn<() => void>(),
replaceWith: vi.fn<(path: string) => void>(),
replaceWith: vi.fn<(path: string) => void>((path) => {
mocks.currentPath = path;
mocks.commitRouterPath(path);
}),
readRouterPath: (): string => '',
commitRouterPath: (_path: string): void => undefined,
abandonUnreachableLastLocation: vi.fn<() => void>(),
currentPath: '',
lastLocation: null as string | null,
@@ -80,6 +85,20 @@ vi.mock('@app/features/navigation/utils/RouterUtils', () => ({
history: null,
}));
vi.mock('@app/features/navigation/state/Navigation', async () => {
const {observable, runInAction} = await import('mobx');
const routerPath = observable.box('');
mocks.readRouterPath = () => routerPath.get();
mocks.commitRouterPath = (path) => runInAction(() => routerPath.set(path));
return {
default: {
get pathname() {
return mocks.readRouterPath();
},
},
};
});
vi.mock('@app/features/ui/state/Location', () => ({
default: {getLastLocation: () => mocks.lastLocation, saveLocation: mocks.saveLocation},
}));
@@ -203,9 +222,11 @@ function gatewayOutage(accountKey: string): InstanceType<typeof ForegroundGatewa
beforeEach(async () => {
mocks.currentPath = '';
mocks.commitRouterPath('');
mocks.lastLocation = null;
mocks.replaceWith.mockImplementation((path) => {
mocks.currentPath = path;
mocks.commitRouterPath(path);
});
mocks.foregroundAccountKey = null;
mocks.hydrateForegroundStores(null);
@@ -490,9 +511,11 @@ describe('Accounts view handover between accounts that share a guild', () => {
mocks.currentPath = GENERAL_ROUTE;
mocks.lastLocation = GENERAL_ROUTE;
runInAction(() => routedPath.set(GENERAL_ROUTE));
mocks.commitRouterPath(GENERAL_ROUTE);
mocks.replaceWith.mockImplementation((path) => {
mocks.currentPath = path;
runInAction(() => routedPath.set(path));
mocks.commitRouterPath(path);
redirectUnknownSharedGuildChannel(path);
});
mocks.saveLocation.mockImplementation((path) => {
@@ -581,6 +604,36 @@ describe('Accounts view handover between accounts that share a guild', () => {
expect(rendered.filter((entry) => !entry.live).every((entry) => entry.path === GENERAL_ROUTE)).toBe(true);
});
test('the target view stays held until the router commits the target route', async () => {
mocks.replaceWith.mockImplementation((path) => {
mocks.currentPath = path;
setTimeout(() => {
runInAction(() => routedPath.set(path));
mocks.commitRouterPath(path);
}, 0);
});
const rendered: Array<{accountKey: string | null; path: string; live: boolean}> = [];
const ViewRecorder = observer(() => {
rendered.push({accountKey: SessionManager.currentAccountKey, path: routedPath.get(), live: Accounts.isViewLive});
return null;
});
const container = document.createElement('div');
const root = createRoot(container);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
act(() => root.render(createElement(ViewRecorder)));
disposers.push(() => act(() => root.unmount()));
await act(async () => {
await Accounts.switchToAccount(accountB);
await new Promise((resolve) => setTimeout(resolve, 10));
});
expect(
rendered.filter((entry) => entry.live && entry.accountKey === accountB && entry.path !== PRIVATE_ROUTE),
).toEqual([]);
expect(rendered.at(-1)).toEqual({accountKey: accountB, path: PRIVATE_ROUTE, live: true});
});
test('the target route is in place in the same tick as its state, before the gateway handshake settles', async () => {
await Accounts.switchToAccount(accountB);
+40 -7
View File
@@ -17,6 +17,7 @@ import {getAccountKey, parseAccountStorageKey} from '@app/features/auth/state/Ac
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {ForegroundGatewayConnectionRecoverableError} from '@app/features/gateway/transport/ForegroundGatewayConnectionFailure';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import Navigation from '@app/features/navigation/state/Navigation';
import {abandonUnreachableLastLocation} from '@app/features/navigation/utils/ChannelRouteReachability';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
@@ -103,7 +104,10 @@ interface AccountReplacementCommitRequest {
type AccountViewReplacement =
| {readonly kind: 'activation'; readonly redirectPath: string | null}
| {readonly kind: 'rollback'; readonly previousRoute: string};
| {readonly kind: 'rollback'; readonly previousRoute: string}
| {readonly kind: 'navigating'; readonly stalePath: string};
const VIEW_NAVIGATION_COMMIT_TIMEOUT_MS = 2000;
type AccountActivationOutcome =
| {readonly kind: 'ready'}
@@ -478,7 +482,9 @@ class Accounts {
throw activationError;
} finally {
stopAwaitingLiveView();
this.viewReplacement = null;
if (this.viewReplacement?.kind !== 'navigating') {
this.viewReplacement = null;
}
}
}
@@ -612,17 +618,44 @@ class Accounts {
private revealReplacementView(): void {
const replacement = this.viewReplacement;
if (replacement === null) {
if (replacement === null || replacement.kind === 'navigating') {
return;
}
const path = this.resolveReplacementViewPath(replacement);
if (path !== null && path !== RouterUtils.getCurrentPath()) {
RouterUtils.replaceWith(path);
const stalePath = Navigation.pathname;
if (path === null || path === RouterUtils.getCurrentPath()) {
this.viewReplacement = null;
return;
}
this.viewReplacement = null;
RouterUtils.replaceWith(path);
this.holdViewUntilNavigationCommits(stalePath);
}
private resolveReplacementViewPath(replacement: AccountViewReplacement): string | null {
private holdViewUntilNavigationCommits(stalePath: string): void {
if (Navigation.pathname !== stalePath) {
this.viewReplacement = null;
return;
}
const marker: AccountViewReplacement = {kind: 'navigating', stalePath};
this.viewReplacement = marker;
const release = () => {
if (this.viewReplacement === marker) {
this.viewReplacement = null;
}
};
const timeout = setTimeout(release, VIEW_NAVIGATION_COMMIT_TIMEOUT_MS);
when(
() => this.viewReplacement !== marker || Navigation.pathname !== stalePath,
() => {
clearTimeout(timeout);
release();
},
);
}
private resolveReplacementViewPath(
replacement: Exclude<AccountViewReplacement, {readonly kind: 'navigating'}>,
): string | null {
if (replacement.kind === 'rollback') {
return replacement.previousRoute === '' ? null : replacement.previousRoute;
}
@@ -380,26 +380,3 @@ export async function pollIpAuthorization(
): Promise<AuthenticationCommands.IpAuthorizationPollResult> {
return AuthenticationCommands.pollIpAuthorization({ticket, target: authCommandTarget(runtimeSnapshot)});
}
export async function initiateDesktopHandoff(runtimeSnapshot: RuntimeConfigSnapshot) {
return AuthenticationCommands.initiateDesktopHandoff(authCommandTarget(runtimeSnapshot));
}
export async function completeDesktopHandoff({
code,
token,
userId,
runtimeSnapshot,
}: {
code: string;
token: string;
userId: string;
runtimeSnapshot: RuntimeConfigSnapshot;
}) {
return AuthenticationCommands.completeDesktopHandoff({
code,
token,
userId,
target: authCommandTarget(runtimeSnapshot),
});
}
@@ -502,7 +502,8 @@
}
.headerContainerThreadPanelOpen {
grid-template-columns: minmax(min(12rem, 40%), 1fr) minmax(0, auto);
container-type: inline-size;
grid-template-columns: minmax(0, max-content) minmax(min-content, 1fr);
}
.headerContainerThreadPanelOpen .topicDivider,
@@ -512,6 +513,7 @@
.headerContainerThreadPanelOpen .messageSearchFocusWrapper {
flex: 0 1 15.25rem;
width: 9rem;
min-width: 9rem;
}
@@ -520,6 +522,13 @@
min-width: 0;
}
@container (max-width: 32rem) {
.headerContainerThreadPanelOpen .messageSearchFocusWrapper {
width: var(--channel-header-action-size);
min-width: var(--channel-header-action-size);
}
}
.iconButton {
display: flex;
align-items: center;
@@ -559,29 +568,11 @@
position: relative;
}
.updateIconButtonDisabled {
composes: updateIconButton;
opacity: 0.4;
pointer-events: none;
}
.updateIcon {
height: 1.5rem;
width: 1.5rem;
}
.updateProgress {
position: absolute;
bottom: -0.4rem;
right: -0.6rem;
background: var(--background-tertiary);
color: var(--channel-header-text-primary);
font-size: 0.65rem;
padding: 0 0.25rem;
border-radius: 0.375rem;
border: 0.0625rem solid rgba(255, 255, 255, 0.08);
}
.updateIconError {
color: var(--status-danger);
}
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Routes} from '@app/app/Routes';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {getActiveInstanceProductName} from '@app/features/app/state/ActiveInstanceProductName';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import styles from '@app/features/channel/components/InviteEmbed.module.css';
import {
@@ -364,7 +364,9 @@ const InviteEmbedInner = observer(function InviteEmbedInner({
{actionType === GuildInvitePrimaryAction.InvitesDisabled && (
<p className={styles.statText} data-flx="channel.invite-embed.stat-text--4">
{guildActionState.isRaidDetected
? i18n._(DETECTED_A_POTENTIAL_RAID_SO_NEW_USERS_CAN_DESCRIPTOR, {productName: PRODUCT_NAME})
? i18n._(DETECTED_A_POTENTIAL_RAID_SO_NEW_USERS_CAN_DESCRIPTOR, {
productName: getActiveInstanceProductName(),
})
: i18n._(INVITES_ARE_CURRENTLY_PAUSED_FOR_THIS_COMMUNITY_DESCRIPTOR)}
</p>
)}
@@ -6,6 +6,7 @@ import {MEMBERS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescript
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import type {UIEvent} from 'react';
@@ -16,6 +17,7 @@ interface MemberListContainerProps {
channelId: string;
identityKey?: string;
children: React.ReactNode;
className?: string;
scrollerRef?: React.RefObject<ScrollerHandle | null>;
estimatedContentSize?: number | null;
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
@@ -26,6 +28,7 @@ export const MemberListContainer: React.FC<MemberListContainerProps> = observer(
channelId,
identityKey,
children,
className,
scrollerRef,
estimatedContentSize,
onScroll,
@@ -43,7 +46,7 @@ export const MemberListContainer: React.FC<MemberListContainerProps> = observer(
return (
<aside
ref={navigationRef}
className={styles.memberListContainer}
className={clsx(styles.memberListContainer, className)}
aria-label={i18n._(MEMBERS_DESCRIPTOR)}
data-flx="channel.member-list-container.member-list-container"
>
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserSettingsModal} from '@app/features/app/components/dialogs/LoadableSettingsModals';
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {getActiveInstanceProductName} from '@app/features/app/state/ActiveInstanceProductName';
import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimAccountModal';
import styles from '@app/features/channel/components/barriers/BarrierComponents.module.css';
import {openChannelFollowModal} from '@app/features/channel/components/modals/ChannelFollowModal';
@@ -359,7 +359,7 @@ export const SystemDmBarrier = observer(() => {
const {i18n} = useLingui();
return (
<BarrierBase
message={i18n._(SYSTEM_ANNOUNCEMENTS_FROM_STAFF_DESCRIPTOR, {productName: PRODUCT_NAME})}
message={i18n._(SYSTEM_ANNOUNCEMENTS_FROM_STAFF_DESCRIPTOR, {productName: getActiveInstanceProductName()})}
icon={
<InfoIcon
size={remFromPx(18)}
@@ -1,59 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import Updater, {
DOWNLOADING_UPDATE_DESCRIPTOR as DOWNLOADING_UPDATE_STATUS_DESCRIPTOR,
} from '@app/features/app/state/Updater';
import Updater from '@app/features/app/state/Updater';
import styles from '@app/features/channel/components/ChannelHeader.module.css';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import {Platform} from '@app/features/platform/types/Platform';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowClockwiseIcon, DownloadSimpleIcon} from '@phosphor-icons/react';
import {ArrowClockwiseIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useMemo} from 'react';
import {useCallback} from 'react';
const INSTALLING_UPDATE_DESCRIPTOR = msg({
message: 'Installing desktop update {version}…',
const UPDATE_AVAILABLE_DESCRIPTOR = msg({
message: 'Update available',
comment:
'Tooltip on the channel header updater icon while a Fluxer Desktop update is being installed. version is the target version. Trailing horizontal ellipsis is intentional.',
});
const INSTALLING_UPDATE_2_DESCRIPTOR = msg({
message: 'Installing desktop update…',
comment:
'Tooltip on the channel header updater icon while a Fluxer Desktop update is being installed when version is not known. Trailing horizontal ellipsis is intentional.',
});
const DOWNLOADING_UPDATE_AT_S_DESCRIPTOR = msg({
message: 'Downloading desktop update: {percent}% ({formatBytes} / {formatBytes2} at {formatBytes3}/s)',
comment:
'Tooltip on the channel header updater icon while downloading an update. Shows percent, bytes downloaded, total bytes, and bytes per second.',
});
const DOWNLOADING_UPDATE_DESCRIPTOR = msg({
message: 'Downloading desktop update {version}: {percent}%',
comment: 'Tooltip on the channel header updater icon while downloading a versioned update without throughput info.',
});
const DOWNLOADING_UPDATE_2_DESCRIPTOR = msg({
message: 'Downloading desktop update: {percent}%',
comment: 'Tooltip on the channel header updater icon while downloading an update with only percent available.',
});
const CLICK_TO_DOWNLOAD_UPDATE_DESCRIPTOR = msg({
message: 'Download desktop update {version} ({formatBytes})',
comment: 'Tooltip on the channel header updater icon prompting download of a versioned update with known size.',
});
const CLICK_TO_DOWNLOAD_UPDATE_2_DESCRIPTOR = msg({
message: 'Download desktop update ({formatBytes})',
comment:
'Tooltip on the channel header updater icon prompting download of an update of known size but unknown version.',
});
const CLICK_TO_DOWNLOAD_UPDATE_3_DESCRIPTOR = msg({
message: 'Download desktop update {version}',
comment: 'Tooltip on the channel header updater icon prompting download of a versioned update with unknown size.',
});
const CLICK_TO_DOWNLOAD_UPDATE_4_DESCRIPTOR = msg({
message: 'Download desktop update',
comment: 'Tooltip on the channel header updater icon prompting download of an update with no version or size info.',
'Tooltip and accessible label on the channel header updater icon in the desktop app. Clicking it closes the window and opens the updater, which downloads and installs the update.',
});
const CHOOSE_LINUX_PACKAGE_DESCRIPTOR = msg({
message: 'Desktop update {version} available. Choose a Linux package.',
@@ -64,21 +26,6 @@ const CHOOSE_LINUX_PACKAGE_2_DESCRIPTOR = msg({
comment:
'Tooltip on the updater icon for a Linux desktop update that requires choosing a package format and no version is known.',
});
const CLICK_TO_RESTART_AND_INSTALL_UPDATE_DESCRIPTOR = msg({
message: 'Restart {productName} to install desktop update {version}',
comment:
'Tooltip on the channel header updater icon prompting a restart to apply a downloaded versioned update. productName is the app name.',
});
const CLICK_TO_RESTART_AND_INSTALL_UPDATE_2_DESCRIPTOR = msg({
message: 'Restart {productName} to install desktop update',
comment:
'Tooltip on the channel header updater icon prompting a restart to apply a downloaded update of unknown version. productName is the app name.',
});
const CLICK_TO_RELOAD_FOR_MODULE_UPDATE_DESCRIPTOR = msg({
message: 'Reload {productName} to use the updated modules',
comment:
'Tooltip on the channel header updater icon prompting a reload to apply freshly downloaded desktop modules. productName is the app name.',
});
const CLICK_TO_RELOAD_AND_UPDATE_DESCRIPTOR = msg({
message: 'Reload {productName} to use web update {version}',
comment:
@@ -90,151 +37,54 @@ const CLICK_TO_RELOAD_AND_UPDATE_2_DESCRIPTOR = msg({
'Tooltip on the channel header updater icon prompting a web reload to apply an update of unknown version. productName is the app name.',
});
function formatBytes(locale: string, bytes: number): string {
const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const;
let value = Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex++;
}
return getCachedNumberFormat(locale, {
style: 'unit',
unit: units[unitIndex],
unitDisplay: 'narrow',
maximumFractionDigits: value >= 100 || unitIndex === 0 ? 0 : 1,
}).format(value);
}
type UpdaterAffordance = 'desktop' | 'manual' | 'web' | null;
function resolveUpdaterVisibility(): {
readonly hasActionableNativeUpdate: boolean;
readonly hasActionableModuleUpdate: boolean;
readonly hasActionableWebUpdate: boolean;
} {
const hasActionableNativeUpdate =
Platform.isElectron &&
(Updater.nativeUpdateReady ||
Updater.nativeAwaitingDownload ||
Updater.nativeManualUpdateAvailable ||
Updater.nativeDownloadInFlight);
const hasActionableModuleUpdate = Platform.isElectron && Updater.moduleUpdateReady && !hasActionableNativeUpdate;
const hasActionableWebUpdate =
!!Updater.updateInfo.web.available && !hasActionableNativeUpdate && !hasActionableModuleUpdate;
return {hasActionableNativeUpdate, hasActionableModuleUpdate, hasActionableWebUpdate};
function resolveUpdaterAffordance(): UpdaterAffordance {
if (Platform.isElectron && Updater.desktopUpdateAvailable) return 'desktop';
if (Platform.isElectron && Updater.nativeManualUpdateAvailable) return 'manual';
if (Updater.updateInfo.web.available) return 'web';
return null;
}
export function isUpdaterIconVisible(): boolean {
const {hasActionableNativeUpdate, hasActionableModuleUpdate, hasActionableWebUpdate} = resolveUpdaterVisibility();
return hasActionableNativeUpdate || hasActionableModuleUpdate || hasActionableWebUpdate;
return resolveUpdaterAffordance() != null;
}
export const UpdaterIcon = observer(() => {
const {i18n} = useLingui();
const store = Updater;
const {hasActionableNativeUpdate, hasActionableModuleUpdate, hasActionableWebUpdate} = resolveUpdaterVisibility();
const tooltip = useMemo(() => {
const version = store.displayVersion;
if (hasActionableModuleUpdate) {
return i18n._(CLICK_TO_RELOAD_FOR_MODULE_UPDATE_DESCRIPTOR, {productName: PRODUCT_NAME});
}
if (Platform.isElectron && store.updateInfo.native.installing) {
return version ? i18n._(INSTALLING_UPDATE_DESCRIPTOR, {version}) : i18n._(INSTALLING_UPDATE_2_DESCRIPTOR);
}
if (Platform.isElectron && store.nativeDownloadInFlight) {
if (!store.nativeDownloadProgressSupported) {
return i18n._(DOWNLOADING_UPDATE_STATUS_DESCRIPTOR);
}
const progress = store.downloadProgress;
const percent = getCachedNumberFormat(i18n.locale).format(progress ? Math.round(progress.percent) : 0);
if (progress && progress.total > 0 && progress.bytesPerSecond > 0) {
return i18n._(DOWNLOADING_UPDATE_AT_S_DESCRIPTOR, {
percent,
formatBytes: formatBytes(i18n.locale, progress.transferred),
formatBytes2: formatBytes(i18n.locale, progress.total),
formatBytes3: formatBytes(i18n.locale, progress.bytesPerSecond),
});
}
return version
? i18n._(DOWNLOADING_UPDATE_DESCRIPTOR, {version, percent})
: i18n._(DOWNLOADING_UPDATE_2_DESCRIPTOR, {percent});
}
if (Platform.isElectron && (store.nativeAwaitingDownload || store.nativeManualUpdateAvailable)) {
if (store.nativeManualUpdateAvailable && store.nativeManualDownloadOptions.length > 0) {
return version ? i18n._(CHOOSE_LINUX_PACKAGE_DESCRIPTOR, {version}) : i18n._(CHOOSE_LINUX_PACKAGE_2_DESCRIPTOR);
}
const size = store.updateInfo.native.downloadSize;
if (version && size && size > 0) {
return i18n._(CLICK_TO_DOWNLOAD_UPDATE_DESCRIPTOR, {version, formatBytes: formatBytes(i18n.locale, size)});
}
if (size && size > 0) {
return i18n._(CLICK_TO_DOWNLOAD_UPDATE_2_DESCRIPTOR, {formatBytes: formatBytes(i18n.locale, size)});
}
return version
? i18n._(CLICK_TO_DOWNLOAD_UPDATE_3_DESCRIPTOR, {version})
: i18n._(CLICK_TO_DOWNLOAD_UPDATE_4_DESCRIPTOR);
}
if (Platform.isElectron && store.nativeUpdateReady) {
return version
? i18n._(CLICK_TO_RESTART_AND_INSTALL_UPDATE_DESCRIPTOR, {version, productName: PRODUCT_NAME})
: i18n._(CLICK_TO_RESTART_AND_INSTALL_UPDATE_2_DESCRIPTOR, {productName: PRODUCT_NAME});
}
return version
? i18n._(CLICK_TO_RELOAD_AND_UPDATE_DESCRIPTOR, {version, productName: PRODUCT_NAME})
: i18n._(CLICK_TO_RELOAD_AND_UPDATE_2_DESCRIPTOR, {productName: PRODUCT_NAME});
}, [
hasActionableModuleUpdate,
store.displayVersion,
store.downloadProgress,
store.nativeAwaitingDownload,
store.nativeDownloadInFlight,
store.nativeDownloadProgressSupported,
store.nativeManualDownloadOptions,
store.nativeManualUpdateAvailable,
store.nativeUpdateReady,
store.updateInfo.native.downloadSize,
store.updateInfo.native.installing,
i18n.locale,
]);
const handleClick = useCallback(() => {
void store.applyUpdate();
}, [store]);
if (!hasActionableNativeUpdate && !hasActionableModuleUpdate && !hasActionableWebUpdate) {
void Updater.applyUpdate();
}, []);
const affordance = resolveUpdaterAffordance();
if (affordance == null) {
return null;
}
const isInstalling = Platform.isElectron && store.updateInfo.native.installing;
const isDownloading = Platform.isElectron && store.nativeDownloadInFlight;
const buttonClass = isInstalling ? styles.updateIconButtonDisabled : styles.updateIconButton;
const Icon = hasActionableWebUpdate || hasActionableModuleUpdate ? ArrowClockwiseIcon : DownloadSimpleIcon;
const percentLabel =
isDownloading && store.nativeDownloadProgressSupported
? getCachedNumberFormat(i18n.locale, {style: 'percent', maximumFractionDigits: 0}).format(
(store.downloadProgress?.percent ?? 0) / 100,
)
: null;
const version = Updater.displayVersion;
let tooltip: string;
if (affordance === 'manual' && Updater.nativeManualDownloadOptions.length > 0) {
tooltip = version ? i18n._(CHOOSE_LINUX_PACKAGE_DESCRIPTOR, {version}) : i18n._(CHOOSE_LINUX_PACKAGE_2_DESCRIPTOR);
} else if (affordance !== 'web') {
tooltip = i18n._(UPDATE_AVAILABLE_DESCRIPTOR);
} else {
tooltip = version
? i18n._(CLICK_TO_RELOAD_AND_UPDATE_DESCRIPTOR, {version, productName: PRODUCT_NAME})
: i18n._(CLICK_TO_RELOAD_AND_UPDATE_2_DESCRIPTOR, {productName: PRODUCT_NAME});
}
return (
<Tooltip text={tooltip} position="bottom" data-flx="channel.channel-header-components.updater-icon.tooltip">
<FocusRing offset={-2} data-flx="channel.channel-header-components.updater-icon.focus-ring">
<button
type="button"
className={buttonClass}
className={styles.updateIconButton}
onClick={handleClick}
aria-label={tooltip}
aria-disabled={isInstalling || undefined}
data-flx="channel.channel-header-components.updater-icon.button.click"
>
<Icon
<ArrowClockwiseIcon
weight="bold"
className={styles.updateIcon}
data-flx="channel.channel-header-components.updater-icon.update-icon"
/>
{percentLabel != null && (
<span
className={styles.updateProgress}
data-flx="channel.channel-header-components.updater-icon.update-progress"
>
{percentLabel}
</span>
)}
</button>
</FocusRing>
</Tooltip>
@@ -73,26 +73,6 @@ const MEDIA_LOADING_STATE_DESCRIPTOR = msg({
message: 'Media loading state',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const ANY_UPDATER_READY_DESCRIPTOR = msg({
message: 'Any updater ready',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const NATIVE_UPDATER_READY_DESCRIPTOR = msg({
message: 'Native updater ready',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const NATIVE_UPDATE_PROGRESS_DESCRIPTOR = msg({
message: 'Native update progress',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const WEB_UPDATER_READY_DESCRIPTOR = msg({
message: 'Web updater ready',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const UPDATER_STATE_DESCRIPTOR = msg({
message: 'Updater state',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const SHOW_MYSELF_TYPING_DESCRIPTOR = msg({
message: 'Show myself typing',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
@@ -297,7 +277,6 @@ const formatDeveloperOptionValue = <K extends keyof DeveloperOptionsState>(
case 'mockMatureContentGateReason':
case 'mockMatureMediaGateReason':
case 'mockTitlebarPlatformOverride':
case 'mockUpdaterState':
return String(value).replace(/_/g, ' ');
case 'premiumSinceOverride':
case 'premiumUntilOverride':
@@ -342,16 +321,6 @@ export const getDeveloperOptionLabel = (key: keyof DeveloperOptionsState): Messa
return EMBED_SKELETONS_DESCRIPTOR;
case 'forceMediaLoading':
return MEDIA_LOADING_STATE_DESCRIPTOR;
case 'forceUpdateReady':
return ANY_UPDATER_READY_DESCRIPTOR;
case 'forceNativeUpdateReady':
return NATIVE_UPDATER_READY_DESCRIPTOR;
case 'mockNativeUpdateProgress':
return NATIVE_UPDATE_PROGRESS_DESCRIPTOR;
case 'forceWebUpdateReady':
return WEB_UPDATER_READY_DESCRIPTOR;
case 'mockUpdaterState':
return UPDATER_STATE_DESCRIPTOR;
case 'showMyselfTyping':
return SHOW_MYSELF_TYPING_DESCRIPTOR;
case 'slowAttachmentUpload':
@@ -12,11 +12,6 @@ export const DEFAULT_DEVELOPER_OPTIONS = {
forceRenderPlaceholders: false,
forceEmbedSkeletons: false,
forceMediaLoading: false,
forceUpdateReady: false,
forceNativeUpdateReady: false,
mockNativeUpdateProgress: null,
forceWebUpdateReady: false,
mockUpdaterState: 'none',
showMyselfTyping: false,
slowAttachmentUpload: false,
slowMessageLoad: false,
@@ -366,7 +366,6 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
onClick={handleClick}
onMouseEnter={scheduleViewerWarm}
onMouseLeave={cancelViewerWarm}
onMouseDown={openInBrowser.onMouseDown}
onAuxClick={openInBrowser.onAuxClick}
onKeyDown={handleClick}
aria-label={ariaLabel}
@@ -8,6 +8,7 @@ import {
} from '@app/features/channel/components/embeds/channel_embed/ChannelEmbedShared';
import {SUPPRESS_EMBEDS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import {useOpenInBrowserOnMiddleClick} from '@app/features/messaging/hooks/useOpenInBrowserOnMiddleClick';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
import TrustedDomain from '@app/features/trusted_domain/state/TrustedDomain';
@@ -31,6 +32,7 @@ export const EmbedLink: FC<LinkComponentProps> = observer(({url, children, class
logger.warn('Invalid URL in embed link:', url);
}
};
const openInBrowser = useOpenInBrowserOnMiddleClick(url);
return (
<FocusRing data-flx="channel.embeds.embed.link-component.focus-ring">
<a
@@ -39,6 +41,7 @@ export const EmbedLink: FC<LinkComponentProps> = observer(({url, children, class
rel="noopener noreferrer"
target="_blank"
onClick={handleClick}
onAuxClick={openInBrowser.onAuxClick}
data-flx="channel.embeds.embed.link-component.a"
>
{children}
@@ -343,7 +343,6 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
className={styles.imagePreviewHandler}
aria-label={ariaLabel}
onClick={openImagePreview}
onMouseDown={openInBrowser.onMouseDown}
onAuxClick={openInBrowser.onAuxClick}
onKeyDown={openImagePreview}
onMouseEnter={onViewerWarmEnter}
@@ -233,7 +233,6 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
className={styles.imagePreviewHandler}
aria-label={i18n._(OPEN_IMAGE_IN_FULL_VIEW_DESCRIPTOR)}
onClick={openImagePreview}
onMouseDown={openInBrowser.onMouseDown}
onAuxClick={openInBrowser.onAuxClick}
onKeyDown={openImagePreview}
onMouseEnter={onViewerWarmEnter}
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {getActiveInstanceProductName} from '@app/features/app/state/ActiveInstanceProductName';
import styles from '@app/features/channel/components/GuildMembersPage.module.css';
import {ROLES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -105,7 +105,7 @@ export function MembersTableHeaderRow({
data-flx="channel.guild-members-page.members-table-view.header-cell--3"
>
<div className={styles.thContent} data-flx="channel.guild-members-page.members-table-view.th-content--2">
{i18n._(JOINED_PRODUCT_DESCRIPTOR, {productName: PRODUCT_NAME})}
{i18n._(JOINED_PRODUCT_DESCRIPTOR, {productName: getActiveInstanceProductName()})}
<button
type="button"
className={clsx(styles.filterButton, joinedFluxerActive && styles.filterButtonActive)}
@@ -140,12 +140,6 @@ export const TextareaContextMenu = observer(
}
targetElement.focus({preventScroll: true});
};
const handleReplaceMisspelling = async (suggestion: string) => {
if (electronAPI?.spellcheckReplaceMisspelling) {
await electronAPI.spellcheckReplaceMisspelling(suggestion);
}
onClose();
};
const handleAddToDictionary = async () => {
if (!misspelledWord) return;
Spellcheck.addPersonalWord(misspelledWord);
@@ -167,6 +161,11 @@ export const TextareaContextMenu = observer(
requestAnimationFrame(action);
});
};
const handleReplaceMisspelling = (suggestion: string) => {
runAfterClose(() => {
void electronAPI?.spellcheckReplaceMisspelling?.(suggestion);
});
};
const execCommand = (command: string) => {
runAfterClose(() => {
document.execCommand(command);
@@ -217,6 +216,7 @@ export const TextareaContextMenu = observer(
);
onClose();
};
const isPasswordTarget = targetElement instanceof HTMLInputElement && targetElement.type === 'password';
const spellcheckEnabled = Spellcheck.enabled;
const hasMisspelling = spellcheckEnabled && misspelledWord && suggestions.length > 0;
return (
@@ -307,19 +307,21 @@ export const TextareaContextMenu = observer(
{i18n._(SELECT_ALL_DESCRIPTOR)}
</MenuItem>
</MenuGroup>
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--5">
<MenuItemSubmenu
label={i18n._(SPELLCHECK_DESCRIPTOR)}
render={() => (
<SpellcheckSubmenu
isElectron={isElectron()}
onOpenSpellcheckSettings={handleOpenSpellcheckSettings}
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu"
/>
)}
data-flx="channel.textarea.textarea-context-menu.menu-item-submenu"
/>
</MenuGroup>
{!isPasswordTarget && (
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--5">
<MenuItemSubmenu
label={i18n._(SPELLCHECK_DESCRIPTOR)}
render={() => (
<SpellcheckSubmenu
isElectron={isElectron()}
onOpenSpellcheckSettings={handleOpenSpellcheckSettings}
data-flx="channel.textarea.textarea-context-menu.spellcheck-submenu"
/>
)}
data-flx="channel.textarea.textarea-context-menu.menu-item-submenu"
/>
</MenuGroup>
)}
{showSendButtonToggle && (
<MenuGroup data-flx="channel.textarea.textarea-context-menu.menu-group--6">
<CheckboxItem
@@ -56,10 +56,6 @@ const FORCE_LOADING_SKELETON_DESCRIPTOR = msg({
message: 'Force loading skeleton',
comment: 'Developer tools debug menu label. Internal-only surface for developers; translators may keep this terse.',
});
const FORCE_UPDATE_READY_DESCRIPTOR = msg({
message: 'Force update ready',
comment: 'Developer option label for simulating an available update.',
});
const SHOW_MYSELF_TYPING_DESCRIPTOR = msg({
message: 'Show myself typing',
comment: 'Developer option label for showing the current user as typing.',
@@ -161,10 +157,6 @@ export const getToggleGroups = (): Array<ToggleGroup> => [
items: [
{key: 'bypassLoadingSkeleton', label: BYPASS_LOADING_SKELETON_DESCRIPTOR},
{key: 'forceLoadingSkeleton', label: FORCE_LOADING_SKELETON_DESCRIPTOR},
{
key: 'forceUpdateReady',
label: FORCE_UPDATE_READY_DESCRIPTOR,
},
{
key: 'showMyselfTyping',
label: SHOW_MYSELF_TYPING_DESCRIPTOR,
@@ -66,6 +66,10 @@ const TERMS_ACCEPTANCE_NAGBAR_DESCRIPTOR = msg({
message: 'Terms acceptance nagbar',
comment: 'Developer control label for the terms-acceptance banner.',
});
const PRIVACY_SETUP_NAGBAR_DESCRIPTOR = msg({
message: 'Privacy setup nagbar',
comment: 'Developer control label for the privacy setup banner.',
});
const VOICE_SESSION_RESTORE_NAGBAR_DESCRIPTOR = msg({
message: 'Voice session restore nagbar',
comment: 'Developer control label for the voice-session-restore banner.',
@@ -301,6 +305,18 @@ export const getNagbarControls = (): Array<NagbarControlDefinition> => [
forceShowDisabled: (state) => state.forceTermsAcceptance,
forceHideDisabled: (state) => state.forceHideTermsAcceptance,
},
{
key: 'forcePrivacySetup',
label: PRIVACY_SETUP_NAGBAR_DESCRIPTOR,
forceKey: 'forcePrivacySetup',
forceHideKey: 'forceHidePrivacySetup',
resetKeys: ['forcePrivacySetup'],
status: (state) =>
state.forcePrivacySetup ? FORCE_ENABLED : state.forceHidePrivacySetup ? FORCE_DISABLED : USING_ACTUAL_STATE,
useActualDisabled: (state) => !state.forcePrivacySetup && !state.forceHidePrivacySetup,
forceShowDisabled: (state) => state.forcePrivacySetup,
forceHideDisabled: (state) => state.forceHidePrivacySetup,
},
{
key: 'forceDesktopDownload',
label: DESKTOP_DOWNLOAD_NAGBAR_DESCRIPTOR,
@@ -28,11 +28,6 @@ export type DeveloperOptionsState = Readonly<{
forceRenderPlaceholders: boolean;
forceEmbedSkeletons: boolean;
forceMediaLoading: boolean;
forceUpdateReady: boolean;
forceNativeUpdateReady: boolean;
mockNativeUpdateProgress: number | null;
forceWebUpdateReady: boolean;
mockUpdaterState: 'none' | 'checking' | 'available' | 'downloading' | 'ready' | 'installing' | 'error';
showMyselfTyping: boolean;
slowAttachmentUpload: boolean;
slowMessageLoad: boolean;
@@ -103,11 +98,6 @@ class DeveloperOptions implements DeveloperOptionsState {
forceRenderPlaceholders = false;
forceEmbedSkeletons = false;
forceMediaLoading = false;
forceUpdateReady = false;
forceNativeUpdateReady = false;
mockNativeUpdateProgress: number | null = null;
forceWebUpdateReady = false;
mockUpdaterState: DeveloperOptionsState['mockUpdaterState'] = 'none';
showMyselfTyping = false;
slowAttachmentUpload = false;
slowMessageLoad = false;
@@ -181,11 +171,6 @@ class DeveloperOptions implements DeveloperOptionsState {
'forceRenderPlaceholders',
'forceEmbedSkeletons',
'forceMediaLoading',
'forceUpdateReady',
'forceNativeUpdateReady',
'mockNativeUpdateProgress',
'forceWebUpdateReady',
'mockUpdaterState',
'showMyselfTyping',
'slowAttachmentUpload',
'slowMessageLoad',

Some files were not shown because too many files have changed in this diff Show More