Compare commits

...
Author SHA1 Message Date
Hampus 70fd9e6331 refactor(api): drop ignored location param from thread routes (#3371) 2026-10-10 17:57:15 +02:00
Hampus c14b7d4cb3 fix(members): show mobile badges and fresh profiles in lists (#3370) 2026-10-10 17:00:30 +02:00
Hampus 0f0ff5f3df feat(schema): add swipe right to left action setting (#3369) 2026-10-10 16:36:13 +02:00
Hampus b4c614d21a fix(threads): show presence in thread member list (#3368) 2026-10-10 15:58:59 +02:00
Hampus bef5e33eec feat(voice): add opt-in peer-to-peer calls and country rollouts (#3367) 2026-10-10 15:45:17 +02:00
Xeon e5a568acca fix(flatpak): Add permissions necessary for security key/card SSO (#3366) 2026-10-10 14:30:11 +02:00
Hampus a208b56354 feat(app): let Windows users switch back to Fluxer Sans (#3365) 2026-10-10 03:15:30 +02:00
Hampus 71be6c1de4 build(fonts): restore symmetric smoothing for Plex at every size (#3364) 2026-10-10 03:15:00 +02:00
Hampus eae905b1da fix(app): use the Windows system font for UI text on Windows (#3363) 2026-10-10 03:14:25 +02:00
Hampus 10cd4f96a4 fix(ui): let report modal steps scroll when zoomed in (#3362) 2026-10-10 01:46:24 +02:00
Hampus 9d74daf8ad fix(reports): drop the in-app DSA link, link copyright help (#3361) 2026-10-10 01:46:19 +02:00
Hampus 6f344def93 test(api): backdate seeded evidence in report deletion tests (#3360) 2026-10-10 01:23:10 +02:00
Hampus 72f9f9c396 fix(settings): group account security into clear cards (#3359) 2026-10-10 00:59:33 +02:00
Hampus c8755885d4 fix(privacy): clarify the privacy settings page (#3358) 2026-10-10 00:58:55 +02:00
Hampus 12d503d4c5 feat(app): show current DM settings and keep them by default (#3357) 2026-10-10 00:58:10 +02:00
Hampus 2f6201bcbd feat(app): guide privacy review with dots and an inline alert (#3356) 2026-10-10 00:57:26 +02:00
Hampus ec681e6061 fix(desktop): retry an update that never installed (#3354) 2026-10-09 15:03:36 +02:00
Hampus 0fc6fad11d fix(ui): report the shown carousel step so report flows react (#3353) 2026-10-09 14:53:08 +02:00
Hampus eb329f2cd0 fix(app): add missing data-flx attributes (#3351) 2026-10-09 14:18:01 +02:00
Hampus 9a20821332 feat(app,api): let group DM owners allow mature content (#3350) 2026-10-09 14:12:13 +02:00
Hampus 4749eb7f86 ci(desktop): use a clean profile for each Windows update check (#3349) 2026-10-09 08:32:45 +02:00
Hampus 2d83fd2a93 ci(desktop): keep the logs from Windows update checks (#3348) 2026-10-09 08:22:21 +02:00
Hampus d180af6ba2 fix(desktop): clean up release check runs on Windows (#3347) 2026-10-09 06:53:50 +02:00
Hampus 4810eb3e24 feat(ci): run the release check on built Windows shells (#3346) 2026-10-09 05:40:01 +02:00
Hampus 48a19dedd8 fix(desktop): stop requiring the removed win-toast at startup (#3345) 2026-10-09 05:39:56 +02:00
Hampus cadf7ea532 feat(schema): add double tap action to synced preferences (#3344) 2026-10-09 04:09:24 +02:00
Hampus fc60d05f17 feat(app): load heavy desktop assets on demand, update in place (#3343) 2026-10-09 03:53:57 +02:00
Hampus 5072488f1e feat(desktop): prefer the bundled renderer, own update prompts (#3341) 2026-10-09 03:01:15 +02:00
Hampus 7a5b9a0ded docs(threads): publish threads, forums and media channels (#3342) 2026-10-09 02:44:06 +02:00
Hampus 4ce4d4e09a feat(ci): bundle the renderer in the desktop shell (#3339) 2026-10-09 02:31:37 +02:00
699 changed files with 44973 additions and 16370 deletions

No files matched your search

+67 -1
View File
@@ -551,6 +551,16 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step verify_windows_unpacked_signatures
- name: Verify the packaged shell starts and boots its bundled renderer (Windows)
if: matrix.platform == 'windows' && matrix.arch == 'x64'
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
env:
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
BUILD_VERSION: ${{ env.VERSION }}
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step verify_bundled_renderer_windows
- name: Create portable ZIP (Windows)
if: matrix.platform == 'windows'
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
@@ -591,6 +601,16 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_app_linux
- name: Verify the packaged shell boots its bundled renderer (Linux)
if: matrix.platform == 'linux' && matrix.arch == 'x64'
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
env:
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
BUILD_VERSION: ${{ env.VERSION }}
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step verify_bundled_renderer_linux
- name: Verify signed Windows artifacts
if: matrix.platform == 'windows'
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
@@ -651,13 +671,59 @@ jobs:
retention-days: 1
compression-level: 0
verify_windows_arm64:
name: Verify windows (arm64)
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs:
- meta
- build
runs-on: windows-11-arm
timeout-minutes: 30
permissions:
actions: read
contents: read
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 }}
sparse-checkout: fluxer_desktop/scripts
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 24
- name: Download windows arm64 build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: artifacts
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-windows-arm64*
- name: Verify the packaged shell starts and boots its bundled renderer
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$zip = Get-ChildItem -Path artifacts -Recurse -Filter "*-$env:VERSION-portable-win-arm64.zip" | Select-Object -First 1
if ($null -eq $zip) { Write-Host 'This build has no windows arm64 portable zip'; exit 0 }
$app = Join-Path $env:RUNNER_TEMP 'app'
Expand-Archive -Path $zip.FullName -DestinationPath $app
$exe = Get-ChildItem -Path $app -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
if ($null -eq $exe) { throw "$($zip.Name) holds no Fluxer executable" }
node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --package-origin http://127.0.0.1:9 --app-arg=--disable-gpu --workdir (Join-Path $env:RUNNER_TEMP 'runs') --timeout-seconds 240
if ($LASTEXITCODE -ne 0) { throw "The windows arm64 build fails its release check" }
upload:
name: Assemble desktop release assets
if: ${{ !cancelled() && needs.build.result == 'success' }}
if: ${{ !cancelled() && needs.build.result == 'success' && needs.verify_windows_arm64.result == 'success' }}
needs:
- meta
- shared_assets
- build
- verify_windows_arm64
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 180
@@ -0,0 +1,152 @@
name: desktop windows release check
on:
workflow_dispatch:
inputs:
channel:
description: Release channel
type: choice
options:
- canary
- stable
default: canary
version:
description: Published version to check
required: true
type: string
expect_failure:
description: Pass only when the published build fails to start
type: boolean
default: false
update_from:
description: Comma-separated published versions to install with Setup and update to the version above from the live feed
required: false
type: string
default: ""
stale_apply_record:
description: Before each update, leave behind an hour-old record of an update that never installed (passes only when the installed versions retry it)
type: boolean
default: false
pull_request:
paths:
- .github/workflows/desktop-windows-release-check.yaml
- fluxer_desktop/scripts/release-check.mjs
permissions:
contents: read
concurrency:
group: desktop-windows-release-check-${{ github.ref }}-${{ inputs.version || 'pr' }}
cancel-in-progress: false
jobs:
check:
name: Check windows (${{ matrix.arch }})
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- arch: x64
os: windows-2025
- arch: arm64
os: windows-11-arm
env:
CHANNEL: ${{ inputs.channel || 'canary' }}
VERSION: ${{ inputs.version || '2026.1009.20411' }}
EXPECT_FAILURE: ${{ github.event_name == 'pull_request' && 'true' || inputs.expect_failure }}
UPDATE_FROM: ${{ inputs.update_from || '' }}
STALE_APPLY_RECORD: ${{ inputs.stale_apply_record && 'true' || 'false' }}
ARCH: ${{ matrix.arch }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
sparse-checkout: fluxer_desktop/scripts
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 24
- name: Check the portable build
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$work = Join-Path $env:RUNNER_TEMP 'portable'
New-Item -ItemType Directory -Force -Path $work | Out-Null
$zip = Join-Path $work 'portable.zip'
Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$env:VERSION/portable" -OutFile $zip
Expand-Archive -Path $zip -DestinationPath (Join-Path $work 'app')
$exe = Get-ChildItem -Path (Join-Path $work 'app') -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
if ($null -eq $exe) { throw "The portable build holds no Fluxer executable" }
$output = & node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --app-arg=--disable-gpu --workdir (Join-Path $work 'runs') --timeout-seconds 240 2>&1
$code = $LASTEXITCODE
$output | ForEach-Object { Write-Host $_ }
if ($env:EXPECT_FAILURE -eq 'true') {
if ($code -eq 0) { throw "Expected $env:VERSION to fail its release check, it passed" }
Write-Host "$env:VERSION fails its release check on windows $env:ARCH as expected"
exit 0
}
if ($code -ne 0) { throw "The release check failed for $env:VERSION on windows $env:ARCH" }
- name: Update installed builds from the live feed
if: env.UPDATE_FROM != ''
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$channelDirs = @{ canary = 'fluxer_desktop_canary'; stable = 'fluxer_desktop' }
$dataDirs = @{ canary = 'fluxercanary'; stable = 'fluxer' }
$installRoot = Join-Path $env:LOCALAPPDATA $channelDirs[$env:CHANNEL]
$logPath = Join-Path (Join-Path $env:APPDATA $dataDirs[$env:CHANNEL]) 'logs\main.log'
$failures = @()
foreach ($from in ($env:UPDATE_FROM -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
Write-Host "== install $from, then update to $env:VERSION"
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
if (Test-Path $installRoot) { Remove-Item -Recurse -Force $installRoot }
Remove-Item -Recurse -Force (Split-Path (Split-Path $logPath -Parent) -Parent) -ErrorAction SilentlyContinue
$setup = Join-Path $env:RUNNER_TEMP "setup-$from.exe"
Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$from/setup" -OutFile $setup
Start-Process -FilePath $setup -ArgumentList '--silent' -Wait
Start-Sleep -Seconds 5
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
$exe = Get-ChildItem -Path (Join-Path $installRoot 'current') -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
if ($null -eq $exe) { $failures += "$from did not install"; continue }
if ($env:STALE_APPLY_RECORD -eq 'true') {
$dataRoot = Split-Path (Split-Path $logPath -Parent) -Parent
New-Item -ItemType Directory -Force -Path $dataRoot | Out-Null
$attemptedAt = [DateTimeOffset]::UtcNow.AddHours(-1).ToUnixTimeMilliseconds()
Set-Content -Path (Join-Path $dataRoot 'update-apply-state.json') -Value "{`"version`":`"$env:VERSION`",`"attemptedAt`":$attemptedAt}" -NoNewline
Write-Host "left a stale apply record for $env:VERSION"
}
Start-Process -FilePath $exe.FullName -ArgumentList '--disable-gpu'
$deadline = (Get-Date).AddMinutes(8)
$reported = $null
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 10
if (Test-Path $logPath) {
$line = Select-String -Path $logPath -Pattern "The renderer reported its build: .*Desktop $([regex]::Escape($env:VERSION)), Web $([regex]::Escape($env:VERSION))" | Select-Object -Last 1
if ($null -ne $line) { $reported = $line.Line; break }
}
}
$installed = Get-Content (Join-Path $installRoot 'current\sq.version') -Raw -ErrorAction SilentlyContinue
Write-Host "installed package after update: $installed"
$logs = Join-Path $env:RUNNER_TEMP "logs\$from"
New-Item -ItemType Directory -Force -Path $logs | Out-Null
if (Test-Path $logPath) { Copy-Item $logPath $logs }
Get-ChildItem -Path $installRoot -Filter '*.log' -ErrorAction SilentlyContinue | Copy-Item -Destination $logs
if (Test-Path $logPath) { Get-Content $logPath | Select-String -Pattern 'Bootstrap|ShellSelfUpdate|Velopack|velopack|NativeModulePreflight|reported its build' | Select-Object -Last 40 | ForEach-Object { Write-Host $_.Line } }
if ($null -eq $reported) { $failures += "$from did not reach Desktop and Web $env:VERSION" } else { Write-Host "$from updated: $reported" }
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
}
if ($failures.Count -gt 0) { throw ($failures -join "`n") }
- name: Upload update logs
if: always() && env.UPDATE_FROM != ''
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: update-logs-${{ matrix.arch }}
path: ${{ runner.temp }}/logs
if-no-files-found: ignore
retention-days: 7
+5
View File
@@ -297,6 +297,11 @@ LIVEKIT_API_SECRET=CHANGE_ME
#FLUXER_LIVEKIT_DEFAULT_REGION={"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}
#FLUXER_LIVEKIT_LOG_LEVEL=info
# STUN servers for peer-to-peer calls, which need the voice_p2p experiment turned
# on in the admin panel. STUN finds each peer's public address. The value below
# is an example.
#FLUXER_VOICE_P2P_STUN_URLS=stun:stun.example.com:3478
FLUXER_KLIPY_API_KEY=
#FLUXER_YOUTUBE_API_KEY=
# Hosts the api never unfurls, comma separated.
+1
View File
@@ -104,6 +104,7 @@ x-fluxer-env: &fluxer-env
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
FLUXER_LIVEKIT_DEFAULT_REGION: '${FLUXER_LIVEKIT_DEFAULT_REGION:-{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}}'
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
FLUXER_VOICE_P2P_STUN_URLS: ${FLUXER_VOICE_P2P_STUN_URLS:-}
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
FLUXER_YOUTUBE_API_KEY: ${FLUXER_YOUTUBE_API_KEY:-}
+98
View File
@@ -11268,6 +11268,7 @@
"gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"},
"push_relay": {"$ref": "#/components/schemas/PushRelayConfigResponse"},
"domain_migration": {"$ref": "#/components/schemas/DomainMigrationConfigResponse"},
"voice_p2p": {"$ref": "#/components/schemas/VoiceP2pConfigResponse"},
"captcha": {"$ref": "#/components/schemas/CaptchaConfigResponse"},
"experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"},
"registration": {
@@ -11682,6 +11683,7 @@
"gateway_rollout",
"push_relay",
"domain_migration",
"voice_p2p",
"captcha",
"experiment_delivery",
"registration",
@@ -11820,6 +11822,7 @@
"nullable": true,
"allOf": [{"$ref": "#/components/schemas/DomainMigrationConfigUpdateRequest"}]
},
"voice_p2p": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/VoiceP2pConfigUpdateRequest"}]},
"captcha": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/CaptchaConfigUpdateRequest"}]},
"experiment_delivery": {
"nullable": true,
@@ -14679,6 +14682,10 @@
"nullable": true,
"type": "string"
},
"rtc_p2p": {
"description": "Whether calls in the voice channel connect participants directly instead of through a voice server",
"type": "boolean"
},
"last_message_id": {
"description": "The ID of the last message sent in this channel",
"nullable": true,
@@ -16314,11 +16321,38 @@
"max_counter": {"type": "integer", "minimum": 100, "maximum": 20000}
}
},
"VoiceP2pConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_country_codes": {"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"type": "boolean"},
"excluded_user_ids": {
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"max_participants": {"type": "integer", "minimum": 2, "maximum": 4}
}
},
"DomainMigrationConfigUpdateRequest": {
"type": "object",
"properties": {
"enabled": {"type": "boolean"},
"rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000},
"rollout_country_codes": {"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]},
"rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[\\x20-\\x7e]+$"},
"included_user_ids": {
"maxItems": 1000,
@@ -16355,6 +16389,11 @@
"voice_e2ee_scope": {"type": "string", "enum": ["guild_feature_only", "platform_wide"]}
}
},
"ExperimentRolloutCountryCodesSchema": {
"maxItems": 250,
"type": "array",
"items": {"type": "string", "pattern": "^[A-Z]{2}$"}
},
"BillingLegacyPricesSchema": {
"type": "object",
"additionalProperties": {
@@ -16470,12 +16509,68 @@
"required": ["enabled", "cost", "max_counter"],
"additionalProperties": false
},
"VoiceP2pConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_country_codes": {
"default": [],
"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]
},
"rollout_salt": {
"default": "voice-p2p-v1",
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[\\x20-\\x7e]+$"
},
"included_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"included_guild_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"include_premium_users": {"default": false, "type": "boolean"},
"excluded_user_ids": {
"default": [],
"maxItems": 1000,
"type": "array",
"items": {"type": "string", "pattern": "^\\d{1,20}$"}
},
"max_participants": {"default": 2, "type": "integer", "minimum": 2, "maximum": 4}
},
"required": [
"enabled",
"config_version",
"rollout_basis_points",
"rollout_country_codes",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
"include_premium_users",
"excluded_user_ids",
"max_participants"
],
"additionalProperties": false
},
"DomainMigrationConfigResponse": {
"type": "object",
"properties": {
"enabled": {"default": false, "type": "boolean"},
"config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991},
"rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000},
"rollout_country_codes": {
"default": [],
"allOf": [{"$ref": "#/components/schemas/ExperimentRolloutCountryCodesSchema"}]
},
"rollout_salt": {
"default": "domain-migration-v1",
"type": "string",
@@ -16509,6 +16604,7 @@
"enabled",
"config_version",
"rollout_basis_points",
"rollout_country_codes",
"rollout_salt",
"included_user_ids",
"included_guild_ids",
@@ -17262,6 +17358,7 @@
"MAX_GROUP_DM_RECIPIENTS",
"MAX_GROUP_DMS",
"GROUP_DM_RECIPIENTS_NOT_ADDABLE",
"GROUP_DM_MATURE_CONTENT_INELIGIBLE",
"MAX_GUILD_CHANNELS",
"MAX_GUILD_MEMBERS",
"MAX_GUILD_ROLES",
@@ -17558,6 +17655,7 @@
"You've reached the maximum of {count, plural, one {# group DM recipient} other {# group DM recipients}}.",
"You've reached the maximum of {count, plural, one {# group DM} other {# group DMs}}.",
"One or more selected users can't be added to this group DM.",
"Not everyone in this group is eligible for mature content.",
"You've reached the maximum of {count, plural, one {# community channel} other {# community channels}}.",
"You've reached the maximum of {count, plural, one {# community member} other {# community members}}.",
"You've reached the maximum of {count, plural, one {# community role} other {# community roles}}.",
@@ -27,6 +27,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub voice_p2p: VoiceP2pConfigResponse,
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
@@ -506,7 +508,10 @@ impl VoiceE2eeScope {
}
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES: usize = 250;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const VOICE_P2P_DEFAULT_SALT: &str = "voice-p2p-v1";
pub const VOICE_P2P_MAX_PARTICIPANTS_RANGE: std::ops::RangeInclusive<u32> = 2..=4;
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
@@ -530,6 +535,7 @@ pub struct DomainMigrationConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_country_codes: Vec<String>,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
@@ -545,6 +551,7 @@ impl Default for DomainMigrationConfigResponse {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_country_codes: Vec::new(),
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
@@ -563,6 +570,8 @@ pub struct DomainMigrationConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_country_codes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
@@ -578,6 +587,60 @@ pub struct DomainMigrationConfigUpdateRequest {
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct VoiceP2pConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_country_codes: Vec<String>,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
pub max_participants: u32,
}
impl Default for VoiceP2pConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_country_codes: Vec::new(),
rollout_salt: VOICE_P2P_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
max_participants: *VOICE_P2P_MAX_PARTICIPANTS_RANGE.start(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct VoiceP2pConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_country_codes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_participants: Option<u32>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CaptchaConfigResponse {
@@ -724,6 +787,8 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_p2p: Option<VoiceP2pConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
@@ -1028,17 +1093,22 @@ mod tests {
.expect("admin schema");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let voice_p2p = serde_json::from_value::<VoiceP2pConfigResponse>(json!({}))
.expect("default voice p2p config");
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
.expect("default captcha config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let voice_p2p = serde_json::to_value(voice_p2p).expect("serializable voice p2p config");
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_voice_p2p: generated_types::VoiceP2pConfigResponse =
serde_json::from_value(voice_p2p.clone()).expect("generated voice p2p config contract");
let generated_captcha: generated_types::CaptchaConfigResponse =
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
@@ -1048,6 +1118,11 @@ mod tests {
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_voice_p2p)
.expect("serializable generated voice p2p config"),
voice_p2p
);
assert_eq!(
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
captcha
@@ -1059,6 +1134,7 @@ mod tests {
);
for (name, value) in [
("DomainMigrationConfigResponse", domain_migration),
("VoiceP2pConfigResponse", voice_p2p),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
@@ -1093,4 +1169,25 @@ mod tests {
json!({})
);
}
#[test]
fn voice_p2p_update_preserves_empty_lists_and_omitted_fields() {
let update = VoiceP2pConfigUpdateRequest {
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::VoiceP2pConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(VoiceP2pConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
+237 -13
View File
@@ -8,18 +8,19 @@ use crate::{
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VoiceE2eeScope,
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceGifIntegrationUpdateRequest,
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
},
},
config::AdminConfig,
@@ -220,6 +221,10 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_voice_p2p" => match build_voice_p2p_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_captcha" => match build_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -557,6 +562,32 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
Ok(ids)
}
fn parse_experiment_country_codes(value: &str) -> Result<Vec<String>, String> {
let mut codes: Vec<String> = Vec::new();
let candidates = value
.split(|character: char| character == ',' || character.is_whitespace())
.filter(|candidate| !candidate.is_empty());
for (index, candidate) in candidates.enumerate() {
if candidate.len() != 2 || !candidate.bytes().all(|byte| byte.is_ascii_alphabetic()) {
return Err(format!(
"Rollout countries entry {} must be a two-letter country code",
index + 1
));
}
let code = candidate.to_ascii_uppercase();
if codes.contains(&code) {
continue;
}
if codes.len() == EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES {
return Err(format!(
"Rollout countries must contain at most {EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES} unique codes"
));
}
codes.push(code);
}
Ok(codes)
}
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
push_relay: Some(PushRelayConfigUpdateRequest {
@@ -579,6 +610,10 @@ fn build_domain_migration_update(
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_country_codes: Some(parse_experiment_country_codes(
form.first("domain_migration_rollout_country_codes")
.unwrap_or_default(),
)?),
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("domain_migration_included_user_ids")
@@ -609,6 +644,50 @@ fn build_domain_migration_update(
})
}
fn build_voice_p2p_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
voice_p2p: Some(VoiceP2pConfigUpdateRequest {
enabled: Some(form.bool_value("voice_p2p_enabled")),
rollout_basis_points: parse_form_number(
form,
"voice_p2p_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_country_codes: Some(parse_experiment_country_codes(
form.first("voice_p2p_rollout_country_codes")
.unwrap_or_default(),
)?),
rollout_salt: parse_experiment_rollout_salt(form, "voice_p2p_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("voice_p2p_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("voice_p2p_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("voice_p2p_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("voice_p2p_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
max_participants: parse_form_number(
form,
"voice_p2p_max_participants",
"Maximum participants",
*VOICE_P2P_MAX_PARTICIPANTS_RANGE.start(),
*VOICE_P2P_MAX_PARTICIPANTS_RANGE.end(),
)?,
}),
..Default::default()
})
}
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
captcha: Some(CaptchaConfigUpdateRequest {
@@ -1340,10 +1419,62 @@ mod tests {
);
}
#[test]
fn parse_experiment_country_codes_splits_uppercases_and_dedupes() {
assert_eq!(
parse_experiment_country_codes(" se ,NO\nbr\r\n fi\tSE ,, no ").expect("valid codes"),
vec![
"SE".to_owned(),
"NO".to_owned(),
"BR".to_owned(),
"FI".to_owned()
]
);
assert_eq!(
parse_experiment_country_codes(" \n ").expect("valid codes"),
Vec::<String>::new()
);
}
#[test]
fn parse_experiment_country_codes_rejects_malformed_codes() {
for value in ["S", "SWE", "S1", "5E", "<>", "ÅL"] {
assert_eq!(
parse_experiment_country_codes(&format!("SE,{value}")).expect_err("invalid code"),
"Rollout countries entry 2 must be a two-letter country code",
"{value}"
);
}
}
#[test]
fn parse_experiment_country_codes_rejects_exceeding_the_cap() {
let codes = (b'A'..=b'Z')
.flat_map(|first| {
(b'A'..=b'Z').map(move |second| format!("{}{}", first as char, second as char))
})
.collect::<Vec<_>>();
let at_cap = codes[..EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES].join(" ");
assert_eq!(
parse_experiment_country_codes(&format!("{at_cap} aa"))
.expect("valid codes at cap")
.len(),
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES
);
assert_eq!(
parse_experiment_country_codes(&format!(
"{at_cap} {}",
codes[EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES]
))
.expect_err("too many codes"),
"Rollout countries must contain at most 250 unique codes"
);
}
#[test]
fn build_domain_migration_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true&domain_migration_rollout_country_codes=se%2C%20NO%0Ase",
);
let update = build_domain_migration_update(&form)
.expect("valid form")
@@ -1369,6 +1500,10 @@ mod tests {
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
assert_eq!(update.standalone_forwarding, Some(true));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.rollout_country_codes,
Some(vec!["SE".to_owned(), "NO".to_owned()])
);
assert_eq!(
update.included_guild_ids,
Some(vec![
@@ -1386,6 +1521,7 @@ mod tests {
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"domain_migration": {
"enabled": false,
"rollout_country_codes": [],
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
@@ -1430,6 +1566,10 @@ mod tests {
"domain_migration_excluded_user_ids=123%2Cinvalid",
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"domain_migration_rollout_country_codes=SE%2CSWE",
"Rollout countries entry 2 must be a two-letter country code",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
@@ -1519,6 +1659,90 @@ mod tests {
);
}
#[test]
fn build_voice_p2p_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"voice_p2p_enabled=true&voice_p2p_rollout_basis_points=%20500%20&voice_p2p_rollout_salt=%20voice-p2p-v2%20&voice_p2p_included_user_ids=1500000000000000001&voice_p2p_excluded_user_ids=1500000000000000002&voice_p2p_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&voice_p2p_include_premium_users=true&voice_p2p_rollout_country_codes=se%2C%20NO%0Ase&voice_p2p_max_participants=%203%20",
);
let update = build_voice_p2p_update(&form)
.expect("valid form")
.voice_p2p
.expect("voice p2p update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("voice-p2p-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(update.max_participants, Some(3));
assert_eq!(
update.rollout_country_codes,
Some(vec!["SE".to_owned(), "NO".to_owned()])
);
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_voice_p2p_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_voice_p2p_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"voice_p2p": {
"enabled": false,
"rollout_country_codes": [],
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_voice_p2p_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"voice_p2p_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"voice_p2p_included_guild_ids=1500000000000000005%0Anot-a-guild",
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
),
(
"voice_p2p_rollout_country_codes=SE%2CSWE",
"Rollout countries entry 2 must be a two-letter country code",
),
(
"voice_p2p_max_participants=5",
"Maximum participants must be a whole number between 2 and 4",
),
(
"voice_p2p_max_participants=1",
"Maximum participants must be a whole number between 2 and 4",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_voice_p2p_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
@@ -5,11 +5,12 @@ use crate::{
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, PendingRegistrationResponse, PushRelayConfigResponse,
RegistrationUrlResponse, SsoConfigResponse, TagStyle,
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -58,6 +59,22 @@ fn entry_count_hint(count: usize, cap: usize) -> Markup {
}
}
fn experiment_rollout_country_codes_field(name: &str, country_codes: &[String]) -> Markup {
html! {
div class="flex flex-col gap-2" {
(text_input(name, "Rollout Countries", &country_codes.join(", "), "SE, NO, FI"))
(entry_count_hint(country_codes.len(), EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES))
p class="text-xs text-neutral-500" {
"Two-letter ISO 3166-1 country codes, separated by commas or spaces. When set, \
the user rollout percentage only applies to requests from these countries, and \
a request with an unknown country is left out. Leave empty to apply the \
percentage everywhere. The always-on lists, the premium switch and the \
never-on list ignore the country. Invalid entries prevent the save."
}
}
}
}
fn number_field(
name: &str,
label: &str,
@@ -193,6 +210,7 @@ pub fn instance_config_page(
@if !instance_config.self_hosted {
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
}
(voice_p2p_section(base, csrf_token, &instance_config.voice_p2p))
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
@if let Some(limit_config) = limit_config {
(limit_config_section(base, limit_config))
@@ -1193,6 +1211,10 @@ fn domain_migration_section(
Some(0), Some(10000), "1",
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
))
(experiment_rollout_country_codes_field(
"domain_migration_rollout_country_codes",
&domain_migration.rollout_country_codes,
))
div class="flex flex-col gap-2" {
(text_input(
"domain_migration_rollout_salt",
@@ -1287,6 +1309,162 @@ fn domain_migration_section(
)
}
fn voice_p2p_section(base: &str, csrf_token: &str, voice_p2p: &VoiceP2pConfigResponse) -> Markup {
let status = if voice_p2p.enabled {
("Live", BadgeVariant::Success)
} else {
("Inert", BadgeVariant::Default)
};
let included_user_ids = voice_p2p.included_user_ids.join("\n");
let excluded_user_ids = voice_p2p.excluded_user_ids.join("\n");
section_card_with_description(
"Peer-to-peer voice",
"Lets selected users start small voice calls that connect the participants directly \
instead of through the voice servers. A peer-to-peer call only moves to the voice \
servers when a participant switches it or its region changes.",
html! {
form method="post" action={(base) "/instance-config?action=update_voice_p2p"} {
(csrf_input(csrf_token))
div class="space-y-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
(badge(status.0, status.1))
span class="text-xs text-neutral-500" {
"Config version " (voice_p2p.config_version)
}
}
(checkbox(
"voice_p2p_enabled",
"true",
"Let the selected users start peer-to-peer calls",
voice_p2p.enabled,
true,
))
p class="text-xs text-neutral-500" {
"Off is the safe state and the kill switch. With this unchecked every new \
call uses the voice servers and a running peer-to-peer call accepts no \
new joins, so the rollout and targeting fields below have no effect at \
all. While it is on, the fields below select who can start a \
peer-to-peer call, and anyone who agrees to a direct connection can \
join one."
}
h3 class="text-sm font-semibold text-neutral-900" { "Call size" }
(number_field(
"voice_p2p_max_participants",
"Maximum participants",
&voice_p2p.max_participants.to_string(),
Some(*VOICE_P2P_MAX_PARTICIPANTS_RANGE.start()),
Some(*VOICE_P2P_MAX_PARTICIPANTS_RANGE.end()),
"1",
Some("Most participants a peer-to-peer call can hold, 2 through 4. Clients only offer a peer-to-peer call in a direct message or group with at most this many members, and a join past it is refused as full. Lowering it never removes anyone from a running call."),
))
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
(number_field(
"voice_p2p_rollout_basis_points",
"Rollout (basis points)",
&voice_p2p.rollout_basis_points.to_string(),
Some(0), Some(10000), "1",
Some("Share of users who can start peer-to-peer calls, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
))
(experiment_rollout_country_codes_field(
"voice_p2p_rollout_country_codes",
&voice_p2p.rollout_country_codes,
))
div class="flex flex-col gap-2" {
(text_input(
"voice_p2p_rollout_salt",
"Rollout Salt",
&voice_p2p.rollout_salt,
VOICE_P2P_DEFAULT_SALT,
))
p class="text-xs text-neutral-500" {
"Seeds the bucketing hash. Changing it reshuffles which users fall \
inside the percentage above. Leave it alone to keep the current \
cohort stable."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_p2p_included_user_ids",
"Always-on User IDs",
"1500000000000000001\n1500000000000000002",
&included_user_ids,
4,
false,
))
(entry_count_hint(
voice_p2p.included_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"One snowflake per line, or comma separated. These users are targeted \
regardless of the percentage above. IDs must contain 1 to 20 decimal \
digits. Invalid entries prevent the save. Blank entries and duplicate \
IDs are ignored."
}
}
div class="flex flex-col gap-2" {
(checkbox(
"voice_p2p_include_premium_users",
"true",
"Include premium users",
voice_p2p.include_premium_users,
true,
))
p class="text-xs text-neutral-500" {
"Includes every account with active premium perks, regardless of the \
percentage above. The never-on list still wins."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_p2p_included_guild_ids",
"Always-on Guild IDs",
"1500000000000000005\n1500000000000000006",
&voice_p2p.included_guild_ids.join("\n"),
4,
false,
))
(entry_count_hint(
voice_p2p.included_guild_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format, with guild IDs. Every member of a listed guild is \
included regardless of the percentage above, unless the user is \
in the never-on list."
}
}
div class="flex flex-col gap-2" {
(textarea_input(
"voice_p2p_excluded_user_ids",
"Never-on User IDs",
"1500000000000000003\n1500000000000000004",
&excluded_user_ids,
4,
false,
))
(entry_count_hint(
voice_p2p.excluded_user_ids.len(),
EXPERIMENT_MAX_TARGETED_USERS,
))
p class="text-xs text-neutral-500" {
"Same format. Exclusion wins over both the always-on list and the \
percentage."
}
}
(form_actions(html! {
(submit_button("Save Peer-to-Peer Voice Configuration"))
}))
}
}
},
)
}
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
}
@@ -2094,11 +2272,49 @@ mod tests {
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
assert!(markup.contains("name=\"domain_migration_rollout_country_codes\""));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("at the cap"));
}
#[test]
fn voice_p2p_section_shows_the_rollout_and_list_counts() {
let voice_p2p = VoiceP2pConfigResponse {
enabled: true,
config_version: 3,
rollout_basis_points: 250,
rollout_country_codes: vec!["SE".to_owned(), "NO".to_owned()],
max_participants: 3,
included_user_ids: vec!["1500000000000000001".to_owned()],
excluded_user_ids: vec![
"1500000000000000002".to_owned(),
"1500000000000000003".to_owned(),
],
..VoiceP2pConfigResponse::default()
};
let markup = voice_p2p_section("/admin", "csrf", &voice_p2p).into_string();
assert!(markup.contains("Peer-to-peer voice"));
assert!(markup.contains("action=update_voice_p2p"));
assert!(markup.contains("name=\"voice_p2p_enabled\""));
assert!(markup.contains("name=\"voice_p2p_rollout_basis_points\""));
assert!(markup.contains("value=\"250\""));
assert!(markup.contains("name=\"voice_p2p_max_participants\""));
assert!(markup.contains("value=\"3\""));
assert!(markup.contains("min=\"2\""));
assert!(markup.contains("max=\"4\""));
assert!(markup.contains("name=\"voice_p2p_include_premium_users\""));
assert!(markup.contains("name=\"voice_p2p_included_guild_ids\""));
assert!(markup.contains("name=\"voice_p2p_rollout_country_codes\""));
assert!(markup.contains("value=\"SE, NO\""));
assert!(markup.contains("2 of 250 stored"));
assert!(markup.contains("Config version 3"));
assert!(markup.contains("1 of 1000 stored"));
assert!(markup.contains("2 of 1000 stored"));
assert!(!markup.contains("anonymous_rollout_basis_points"));
assert!(!markup.contains("standalone_forwarding"));
}
#[test]
fn push_relay_section_shows_the_consent_toggle() {
let accepted = PushRelayConfigResponse {
+25
View File
@@ -403,6 +403,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"enabled": true,
"config_version": 2,
"rollout_basis_points": 2500,
"rollout_country_codes": ["SE"],
"rollout_salt": "domain-migration-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": [],
@@ -416,6 +417,19 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"anonymous_rollout_basis_points": 100,
"standalone_forwarding": true
},
"voice_p2p": {
"enabled": true,
"config_version": 4,
"rollout_basis_points": 750,
"rollout_country_codes": ["SE", "NO"],
"rollout_salt": "voice-p2p-v1",
"included_user_ids": ["1500000000000000001"],
"excluded_user_ids": ["1500000000000000002"],
"included_guild_ids": ["1500000000000000005"],
"include_premium_users": true,
"max_participants": 3,
"future_voice_p2p_knob": true
},
"captcha": {
"enabled": true,
"cost": 5000,
@@ -569,10 +583,21 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert!(resp.domain_migration.enabled);
assert_eq!(resp.domain_migration.config_version, 2);
assert_eq!(resp.domain_migration.rollout_basis_points, 2500);
assert_eq!(resp.domain_migration.rollout_country_codes.len(), 1);
assert_eq!(*resp.domain_migration.rollout_salt, "domain-migration-v1");
assert_eq!(resp.domain_migration.included_user_ids.len(), 1);
assert_eq!(resp.domain_migration.anonymous_rollout_basis_points, 100);
assert!(resp.domain_migration.standalone_forwarding);
assert!(resp.voice_p2p.enabled);
assert_eq!(resp.voice_p2p.config_version, 4);
assert_eq!(resp.voice_p2p.rollout_basis_points, 750);
assert_eq!(resp.voice_p2p.rollout_country_codes.len(), 2);
assert_eq!(*resp.voice_p2p.rollout_salt, "voice-p2p-v1");
assert_eq!(resp.voice_p2p.included_user_ids.len(), 1);
assert_eq!(resp.voice_p2p.excluded_user_ids.len(), 1);
assert_eq!(resp.voice_p2p.included_guild_ids.len(), 1);
assert!(resp.voice_p2p.include_premium_users);
assert_eq!(resp.voice_p2p.max_participants, 3);
assert!(resp.push_relay.relay_consent_accepted);
assert!(resp.captcha.enabled);
assert_eq!(resp.captcha.max_counter, 1000);
+11
View File
@@ -1391,6 +1391,7 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
"/instance-config?action=update_gateway_rollout",
"/instance-config?action=update_sso",
"/instance-config?action=update_domain_migration",
"/instance-config?action=update_voice_p2p",
"/instance-config?action=update_experiment_delivery",
][..],
),
@@ -2424,6 +2425,16 @@ fn instance_config() -> Value {
"anonymous_rollout_basis_points": 0,
"standalone_forwarding": false
},
"voice_p2p": {
"enabled": false,
"config_version": 0,
"rollout_basis_points": 0,
"rollout_country_codes": [],
"rollout_salt": "voice-p2p-v1",
"included_user_ids": [],
"excluded_user_ids": [],
"max_participants": 2
},
"experiment_delivery": {
"poll_interval_seconds": 300,
"poll_jitter_percent": 15
+1
View File
@@ -318,6 +318,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
url: master.integrations.voice.url,
internalUrl: master.integrations.voice.internal_url,
defaultRegion: master.integrations.voice.default_region,
p2pStunUrls: master.integrations.voice.p2p_stun_urls,
},
stripe: {
enabled: master.integrations.stripe.enabled,
+10
View File
@@ -338,6 +338,10 @@ import {
type UserSettingsRow,
type UsersPendingDeletionRow,
} from '@app/api/database/types/UserTypes';
import {
VOICE_P2P_CONNECTION_REPORT_COLUMNS,
type VoiceP2pConnectionReportRow,
} from '@app/api/database/types/VoiceTypes';
import {ATTACHMENT_DECAY_COLUMNS, type AttachmentDecayRow} from '@app/api/types/AttachmentDecayTypes';
import {seconds} from 'itty-time';
@@ -1474,3 +1478,9 @@ export const BillingActionIntents = defineTable<BillingActionIntentRow, 'intent_
columns: BILLING_ACTION_INTENT_COLUMNS,
primaryKey: ['intent_id'],
});
export const VoiceP2pConnectionReports = defineTable<VoiceP2pConnectionReportRow, 'user_id' | 'report_id'>({
name: 'voice_p2p_connection_reports',
columns: VOICE_P2P_CONNECTION_REPORT_COLUMNS,
primaryKey: ['user_id', 'report_id'],
defaultTtlSeconds: seconds('180 days'),
});
@@ -38,6 +38,7 @@ import {
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
import {VoiceP2pConfigSchema} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {InstanceBranding} from '@fluxer/schema/src/domains/instance/InstanceSchemas';
@@ -66,6 +67,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gatewayRollout,
pushRelay,
domainMigration,
voiceP2p,
captcha,
experimentDelivery,
registrationConfig,
@@ -76,6 +78,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getGatewayRolloutConfig(),
instanceConfigRepository.getPushRelayConfig(),
instanceConfigRepository.getDomainMigrationConfig(),
instanceConfigRepository.getVoiceP2pConfig(),
instanceConfigRepository.getCaptchaConfig(),
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
@@ -113,6 +116,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
gateway_rollout: gatewayRollout,
push_relay: pushRelay,
domain_migration: domainMigration,
voice_p2p: voiceP2p,
captcha,
experiment_delivery: experimentDelivery,
registration: {
@@ -396,6 +400,18 @@ export function InstanceConfigAdminController(app: HonoApp) {
);
}
}
if (data.voice_p2p) {
const patch = omitUndefinedFields(data.voice_p2p);
if (Object.keys(patch).length > 0) {
await instanceConfigRepository.updateVoiceP2pConfig((current) =>
VoiceP2pConfigSchema.parse({
...current,
...patch,
config_version: current.config_version + 1,
}),
);
}
}
if (data.captcha) {
const patch = omitUndefinedFields(data.captcha);
if (Object.keys(patch).length > 0) {
@@ -130,6 +130,9 @@ describe('DELETE /admin/reports/:report_id', () => {
const profileKey = `reports/${reportId}/profile/user_avatar/abc${id}`;
await harness.storageService.uploadObject({bucket: REPORTS_BUCKET, key: attachmentKey, body: new Uint8Array([1])});
await harness.storageService.uploadObject({bucket: REPORTS_BUCKET, key: profileKey, body: new Uint8Array([2])});
const uploadedAt = new Date(Date.now() - ms('1 hour'));
harness.storageService.setObjectLastModified(REPORTS_BUCKET, attachmentKey, uploadedAt);
harness.storageService.setObjectLastModified(REPORTS_BUCKET, profileKey, uploadedAt);
const report = await repository.createReport({
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
report_id: id,
@@ -35,6 +35,7 @@ import {ThemeController} from '@app/api/theme/ThemeController';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {UnfurlController} from '@app/api/unfurl/UnfurlController';
import {UserController} from '@app/api/user/controllers/UserController';
import {VoiceP2pConnectionReportController} from '@app/api/voice/VoiceP2pConnectionReportController';
import {WebhookController} from '@app/api/webhook/WebhookController';
export function registerControllers(routes: HonoApp, config: APIConfig): void {
@@ -67,6 +68,7 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
TestHarnessController(routes);
}
UserController(routes);
VoiceP2pConnectionReportController(routes);
StoreBillingController(routes);
WebhookController(routes);
OAuth2Controller(routes);
@@ -120,6 +120,7 @@ function serializeGuildVoiceChannel(channel: Channel, ctx: ContentWarningCtx): C
user_limit: channel.userLimit,
voice_connection_limit: channel.voiceConnectionLimit,
rtc_region: channel.rtcRegion,
rtc_p2p: channel.rtcP2p,
...serializeContentWarningFields(channel, ctx),
rate_limit_per_user: channel.rateLimitPerUser,
};
@@ -165,6 +166,7 @@ function serializeGroupDMChannel(channel: Channel): ChannelResponse {
icon: channel.iconHash ?? null,
owner_id: channel.ownerId ? channel.ownerId.toString() : null,
nicks: nicknameMap.size > 0 ? nicks : undefined,
nsfw: channel.isNsfw,
};
}
@@ -40,6 +40,7 @@ function gdmRow(overrides: Partial<ChannelRow> = {}): ChannelRow {
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
rtc_p2p: null,
last_message_id: LAST_MESSAGE,
last_pin_timestamp: null,
permission_overwrites: null,
@@ -100,6 +100,7 @@ export function channelRowFromPrivateChannelSnapshot(row: PrivateChannelRow): Ch
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
rtc_p2p: null,
last_message_id: row.channel_last_message_id ?? null,
last_pin_timestamp: row.channel_last_pin_timestamp ?? null,
permission_overwrites: null,
@@ -11,6 +11,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
import {lookupGeoip} from '@app/api/utils/IpUtils';
import {Validator} from '@app/api/Validator';
import {ANNOUNCEMENT_CONVERTIBLE_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {TEXT_THREAD_PARENT_CHANNEL_TYPES, THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
@@ -192,6 +193,7 @@ export function ChannelController(app: HonoApp) {
const requestCache = ctx.get('requestCache');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const channelRequestService = ctx.get('channelRequestService');
const {countryCode} = await lookupGeoip(ctx.req.raw);
return ctx.json(
await channelRequestService.updateChannel({
viewer: viewerFromCtx(ctx),
@@ -202,6 +204,7 @@ export function ChannelController(app: HonoApp) {
requestCache,
auditLogReason,
typeConversion,
countryCode,
}),
);
},
@@ -33,7 +33,6 @@ import {
StartThreadFromMessageRequest,
StartThreadRequest,
ThreadChannelResponse,
ThreadLocationQuery,
ThreadMemberGetQuery,
ThreadMemberListResponse,
ThreadMembersListQuery,
@@ -389,7 +388,6 @@ export function ThreadController(app: HonoApp) {
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_PUT),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'join_thread',
summary: 'Join a thread',
@@ -415,7 +413,6 @@ export function ThreadController(app: HonoApp) {
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_DELETE),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'leave_thread',
summary: 'Leave a thread',
@@ -474,7 +471,6 @@ export function ThreadController(app: HonoApp) {
LoginRequired,
SelfThreadMemberAlias('join'),
Validator('param', ChannelIdUserIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'add_thread_member',
summary: 'Add a thread member',
@@ -504,7 +500,6 @@ export function ThreadController(app: HonoApp) {
LoginRequired,
SelfThreadMemberAlias('leave'),
Validator('param', ChannelIdUserIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'remove_thread_member',
summary: 'Remove a thread member',
@@ -61,6 +61,7 @@ function makeChannelRow(channelId: ChannelID): ChannelRow {
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
rtc_p2p: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
@@ -128,6 +128,7 @@ function channelRow(channelId: ChannelID, type: number, overrides: Partial<Chann
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
rtc_p2p: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
@@ -860,6 +861,7 @@ describe('thread storage leaves control storage untouched', () => {
'user_limit',
'voice_connection_limit',
'rtc_region',
'rtc_p2p',
'last_message_id',
'last_pin_timestamp',
'permission_overwrites',
@@ -149,6 +149,7 @@ export class ChannelDataService {
requestCache,
auditLogReason,
typeConversion,
countryCode,
}: {
userId: UserID;
viewer: ThreadViewer;
@@ -158,6 +159,7 @@ export class ChannelDataService {
requestCache: RequestCache;
auditLogReason: string | null;
typeConversion?: ChannelTypeConversion | null;
countryCode?: string | null;
}): Promise<Channel> {
const authChannel = await this.auth.getChannelAuthenticated({userId, channelId, viewer, skipNsfwValidation: true});
const {channel} = authChannel;
@@ -179,6 +181,7 @@ export class ChannelDataService {
icon: groupDmData.icon !== undefined ? groupDmData.icon : undefined,
ownerId: groupDmData.owner_id ? createUserID(groupDmData.owner_id) : undefined,
nicks: groupDmData.nicks,
nsfw: groupDmData.nsfw ?? undefined,
requestCache,
});
}
@@ -226,6 +229,9 @@ export class ChannelDataService {
if (guildChannelData.rtc_region !== undefined) {
channelUpdateData.rtc_region = guildChannelData.rtc_region ?? null;
}
if (guildChannelData.rtc_p2p !== undefined) {
channelUpdateData.rtc_p2p = guildChannelData.rtc_p2p;
}
if (guildChannelData.icon !== undefined) {
channelUpdateData.icon = guildChannelData.icon ?? null;
}
@@ -245,6 +251,7 @@ export class ChannelDataService {
requestCache,
auditLogReason,
typeConversion,
countryCode,
});
}
}
@@ -122,6 +122,7 @@ export class ChannelRequestService {
requestCache: RequestCache;
auditLogReason: string | null;
typeConversion?: ChannelTypeConversion | null;
countryCode?: string | null;
}): Promise<ChannelResponse> {
if (!('available_tags' in params.data) || params.data.available_tags === undefined) {
return this.applyChannelUpdate(params);
@@ -138,6 +139,7 @@ export class ChannelRequestService {
requestCache: RequestCache;
auditLogReason: string | null;
typeConversion?: ChannelTypeConversion | null;
countryCode?: string | null;
}): Promise<ChannelResponse> {
const channel = await this.channelService.channelData.editChannel({
userId: params.userId,
@@ -148,6 +150,7 @@ export class ChannelRequestService {
requestCache: params.requestCache,
auditLogReason: params.auditLogReason,
typeConversion: params.typeConversion,
countryCode: params.countryCode,
});
return this.maskedChannelResponse(channel, params);
}
@@ -36,6 +36,7 @@ function buildPersonalNotesChannelRow(userId: UserID): ChannelRow {
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
rtc_p2p: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
@@ -32,6 +32,7 @@ import {
type ThreadViewer,
viewerActive,
} from '@app/api/experiment/ChannelThreadsGate';
import {resolveExperimentTargeting} from '@app/api/experiment/ExperimentTargeting';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {mapGuildToGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
@@ -48,6 +49,7 @@ import {Logger} from '@app/api/Logger';
import type {LimitConfigService} from '@app/api/limits/LimitConfigService';
import {createLimitMatchContext} from '@app/api/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import type {Channel} from '@app/api/models/Channel';
import {ChannelPermissionOverwrite} from '@app/api/models/ChannelPermissionOverwrite';
import type {ThreadState} from '@app/api/models/ThreadState';
@@ -81,6 +83,7 @@ import {CannotExecuteOnDmError} from '@fluxer/errors/src/domains/core/CannotExec
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {resolveLimit} from '@fluxer/limits/src/LimitResolver';
import {resolveVoiceP2pAssignment} from '@fluxer/schema/src/domains/admin/VoiceP2pSchemas';
import {ChannelNameType} from '@fluxer/schema/src/primitives/ChannelValidators';
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
@@ -105,6 +108,7 @@ export interface ChannelUpdateData {
deny?: bigint;
}> | null;
rtc_region?: string | null;
rtc_p2p?: boolean;
icon?: string | null;
owner_id?: bigint | null;
nicks?: Record<string, string | null> | null;
@@ -137,6 +141,21 @@ export class ChannelOperationsService {
private snowflakeService: ISnowflakeService,
) {}
private async assertVoiceP2pAssigned(userId: UserID, countryCode: string | null): Promise<void> {
const config = await getInstanceConfigRepository().getVoiceP2pConfig();
const user = config.enabled ? await this.userRepository.findUnique(userId) : null;
if (
user === null ||
!resolveVoiceP2pAssignment(
config,
userId.toString(),
await resolveExperimentTargeting(user, countryCode, [config]),
).enabled
) {
throw InputValidationError.fromCode('rtc_p2p', ValidationErrorCodes.GUILD_FEATURE_NOT_TOGGLEABLE);
}
}
async getChannel({
userId,
viewer,
@@ -183,6 +202,7 @@ export class ChannelOperationsService {
auditLogReason,
typeConversion,
threadParent,
countryCode,
}: {
userId: UserID;
viewer: ThreadViewer;
@@ -193,6 +213,7 @@ export class ChannelOperationsService {
auditLogReason: string | null;
typeConversion?: ChannelTypeConversion | null;
threadParent?: ThreadParentSettingsInput | null;
countryCode?: string | null;
}): Promise<Channel> {
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
userId,
@@ -267,6 +288,9 @@ export class ChannelOperationsService {
}
}
}
if (data.rtc_p2p === true && !channel.rtcP2p && channel.type === ChannelTypes.GUILD_VOICE) {
await this.assertVoiceP2pAssigned(userId, countryCode ?? null);
}
const previousPermissionOverwrites = channel.permissionOverwrites;
let permissionOverwrites = channel.permissionOverwrites;
if (data.permission_overwrites !== undefined) {
@@ -378,6 +402,7 @@ export class ChannelOperationsService {
data.rtc_region !== undefined && channel.type === ChannelTypes.GUILD_VOICE
? data.rtc_region
: channel.rtcRegion,
rtc_p2p: data.rtc_p2p !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.rtc_p2p : channel.rtcP2p,
permission_overwrites: new Map(
Array.from(permissionOverwrites.entries()).map(([targetId, overwrite]) => [
targetId,
@@ -12,7 +12,9 @@ import type {ISnowflakeService} from '@app/api/infrastructure/ISnowflakeService'
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import type {Channel} from '@app/api/models/Channel';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {GroupDmMatureContentIneligibleError} from '@fluxer/errors/src/domains/channel/GroupDmMatureContentIneligibleError';
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
@@ -37,6 +39,7 @@ export class GroupDmUpdateService {
icon,
ownerId,
nicks,
nsfw,
requestCache,
}: {
userId: UserID;
@@ -45,6 +48,7 @@ export class GroupDmUpdateService {
icon?: string | null;
ownerId?: UserID;
nicks?: Record<string, string | null> | null;
nsfw?: boolean;
requestCache: RequestCache;
}): Promise<Channel> {
const channel = await this.channelRepository.channelData.findUnique(channelId);
@@ -113,6 +117,20 @@ export class GroupDmUpdateService {
updates.nicks = updatedNicknames.size > 0 ? (updatedNicknames as Map<string, string>) : null;
}
}
if (nsfw !== undefined && nsfw !== channel.isNsfw) {
if (channel.ownerId !== userId) {
throw new MissingPermissionsError();
}
if (nsfw) {
const recipients = await Promise.all(
Array.from(channel.recipientIds).map((id) => this.userRepository.findUnique(id)),
);
if (recipients.some((recipient) => !recipient || !canUserAccessNsfwContent(recipient))) {
throw new GroupDmMatureContentIneligibleError();
}
}
updates.nsfw = nsfw;
}
let iconHash: string | null = null;
if (icon !== undefined) {
iconHash = await this.avatarService.uploadAvatar({
@@ -24,6 +24,7 @@ import type {User} from '@app/api/models/User';
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
import {assertAccountNotLimited} from '@app/api/user/AccountLimit';
import type {IUserRepository} from '@app/api/user/IUserRepository';
import {canUserAccessNsfwContent} from '@app/api/utils/AgeUtils';
import {UserPermissionUtils} from '@app/api/utils/UserPermissionUtils';
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
@@ -36,6 +37,7 @@ import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownCha
import {CannotRemoveOtherRecipientsError} from '@fluxer/errors/src/domains/core/CannotRemoveOtherRecipientsError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domains/moderation/NsfwContentRequiresAgeVerificationError';
import {NotFriendsWithUserError} from '@fluxer/errors/src/domains/user/NotFriendsWithUserError';
export class GroupDmOperationsService {
@@ -84,6 +86,9 @@ export class GroupDmOperationsService {
userId,
targetId: recipientId,
});
if (channel.isNsfw && !(await this.canJoinMatureGroup(recipientId))) {
throw new MissingAccessError();
}
const {channel: updatedChannel} = await this.addRecipientViaInviteWithResult({
channelId,
recipientId,
@@ -122,6 +127,11 @@ export class GroupDmOperationsService {
return updatedChannel;
}
private async canJoinMatureGroup(userId: UserID): Promise<boolean> {
const user = await this.userRepository.findUnique(userId);
return user != null && canUserAccessNsfwContent(user);
}
private async assertInviterNotLimited(userId: UserID): Promise<void> {
const user = await this.userRepository.findUnique(userId);
if (user) assertAccountNotLimited(user);
@@ -169,6 +179,9 @@ export class GroupDmOperationsService {
if (channel.recipientIds.has(recipientId)) {
return {channel, recipientAdded: false};
}
if (channel.isNsfw && !(await this.canJoinMatureGroup(recipientId))) {
throw new NsfwContentRequiresAgeVerificationError();
}
const inviterUser = inviterId ? await this.userRepository.findUnique(inviterId) : null;
const fallbackLimit = MAX_GROUP_DM_RECIPIENTS;
const recipientLimit = this.resolveLimitForUser(inviterUser ?? null, 'max_group_dm_recipients', fallbackLimit);
@@ -9,7 +9,7 @@ import * as EmojiUtils from '@app/api/utils/EmojiUtils';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {GuildExplicitContentFilterTypes, GuildFeatures, GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants';
import {RelationshipTypes, SensitiveMediaFilterLevel} from '@fluxer/constants/src/UserConstants';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -40,6 +40,30 @@ export class MessageContentService {
});
}
async resolveDmNsfwContext(channel: Channel, senderId: UserID): Promise<DmNsfwContext | undefined> {
if (channel.type !== ChannelTypes.DM) {
return undefined;
}
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== senderId);
if (recipientIds.length !== 1) {
return undefined;
}
const recipientId = recipientIds[0];
const [senderSettings, recipientSettings, friendship] = await Promise.all([
this.userRepository.findSettings(senderId),
this.userRepository.findSettings(recipientId),
this.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.FRIEND),
]);
const areFriends = friendship != null;
const senderFilterLevel = areFriends
? (senderSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
: (senderSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
const recipientFilterLevel = areFriends
? (recipientSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
: (recipientSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
return {senderFilterLevel, recipientFilterLevel};
}
isNSFWContentAllowed(params: {
channel?: Channel;
guild?: GuildResponse | null;
@@ -61,6 +85,9 @@ export class MessageContentService {
if (channel?.type === ChannelTypes.DM_PERSONAL_NOTES) {
return true;
}
if (channel?.type === ChannelTypes.GROUP_DM && channel.isNsfw) {
return true;
}
if (!guild) {
if (dmNsfwContext) {
const eitherBlocks =
@@ -175,6 +175,7 @@ export class MessageEditService {
});
}
const isBugHunterBot = !!user?.isBot && (user.flags & UserFlags.BUG_HUNTER) !== 0n;
const dmNsfwContext = guild ? undefined : await this.deps.persistenceService.resolveDmNsfwContext(channel, userId);
const updateResult = await this.deps.messageWriteLock.withFreshMessage(channelId, messageId, async (fresh) => {
if (!fresh) throw new UnknownMessageError();
return this.deps.crosspostPropagation.withPublishedEditBudget({fresh, actor: 'author'}, () =>
@@ -190,6 +191,7 @@ export class MessageEditService {
isBot: user?.isBot,
isBugHunterBot,
locale: user?.locale,
dmNsfwContext,
}),
);
});
@@ -158,6 +158,10 @@ export class MessagePersistenceService {
return resolveNsfwScopeChannel(channel, (channelId) => this.channelRepository.channelData.findUnique(channelId));
}
resolveDmNsfwContext(channel: Channel, senderId: UserID): Promise<DmNsfwContext | undefined> {
return this.contentService.resolveDmNsfwContext(channel, senderId);
}
getEmbedAttachmentResolver(): MessageEmbedAttachmentResolver {
return this.embedAttachmentResolver;
}
@@ -402,6 +406,7 @@ export class MessagePersistenceService {
isBot?: boolean;
isBugHunterBot?: boolean;
locale?: string | null;
dmNsfwContext?: DmNsfwContext;
}): Promise<UpdateMessageResult> {
const {message, messageId, data, channel, guild, member} = params;
if (message.messageSnapshots && message.messageSnapshots.length > 0) {
@@ -412,6 +417,7 @@ export class MessagePersistenceService {
guild,
member,
isBot: params.isBot,
dmNsfwContext: params.dmNsfwContext,
});
const updatedRowData = {...message.toRow()};
let hasChanges = false;
@@ -44,6 +44,7 @@ function dmChannelRow(lastMessageId: MessageID | null): ChannelRow {
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
rtc_p2p: null,
last_message_id: lastMessageId,
last_pin_timestamp: null,
permission_overwrites: null,
@@ -19,7 +19,6 @@ import type {AuthenticatedChannel} from '@app/api/channel/services/Authenticated
import type {CrosspostPropagation} from '@app/api/channel/services/message/CrosspostPropagation';
import {emitMessageCreated} from '@app/api/channel/services/message/MessageActivity';
import type {MessageChannelAuthService} from '@app/api/channel/services/message/MessageChannelAuthService';
import type {DmNsfwContext} from '@app/api/channel/services/message/MessageContentService';
import type {MessageDispatchService} from '@app/api/channel/services/message/MessageDispatchService';
import type {MessageEmbedAttachmentResolver} from '@app/api/channel/services/message/MessageEmbedAttachmentResolver';
import {
@@ -69,12 +68,7 @@ import {
} from '@fluxer/constants/src/ChannelConstants';
import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {threadWriteBlock} from '@fluxer/constants/src/ThreadPermissionUtils';
import {
DELETED_USER_ID,
RelationshipTypes,
SensitiveMediaFilterLevel,
UserFlags,
} from '@fluxer/constants/src/UserConstants';
import {DELETED_USER_ID, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
@@ -193,33 +187,6 @@ export class MessageSendService {
return includeDefault ? {includeDefault} : null;
}
private async buildDmNsfwContext(channel: Channel, senderId: UserID): Promise<DmNsfwContext | undefined> {
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
return undefined;
}
if (channel.type !== ChannelTypes.DM) {
return undefined;
}
const recipientIds = Array.from(channel.recipientIds).filter((id) => id !== senderId);
if (recipientIds.length !== 1) {
return undefined;
}
const recipientId = recipientIds[0];
const [senderSettings, recipientSettings, friendship] = await Promise.all([
this.deps.userRepository.findSettings(senderId),
this.deps.userRepository.findSettings(recipientId),
this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.FRIEND),
]);
const areFriends = friendship != null;
const senderFilterLevel = areFriends
? (senderSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
: (senderSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
const recipientFilterLevel = areFriends
? (recipientSettings?.sensitiveContentFriendDmFilter ?? SensitiveMediaFilterLevel.SHOW)
: (recipientSettings?.sensitiveContentNonFriendDmFilter ?? SensitiveMediaFilterLevel.BLOCK);
return {senderFilterLevel, recipientFilterLevel};
}
private attachmentsToProcess(attachments?: Array<AttachmentRequestData>): Array<AttachmentToProcess> | undefined {
if (!attachments) return undefined;
const processed = attachments.filter(
@@ -1033,7 +1000,7 @@ export class MessageSendService {
channelId,
data,
});
const dmNsfwContext = guild ? undefined : await this.buildDmNsfwContext(channel, user.id);
const dmNsfwContext = guild ? undefined : await this.deps.persistenceService.resolveDmNsfwContext(channel, user.id);
const messageId = forumStarter ? channelIdToMessageId(channel.id) : await this.generateMessageId(channel);
let mentionData: SendMentionData | undefined;
const shouldExtractMentions =
@@ -453,6 +453,7 @@ export class ThreadCreationService {
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
rtc_p2p: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {MessageContentService} from '@app/api/channel/services/message/MessageContentService';
import {createChannelInvite, createFriendship, createGroupDmChannel} from '@app/api/channel/tests/ChannelTestUtils';
import {ensureSessionStarted, sendMessage} from '@app/api/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
describe('group dm mature content', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(async () => {
await harness.shutdown();
});
async function setup() {
const owner = await createTestAccount(harness);
const member = await createTestAccount(harness);
const other = await createTestAccount(harness);
await createFriendship(harness, owner, member);
await createFriendship(harness, owner, other);
await ensureSessionStarted(harness, owner.token);
const group = await createGroupDmChannel(harness, owner.token, [member.userId, other.userId]);
return {owner, member, other, groupId: group.id};
}
async function joinAsMinor(groupId: string, owner: TestAccount, status: number, code?: string) {
const minor = await createTestAccount(harness, {dateOfBirth: '2012-01-01'});
const invite = await createChannelInvite(harness, owner.token, groupId);
await createBuilder(harness, minor.token).post(`/invites/${invite.code}`).body({}).expect(status, code).execute();
}
function setNsfw(account: TestAccount, channelId: string, nsfw: boolean, status = 200, code?: string) {
return createBuilder<ChannelResponse>(harness, account.token)
.patch(`/channels/${channelId}`)
.body({type: ChannelTypes.GROUP_DM, nsfw})
.expect(status, code)
.execute();
}
it('lets the owner mark the group and allows mature media once marked', async () => {
const s = await setup();
const scope = vi.spyOn(MessageContentService.prototype, 'isNSFWContentAllowed');
await sendMessage(harness, s.owner.token, s.groupId, 'before');
expect(scope.mock.results.at(-1)?.value).toBe(false);
const updated = await setNsfw(s.owner, s.groupId, true);
expect(updated.nsfw).toBe(true);
await sendMessage(harness, s.owner.token, s.groupId, 'after');
expect(scope.mock.results.at(-1)?.value).toBe(true);
const cleared = await setNsfw(s.owner, s.groupId, false);
expect(cleared.nsfw).toBe(false);
});
it('rejects the toggle from a member who is not the owner', async () => {
const s = await setup();
await setNsfw(s.member, s.groupId, true, 403);
});
it('refuses to mark a group that includes a minor', async () => {
const s = await setup();
await joinAsMinor(s.groupId, s.owner, 200);
await setNsfw(s.owner, s.groupId, true, 400, APIErrorCodes.GROUP_DM_MATURE_CONTENT_INELIGIBLE);
});
it('refuses to let a minor join a marked group', async () => {
const s = await setup();
await setNsfw(s.owner, s.groupId, true);
await joinAsMinor(s.groupId, s.owner, 403, APIErrorCodes.NSFW_CONTENT_AGE_RESTRICTED);
});
});
@@ -38,6 +38,7 @@ function channel(id: bigint, type: number, overrides: Partial<ChannelRow> = {}):
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
rtc_p2p: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
+1
View File
@@ -192,6 +192,7 @@ export interface APIConfig {
latitude: number;
longitude: number;
};
p2pStunUrls?: Array<string>;
};
stripe: {
enabled: boolean;
@@ -47,7 +47,6 @@ export const CONTENT_I18N_MESSAGES = {
'report_flow.label.csam': 'Real or AI-generated images or videos of child sexual abuse',
'report_flow.label.discovery_listing': 'Discovery listing',
'report_flow.label.dislike': "It's just not for me",
'report_flow.label.dsa': "Content that's illegal in the EU (Digital Services Act)",
'report_flow.label.eating_disorder_promotion': 'Encouraging disordered eating',
'report_flow.label.eating_disorder_promotion_profile': 'Their profile promotes eating disorders',
'report_flow.label.email': 'Email address',
@@ -45,7 +45,6 @@ const CONTENT_I18N_AR_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "صور أو مقاطع فيديو حقيقية أو مولدة بالذكاء الاصطناعي تُظهر اعتداءً جنسيًا على الأطفال",
"report_flow.label.discovery_listing": "قائمة الاكتشاف",
"report_flow.label.dislike": "هذا ببساطة لا يناسبني",
"report_flow.label.dsa": "محتوى غير قانوني في الاتحاد الأوروبي (قانون الخدمات الرقمية)",
"report_flow.label.eating_disorder_promotion": "تشجيع سلوكيات الأكل المضطربة",
"report_flow.label.eating_disorder_promotion_profile": "ملفهم الشخصي يروج لاضطرابات الأكل",
"report_flow.label.email": "عنوان البريد الإلكتروني",
@@ -34,10 +34,6 @@
"msgctxt": "report_flow.label.dislike",
"msgid": "It's just not for me"
},
{
"msgctxt": "report_flow.label.dsa",
"msgid": "Content that's illegal in the EU (Digital Services Act)"
},
{
"msgctxt": "report_flow.label.eating_disorder_promotion",
"msgid": "Encouraging disordered eating"
@@ -45,7 +45,6 @@ const CONTENT_I18N_BG_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Реални или генерирани от изкуствен интелект изображения или видеоклипове на сексуално насилие над деца",
"report_flow.label.discovery_listing": "Списък за откриване",
"report_flow.label.dislike": "Просто не е за мен",
"report_flow.label.dsa": "Съдържание, което е незаконно в ЕС (Акт за цифровите услуги)",
"report_flow.label.eating_disorder_promotion": "Насърчаване на хранителни разстройства",
"report_flow.label.eating_disorder_promotion_profile": "Профилът им насърчава хранителни разстройства",
"report_flow.label.email": "Имейл адрес",
@@ -45,7 +45,6 @@ const CONTENT_I18N_CS_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Skutečné nebo pomocí AI vytvořené obrázky či videa sexuálního zneužívání dětí",
"report_flow.label.discovery_listing": "Záznam v seznamu objevování",
"report_flow.label.dislike": "Prostě to není pro mě",
"report_flow.label.dsa": "Obsah, který je v EU nelegální (akt o digitálních službách)",
"report_flow.label.eating_disorder_promotion": "Nabádání k poruchám příjmu potravy",
"report_flow.label.eating_disorder_promotion_profile": "Jejich profil propaguje poruchy příjmu potravy",
"report_flow.label.email": "E-mailová adresa",
@@ -45,7 +45,6 @@ const CONTENT_I18N_DA_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Ægte eller AI-genererede billeder eller videoer af seksuelle overgreb mod børn",
"report_flow.label.discovery_listing": "Opslag i Opdag",
"report_flow.label.dislike": "Det er bare ikke noget for mig",
"report_flow.label.dsa": "Indhold, der er ulovligt i EU (retsakten om digitale tjenester, DSA)",
"report_flow.label.eating_disorder_promotion": "Opfordrer til spiseforstyrrelser",
"report_flow.label.eating_disorder_promotion_profile": "Deres profil fremmer spiseforstyrrelser",
"report_flow.label.email": "E-mailadresse",
@@ -45,7 +45,6 @@ const CONTENT_I18N_DE_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Reale oder KI-generierte Bilder oder Videos von sexuellem Kindesmissbrauch",
"report_flow.label.discovery_listing": "Eintrag in „Entdecken“",
"report_flow.label.dislike": "Das ist einfach nichts für mich",
"report_flow.label.dsa": "Inhalte, die in der EU illegal sind (Gesetz über digitale Dienste)",
"report_flow.label.eating_disorder_promotion": "Zu gestörtem Essverhalten ermutigen",
"report_flow.label.eating_disorder_promotion_profile": "Das Profil der Person fördert Essstörungen",
"report_flow.label.email": "E-Mail-Adresse",
@@ -45,7 +45,6 @@ const CONTENT_I18N_EL_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Πραγματικές ή δημιουργημένες με AI εικόνες ή βίντεο σεξουαλικής κακοποίησης παιδιών",
"report_flow.label.discovery_listing": "Καταχώριση ανακάλυψης",
"report_flow.label.dislike": "Απλά δεν μου ταιριάζει",
"report_flow.label.dsa": "Περιεχόμενο που είναι παράνομο στην ΕΕ (πράξη για τις ψηφιακές υπηρεσίες, DSA)",
"report_flow.label.eating_disorder_promotion": "Ενθάρρυνση διατροφικών διαταραχών",
"report_flow.label.eating_disorder_promotion_profile": "Το προφίλ τους προωθεί διατροφικές διαταραχές",
"report_flow.label.email": "Διεύθυνση email",
@@ -45,7 +45,6 @@ const CONTENT_I18N_EN_GB_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Real or AI-generated images or videos of child sexual abuse",
"report_flow.label.discovery_listing": "Discovery listing",
"report_flow.label.dislike": "It's just not for me",
"report_flow.label.dsa": "Content that's illegal in the EU (Digital Services Act)",
"report_flow.label.eating_disorder_promotion": "Encouraging disordered eating",
"report_flow.label.eating_disorder_promotion_profile": "Their profile promotes eating disorders",
"report_flow.label.email": "Email address",
@@ -45,7 +45,6 @@ const CONTENT_I18N_ES_419_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Imágenes o videos reales o generados por IA de abuso sexual infantil",
"report_flow.label.discovery_listing": "Listado en Descubrir",
"report_flow.label.dislike": "Simplemente no es para mí",
"report_flow.label.dsa": "Contenido ilegal en la UE (Reglamento de Servicios Digitales)",
"report_flow.label.eating_disorder_promotion": "Fomentar trastornos alimenticios",
"report_flow.label.eating_disorder_promotion_profile": "Su perfil promueve trastornos alimenticios",
"report_flow.label.email": "Dirección de correo electrónico",
@@ -45,7 +45,6 @@ const CONTENT_I18N_ES_ES_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Imágenes o vídeos reales o generados por IA de abuso sexual infantil",
"report_flow.label.discovery_listing": "Ficha en Descubrir",
"report_flow.label.dislike": "Simplemente no es para mí",
"report_flow.label.dsa": "Contenido ilegal en la UE (Reglamento de Servicios Digitales)",
"report_flow.label.eating_disorder_promotion": "Fomentar trastornos de la conducta alimentaria",
"report_flow.label.eating_disorder_promotion_profile": "Su perfil promueve trastornos de la conducta alimentaria",
"report_flow.label.email": "Dirección de correo electrónico",
@@ -45,7 +45,6 @@ const CONTENT_I18N_FI_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Aitoja tai tekoälyn luomia kuvia tai videoita lapsiin kohdistuvasta seksuaalisesta hyväksikäytöstä",
"report_flow.label.discovery_listing": "Löydä-listaus",
"report_flow.label.dislike": "Se ei vain ole minun juttuni",
"report_flow.label.dsa": "Laiton sisältö EU:ssa (digipalvelusäädös, DSA)",
"report_flow.label.eating_disorder_promotion": "Syömishäiriöihin kannustaminen",
"report_flow.label.eating_disorder_promotion_profile": "Hänen profiilinsa edistää syömishäiriöitä",
"report_flow.label.email": "Sähköpostiosoite",
@@ -45,7 +45,6 @@ const CONTENT_I18N_FR_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Images ou vidéos réelles ou générées par IA représentant des abus sexuels sur des enfants",
"report_flow.label.discovery_listing": "Fiche dans Découverte",
"report_flow.label.dislike": "Ce n'est pas pour moi",
"report_flow.label.dsa": "Contenu illégal dans l'UE (règlement sur les services numériques, DSA)",
"report_flow.label.eating_disorder_promotion": "Encourager les troubles du comportement alimentaire",
"report_flow.label.eating_disorder_promotion_profile": "Son profil fait la promotion des troubles du comportement alimentaire",
"report_flow.label.email": "Adresse e-mail",
@@ -45,7 +45,6 @@ const CONTENT_I18N_HE_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "תמונות או סרטונים של פגיעה מינית בילדים, אמיתיים או שנוצרו באמצעות AI",
"report_flow.label.discovery_listing": "רישום בגילוי",
"report_flow.label.dislike": "זה פשוט לא בשבילי",
"report_flow.label.dsa": "תוכן בלתי חוקי באיחוד האירופי (חוק השירותים הדיגיטליים, DSA)",
"report_flow.label.eating_disorder_promotion": "עידוד הפרעות אכילה",
"report_flow.label.eating_disorder_promotion_profile": "הפרופיל שלהם מקדם הפרעות אכילה",
"report_flow.label.email": "כתובת אימייל",
@@ -45,7 +45,6 @@ const CONTENT_I18N_HI_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "बाल यौन शोषण की असली या AI से बनाई गई तस्वीरें या वीडियो",
"report_flow.label.discovery_listing": "डिस्कवरी लिस्टिंग",
"report_flow.label.dislike": "यह बस मेरे लिए नहीं है",
"report_flow.label.dsa": "यूरोपीय संघ में गैर-कानूनी सामग्री (डिजिटल सेवा अधिनियम)",
"report_flow.label.eating_disorder_promotion": "खाने संबंधी विकार वाली आदतों को बढ़ावा देना",
"report_flow.label.eating_disorder_promotion_profile": "उनकी प्रोफ़ाइल खाने संबंधी विकारों को बढ़ावा देती है",
"report_flow.label.email": "ईमेल पता",
@@ -45,7 +45,6 @@ const CONTENT_I18N_HR_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Stvarne ili AI-generirane slike ili videozapisi seksualnog zlostavljanja djece",
"report_flow.label.discovery_listing": "Unos u Otkrivanju",
"report_flow.label.dislike": "Jednostavno mi ne odgovara",
"report_flow.label.dsa": "Sadržaj koji je nezakonit u EU-u (Akt o digitalnim uslugama)",
"report_flow.label.eating_disorder_promotion": "Poticanje poremećaja hranjenja",
"report_flow.label.eating_disorder_promotion_profile": "Njihov profil promiče poremećaje hranjenja",
"report_flow.label.email": "Adresa e-pošte",
@@ -45,7 +45,6 @@ const CONTENT_I18N_HU_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Gyermekek szexuális bántalmazását ábrázoló valódi vagy mesterséges intelligencia által generált képek vagy videók",
"report_flow.label.discovery_listing": "Listázás a Felfedezésben",
"report_flow.label.dislike": "Egyszerűen nem nekem való",
"report_flow.label.dsa": "Az EU-ban illegális tartalom (digitális szolgáltatásokról szóló rendelet, DSA)",
"report_flow.label.eating_disorder_promotion": "Kóros étkezési szokásokra buzdítás",
"report_flow.label.eating_disorder_promotion_profile": "A profilja evészavarokat népszerűsít",
"report_flow.label.email": "E-mail-cím",
@@ -45,7 +45,6 @@ const CONTENT_I18N_ID_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Gambar atau video kekerasan seksual terhadap anak, baik yang asli maupun buatan AI",
"report_flow.label.discovery_listing": "Listing di Discovery",
"report_flow.label.dislike": "Ini tidak cocok untuk saya",
"report_flow.label.dsa": "Konten yang ilegal di UE (Undang-Undang Layanan Digital/DSA)",
"report_flow.label.eating_disorder_promotion": "Mendorong perilaku makan yang terganggu",
"report_flow.label.eating_disorder_promotion_profile": "Profil mereka mempromosikan gangguan makan",
"report_flow.label.email": "Alamat email",
@@ -45,7 +45,6 @@ const CONTENT_I18N_IT_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Immagini o video reali o generati da IA di abusi sessuali su minori",
"report_flow.label.discovery_listing": "Elenco in Scopri",
"report_flow.label.dislike": "Non fa per me",
"report_flow.label.dsa": "Contenuti illegali nell'UE (Regolamento sui servizi digitali, DSA)",
"report_flow.label.eating_disorder_promotion": "Incoraggiare disturbi alimentari",
"report_flow.label.eating_disorder_promotion_profile": "Il suo profilo promuove disturbi alimentari",
"report_flow.label.email": "Indirizzo e-mail",
@@ -45,7 +45,6 @@ const CONTENT_I18N_JA_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "児童性的虐待の画像・動画(実在のもの、またはAI生成のもの)",
"report_flow.label.discovery_listing": "「見つける」の掲載内容",
"report_flow.label.dislike": "自分には合わない",
"report_flow.label.dsa": "EUで違法なコンテンツ(デジタルサービス法、DSA)",
"report_flow.label.eating_disorder_promotion": "摂食障害的な食行動を促している",
"report_flow.label.eating_disorder_promotion_profile": "プロフィールが摂食障害を助長している",
"report_flow.label.email": "メールアドレス",
@@ -45,7 +45,6 @@ const CONTENT_I18N_KO_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "아동 성착취물(실제 또는 AI로 생성된 이미지 또는 동영상)",
"report_flow.label.discovery_listing": "디스커버리 목록",
"report_flow.label.dislike": "그냥 내 취향이 아닙니다",
"report_flow.label.dsa": "EU에서 불법인 콘텐츠(디지털 서비스법, DSA)",
"report_flow.label.eating_disorder_promotion": "섭식 장애 행동 조장",
"report_flow.label.eating_disorder_promotion_profile": "프로필에서 섭식 장애를 조장합니다",
"report_flow.label.email": "이메일 주소",
@@ -45,7 +45,6 @@ const CONTENT_I18N_LT_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Tikri ar dirbtinio intelekto sukurti vaikų seksualinės prievartos vaizdai ar vaizdo įrašai",
"report_flow.label.discovery_listing": "Atradimo sąrašas",
"report_flow.label.dislike": "Tai tiesiog ne man",
"report_flow.label.dsa": "Turinys, kuris neteisėtas ES (Skaitmeninių paslaugų aktas)",
"report_flow.label.eating_disorder_promotion": "Sutrikusių mitybos įpročių skatinimas",
"report_flow.label.eating_disorder_promotion_profile": "Jų profilis propaguoja valgymo sutrikimus",
"report_flow.label.email": "El. pašto adresas",
@@ -45,7 +45,6 @@ const CONTENT_I18N_NL_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Echte of door AI gegenereerde beelden of video's van seksueel misbruik van kinderen",
"report_flow.label.discovery_listing": "Vermelding bij Ontdekken",
"report_flow.label.dislike": "Het is gewoon niets voor mij",
"report_flow.label.dsa": "Inhoud die illegaal is in de EU (digitaledienstenverordening)",
"report_flow.label.eating_disorder_promotion": "Gestoord eetgedrag aanmoedigen",
"report_flow.label.eating_disorder_promotion_profile": "Hun profiel promoot eetstoornissen",
"report_flow.label.email": "E-mailadres",
@@ -45,7 +45,6 @@ const CONTENT_I18N_NO_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Ekte eller AI-genererte bilder eller videoer av seksuelle overgrep mot barn",
"report_flow.label.discovery_listing": "Oppføring i Oppdag",
"report_flow.label.dislike": "Det er bare ikke noe for meg",
"report_flow.label.dsa": "Innhold som er ulovlig i EU (forordningen om digitale tjenester, DSA)",
"report_flow.label.eating_disorder_promotion": "Oppfordrer til spiseforstyrrelser",
"report_flow.label.eating_disorder_promotion_profile": "Profilen deres fremmer spiseforstyrrelser",
"report_flow.label.email": "E-postadresse",
@@ -45,7 +45,6 @@ const CONTENT_I18N_PL_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Prawdziwe lub wygenerowane przez AI obrazy lub filmy przedstawiające wykorzystywanie seksualne dzieci",
"report_flow.label.discovery_listing": "Wpis w sekcji Odkrywanie",
"report_flow.label.dislike": "To po prostu nie jest dla mnie",
"report_flow.label.dsa": "Treści, które są nielegalne w UE (akt o usługach cyfrowych)",
"report_flow.label.eating_disorder_promotion": "Zachęcanie do zaburzeń odżywiania",
"report_flow.label.eating_disorder_promotion_profile": "Ich profil promuje zaburzenia odżywiania",
"report_flow.label.email": "Adres e-mail",
@@ -45,7 +45,6 @@ const CONTENT_I18N_PT_BR_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Imagens ou vídeos reais ou gerados por IA de abuso sexual infantil",
"report_flow.label.discovery_listing": "Listagem em Descobrir",
"report_flow.label.dislike": "Não é para mim",
"report_flow.label.dsa": "Conteúdo ilegal na UE (Regulamento dos Serviços Digitais)",
"report_flow.label.eating_disorder_promotion": "Incentivando transtornos alimentares",
"report_flow.label.eating_disorder_promotion_profile": "O perfil dele(a) promove transtornos alimentares",
"report_flow.label.email": "Endereço de e-mail",
@@ -45,7 +45,6 @@ const CONTENT_I18N_RO_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Imagini sau videoclipuri reale sau generate de AI cu abuz sexual asupra copiilor",
"report_flow.label.discovery_listing": "Listarea în Discovery",
"report_flow.label.dislike": "Pur și simplu nu este pentru mine",
"report_flow.label.dsa": "Conținut ilegal în UE (Regulamentul privind serviciile digitale)",
"report_flow.label.eating_disorder_promotion": "Încurajarea tulburărilor de alimentație",
"report_flow.label.eating_disorder_promotion_profile": "Profilul său promovează tulburările de alimentație",
"report_flow.label.email": "Adresă de e-mail",
@@ -45,7 +45,6 @@ const CONTENT_I18N_RU_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Реальные или созданные ИИ изображения или видео сексуального насилия над детьми",
"report_flow.label.discovery_listing": "Запись в каталоге",
"report_flow.label.dislike": "Мне это не подходит",
"report_flow.label.dsa": "Незаконный в ЕС контент (Закон о цифровых услугах, DSA)",
"report_flow.label.eating_disorder_promotion": "Поощрение расстройств пищевого поведения",
"report_flow.label.eating_disorder_promotion_profile": "Их профиль пропагандирует расстройства пищевого поведения",
"report_flow.label.email": "Адрес электронной почты",
@@ -45,7 +45,6 @@ const CONTENT_I18N_SV_SE_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Verkliga eller AI-genererade bilder eller videor av sexuella övergrepp mot barn",
"report_flow.label.discovery_listing": "Listning i Upptäck",
"report_flow.label.dislike": "Det är bara inget för mig",
"report_flow.label.dsa": "Innehåll som är olagligt i EU (förordningen om digitala tjänster)",
"report_flow.label.eating_disorder_promotion": "Uppmuntrar till ätstörningar",
"report_flow.label.eating_disorder_promotion_profile": "Deras profil främjar ätstörningar",
"report_flow.label.email": "E-postadress",
@@ -45,7 +45,6 @@ const CONTENT_I18N_TH_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "ภาพหรือวิดีโอการล่วงละเมิดทางเพศเด็ก ไม่ว่าจะเป็นของจริงหรือสร้างโดย AI",
"report_flow.label.discovery_listing": "รายการใน Discovery",
"report_flow.label.dislike": "ไม่เหมาะกับฉัน",
"report_flow.label.dsa": "เนื้อหาที่ผิดกฎหมายในสหภาพยุโรป (กฎหมายบริการดิจิทัล หรือ DSA)",
"report_flow.label.eating_disorder_promotion": "ส่งเสริมพฤติกรรมการกินที่ผิดปกติ",
"report_flow.label.eating_disorder_promotion_profile": "โปรไฟล์ของพวกเขาส่งเสริมความผิดปกติในการกิน",
"report_flow.label.email": "ที่อยู่อีเมล",
@@ -45,7 +45,6 @@ const CONTENT_I18N_TR_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Çocuk cinsel istismarına ait gerçek veya yapay zeka ile üretilmiş görseller ya da videolar",
"report_flow.label.discovery_listing": "Keşfet listesi",
"report_flow.label.dislike": "Bana göre değil",
"report_flow.label.dsa": "AB'de yasa dışı içerik (Dijital Hizmetler Yasası)",
"report_flow.label.eating_disorder_promotion": "Düzensiz yeme davranışlarını teşvik etme",
"report_flow.label.eating_disorder_promotion_profile": "Profili yeme bozukluklarını teşvik ediyor",
"report_flow.label.email": "E-posta adresi",
@@ -45,7 +45,6 @@ const CONTENT_I18N_UK_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Реальні або згенеровані ШІ зображення чи відео сексуального насильства над дітьми",
"report_flow.label.discovery_listing": "Запис у Discovery",
"report_flow.label.dislike": "Мені це просто не підходить",
"report_flow.label.dsa": "Вміст, незаконний в ЄС (Акт про цифрові послуги)",
"report_flow.label.eating_disorder_promotion": "Заохочення розладів харчової поведінки",
"report_flow.label.eating_disorder_promotion_profile": "Їхній профіль пропагує розлади харчової поведінки",
"report_flow.label.email": "Адреса електронної пошти",
@@ -45,7 +45,6 @@ const CONTENT_I18N_VI_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "Ảnh hoặc video lạm dụng tình dục trẻ em, dù là thật hay do AI tạo ra",
"report_flow.label.discovery_listing": "Danh sách khám phá",
"report_flow.label.dislike": "Không hợp với tôi",
"report_flow.label.dsa": "Nội dung bất hợp pháp tại EU (Đạo luật Dịch vụ Kỹ thuật số)",
"report_flow.label.eating_disorder_promotion": "Khuyến khích chứng rối loạn ăn uống",
"report_flow.label.eating_disorder_promotion_profile": "Hồ sơ của họ quảng bá chứng rối loạn ăn uống",
"report_flow.label.email": "Địa chỉ email",
@@ -45,7 +45,6 @@ const CONTENT_I18N_ZH_CN_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "真实或 AI 生成的儿童性虐待图像或视频",
"report_flow.label.discovery_listing": "社区发现列表",
"report_flow.label.dislike": "我就是不喜欢",
"report_flow.label.dsa": "在欧盟属违法的内容(《数字服务法》)",
"report_flow.label.eating_disorder_promotion": "鼓励进食障碍行为",
"report_flow.label.eating_disorder_promotion_profile": "其个人资料宣传进食障碍",
"report_flow.label.email": "电子邮件地址",
@@ -45,7 +45,6 @@ const CONTENT_I18N_ZH_TW_MESSAGES = defineContentI18nLocaleMessages({
"report_flow.label.csam": "兒童性虐待的真實或 AI 生成圖片或影片",
"report_flow.label.discovery_listing": "探索列表",
"report_flow.label.dislike": "對我來說不太適合",
"report_flow.label.dsa": "在歐盟屬違法的內容(《數位服務法》)",
"report_flow.label.eating_disorder_promotion": "鼓勵飲食失調",
"report_flow.label.eating_disorder_promotion_profile": "他們的個人檔案宣傳飲食失調",
"report_flow.label.email": "電子郵件地址",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "صور أو مقاطع فيديو حقيقية أو مولدة بالذكاء الاصطناعي تُظهر اعتداءً جنسيًا على الأطفال",
"report_flow.label.discovery_listing": "قائمة الاكتشاف",
"report_flow.label.dislike": "هذا ببساطة لا يناسبني",
"report_flow.label.dsa": "محتوى غير قانوني في الاتحاد الأوروبي (قانون الخدمات الرقمية)",
"report_flow.label.eating_disorder_promotion": "تشجيع سلوكيات الأكل المضطربة",
"report_flow.label.eating_disorder_promotion_profile": "ملفهم الشخصي يروج لاضطرابات الأكل",
"report_flow.label.email": "عنوان البريد الإلكتروني",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Реални или генерирани от изкуствен интелект изображения или видеоклипове на сексуално насилие над деца",
"report_flow.label.discovery_listing": "Списък за откриване",
"report_flow.label.dislike": "Просто не е за мен",
"report_flow.label.dsa": "Съдържание, което е незаконно в ЕС (Акт за цифровите услуги)",
"report_flow.label.eating_disorder_promotion": "Насърчаване на хранителни разстройства",
"report_flow.label.eating_disorder_promotion_profile": "Профилът им насърчава хранителни разстройства",
"report_flow.label.email": "Имейл адрес",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Skutečné nebo pomocí AI vytvořené obrázky či videa sexuálního zneužívání dětí",
"report_flow.label.discovery_listing": "Záznam v seznamu objevování",
"report_flow.label.dislike": "Prostě to není pro mě",
"report_flow.label.dsa": "Obsah, který je v EU nelegální (akt o digitálních službách)",
"report_flow.label.eating_disorder_promotion": "Nabádání k poruchám příjmu potravy",
"report_flow.label.eating_disorder_promotion_profile": "Jejich profil propaguje poruchy příjmu potravy",
"report_flow.label.email": "E-mailová adresa",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Ægte eller AI-genererede billeder eller videoer af seksuelle overgreb mod børn",
"report_flow.label.discovery_listing": "Opslag i Opdag",
"report_flow.label.dislike": "Det er bare ikke noget for mig",
"report_flow.label.dsa": "Indhold, der er ulovligt i EU (retsakten om digitale tjenester, DSA)",
"report_flow.label.eating_disorder_promotion": "Opfordrer til spiseforstyrrelser",
"report_flow.label.eating_disorder_promotion_profile": "Deres profil fremmer spiseforstyrrelser",
"report_flow.label.email": "E-mailadresse",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Reale oder KI-generierte Bilder oder Videos von sexuellem Kindesmissbrauch",
"report_flow.label.discovery_listing": "Eintrag in „Entdecken“",
"report_flow.label.dislike": "Das ist einfach nichts für mich",
"report_flow.label.dsa": "Inhalte, die in der EU illegal sind (Gesetz über digitale Dienste)",
"report_flow.label.eating_disorder_promotion": "Zu gestörtem Essverhalten ermutigen",
"report_flow.label.eating_disorder_promotion_profile": "Das Profil der Person fördert Essstörungen",
"report_flow.label.email": "E-Mail-Adresse",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Πραγματικές ή δημιουργημένες με AI εικόνες ή βίντεο σεξουαλικής κακοποίησης παιδιών",
"report_flow.label.discovery_listing": "Καταχώριση ανακάλυψης",
"report_flow.label.dislike": "Απλά δεν μου ταιριάζει",
"report_flow.label.dsa": "Περιεχόμενο που είναι παράνομο στην ΕΕ (πράξη για τις ψηφιακές υπηρεσίες, DSA)",
"report_flow.label.eating_disorder_promotion": "Ενθάρρυνση διατροφικών διαταραχών",
"report_flow.label.eating_disorder_promotion_profile": "Το προφίλ τους προωθεί διατροφικές διαταραχές",
"report_flow.label.email": "Διεύθυνση email",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Real or AI-generated images or videos of child sexual abuse",
"report_flow.label.discovery_listing": "Discovery listing",
"report_flow.label.dislike": "It's just not for me",
"report_flow.label.dsa": "Content that's illegal in the EU (Digital Services Act)",
"report_flow.label.eating_disorder_promotion": "Encouraging disordered eating",
"report_flow.label.eating_disorder_promotion_profile": "Their profile promotes eating disorders",
"report_flow.label.email": "Email address",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Imágenes o videos reales o generados por IA de abuso sexual infantil",
"report_flow.label.discovery_listing": "Listado en Descubrir",
"report_flow.label.dislike": "Simplemente no es para mí",
"report_flow.label.dsa": "Contenido ilegal en la UE (Reglamento de Servicios Digitales)",
"report_flow.label.eating_disorder_promotion": "Fomentar trastornos alimenticios",
"report_flow.label.eating_disorder_promotion_profile": "Su perfil promueve trastornos alimenticios",
"report_flow.label.email": "Dirección de correo electrónico",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Imágenes o vídeos reales o generados por IA de abuso sexual infantil",
"report_flow.label.discovery_listing": "Ficha en Descubrir",
"report_flow.label.dislike": "Simplemente no es para mí",
"report_flow.label.dsa": "Contenido ilegal en la UE (Reglamento de Servicios Digitales)",
"report_flow.label.eating_disorder_promotion": "Fomentar trastornos de la conducta alimentaria",
"report_flow.label.eating_disorder_promotion_profile": "Su perfil promueve trastornos de la conducta alimentaria",
"report_flow.label.email": "Dirección de correo electrónico",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Aitoja tai tekoälyn luomia kuvia tai videoita lapsiin kohdistuvasta seksuaalisesta hyväksikäytöstä",
"report_flow.label.discovery_listing": "Löydä-listaus",
"report_flow.label.dislike": "Se ei vain ole minun juttuni",
"report_flow.label.dsa": "Laiton sisältö EU:ssa (digipalvelusäädös, DSA)",
"report_flow.label.eating_disorder_promotion": "Syömishäiriöihin kannustaminen",
"report_flow.label.eating_disorder_promotion_profile": "Hänen profiilinsa edistää syömishäiriöitä",
"report_flow.label.email": "Sähköpostiosoite",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Images ou vidéos réelles ou générées par IA représentant des abus sexuels sur des enfants",
"report_flow.label.discovery_listing": "Fiche dans Découverte",
"report_flow.label.dislike": "Ce n'est pas pour moi",
"report_flow.label.dsa": "Contenu illégal dans l'UE (règlement sur les services numériques, DSA)",
"report_flow.label.eating_disorder_promotion": "Encourager les troubles du comportement alimentaire",
"report_flow.label.eating_disorder_promotion_profile": "Son profil fait la promotion des troubles du comportement alimentaire",
"report_flow.label.email": "Adresse e-mail",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "תמונות או סרטונים של פגיעה מינית בילדים, אמיתיים או שנוצרו באמצעות AI",
"report_flow.label.discovery_listing": "רישום בגילוי",
"report_flow.label.dislike": "זה פשוט לא בשבילי",
"report_flow.label.dsa": "תוכן בלתי חוקי באיחוד האירופי (חוק השירותים הדיגיטליים, DSA)",
"report_flow.label.eating_disorder_promotion": "עידוד הפרעות אכילה",
"report_flow.label.eating_disorder_promotion_profile": "הפרופיל שלהם מקדם הפרעות אכילה",
"report_flow.label.email": "כתובת אימייל",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "बाल यौन शोषण की असली या AI से बनाई गई तस्वीरें या वीडियो",
"report_flow.label.discovery_listing": "डिस्कवरी लिस्टिंग",
"report_flow.label.dislike": "यह बस मेरे लिए नहीं है",
"report_flow.label.dsa": "यूरोपीय संघ में गैर-कानूनी सामग्री (डिजिटल सेवा अधिनियम)",
"report_flow.label.eating_disorder_promotion": "खाने संबंधी विकार वाली आदतों को बढ़ावा देना",
"report_flow.label.eating_disorder_promotion_profile": "उनकी प्रोफ़ाइल खाने संबंधी विकारों को बढ़ावा देती है",
"report_flow.label.email": "ईमेल पता",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Stvarne ili AI-generirane slike ili videozapisi seksualnog zlostavljanja djece",
"report_flow.label.discovery_listing": "Unos u Otkrivanju",
"report_flow.label.dislike": "Jednostavno mi ne odgovara",
"report_flow.label.dsa": "Sadržaj koji je nezakonit u EU-u (Akt o digitalnim uslugama)",
"report_flow.label.eating_disorder_promotion": "Poticanje poremećaja hranjenja",
"report_flow.label.eating_disorder_promotion_profile": "Njihov profil promiče poremećaje hranjenja",
"report_flow.label.email": "Adresa e-pošte",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Gyermekek szexuális bántalmazását ábrázoló valódi vagy mesterséges intelligencia által generált képek vagy videók",
"report_flow.label.discovery_listing": "Listázás a Felfedezésben",
"report_flow.label.dislike": "Egyszerűen nem nekem való",
"report_flow.label.dsa": "Az EU-ban illegális tartalom (digitális szolgáltatásokról szóló rendelet, DSA)",
"report_flow.label.eating_disorder_promotion": "Kóros étkezési szokásokra buzdítás",
"report_flow.label.eating_disorder_promotion_profile": "A profilja evészavarokat népszerűsít",
"report_flow.label.email": "E-mail-cím",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Gambar atau video kekerasan seksual terhadap anak, baik yang asli maupun buatan AI",
"report_flow.label.discovery_listing": "Listing di Discovery",
"report_flow.label.dislike": "Ini tidak cocok untuk saya",
"report_flow.label.dsa": "Konten yang ilegal di UE (Undang-Undang Layanan Digital/DSA)",
"report_flow.label.eating_disorder_promotion": "Mendorong perilaku makan yang terganggu",
"report_flow.label.eating_disorder_promotion_profile": "Profil mereka mempromosikan gangguan makan",
"report_flow.label.email": "Alamat email",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Immagini o video reali o generati da IA di abusi sessuali su minori",
"report_flow.label.discovery_listing": "Elenco in Scopri",
"report_flow.label.dislike": "Non fa per me",
"report_flow.label.dsa": "Contenuti illegali nell'UE (Regolamento sui servizi digitali, DSA)",
"report_flow.label.eating_disorder_promotion": "Incoraggiare disturbi alimentari",
"report_flow.label.eating_disorder_promotion_profile": "Il suo profilo promuove disturbi alimentari",
"report_flow.label.email": "Indirizzo e-mail",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "児童性的虐待の画像・動画(実在のもの、またはAI生成のもの)",
"report_flow.label.discovery_listing": "「見つける」の掲載内容",
"report_flow.label.dislike": "自分には合わない",
"report_flow.label.dsa": "EUで違法なコンテンツ(デジタルサービス法、DSA)",
"report_flow.label.eating_disorder_promotion": "摂食障害的な食行動を促している",
"report_flow.label.eating_disorder_promotion_profile": "プロフィールが摂食障害を助長している",
"report_flow.label.email": "メールアドレス",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "아동 성착취물(실제 또는 AI로 생성된 이미지 또는 동영상)",
"report_flow.label.discovery_listing": "디스커버리 목록",
"report_flow.label.dislike": "그냥 내 취향이 아닙니다",
"report_flow.label.dsa": "EU에서 불법인 콘텐츠(디지털 서비스법, DSA)",
"report_flow.label.eating_disorder_promotion": "섭식 장애 행동 조장",
"report_flow.label.eating_disorder_promotion_profile": "프로필에서 섭식 장애를 조장합니다",
"report_flow.label.email": "이메일 주소",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Tikri ar dirbtinio intelekto sukurti vaikų seksualinės prievartos vaizdai ar vaizdo įrašai",
"report_flow.label.discovery_listing": "Atradimo sąrašas",
"report_flow.label.dislike": "Tai tiesiog ne man",
"report_flow.label.dsa": "Turinys, kuris neteisėtas ES (Skaitmeninių paslaugų aktas)",
"report_flow.label.eating_disorder_promotion": "Sutrikusių mitybos įpročių skatinimas",
"report_flow.label.eating_disorder_promotion_profile": "Jų profilis propaguoja valgymo sutrikimus",
"report_flow.label.email": "El. pašto adresas",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Echte of door AI gegenereerde beelden of video's van seksueel misbruik van kinderen",
"report_flow.label.discovery_listing": "Vermelding bij Ontdekken",
"report_flow.label.dislike": "Het is gewoon niets voor mij",
"report_flow.label.dsa": "Inhoud die illegaal is in de EU (digitaledienstenverordening)",
"report_flow.label.eating_disorder_promotion": "Gestoord eetgedrag aanmoedigen",
"report_flow.label.eating_disorder_promotion_profile": "Hun profiel promoot eetstoornissen",
"report_flow.label.email": "E-mailadres",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Ekte eller AI-genererte bilder eller videoer av seksuelle overgrep mot barn",
"report_flow.label.discovery_listing": "Oppføring i Oppdag",
"report_flow.label.dislike": "Det er bare ikke noe for meg",
"report_flow.label.dsa": "Innhold som er ulovlig i EU (forordningen om digitale tjenester, DSA)",
"report_flow.label.eating_disorder_promotion": "Oppfordrer til spiseforstyrrelser",
"report_flow.label.eating_disorder_promotion_profile": "Profilen deres fremmer spiseforstyrrelser",
"report_flow.label.email": "E-postadresse",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Prawdziwe lub wygenerowane przez AI obrazy lub filmy przedstawiające wykorzystywanie seksualne dzieci",
"report_flow.label.discovery_listing": "Wpis w sekcji Odkrywanie",
"report_flow.label.dislike": "To po prostu nie jest dla mnie",
"report_flow.label.dsa": "Treści, które są nielegalne w UE (akt o usługach cyfrowych)",
"report_flow.label.eating_disorder_promotion": "Zachęcanie do zaburzeń odżywiania",
"report_flow.label.eating_disorder_promotion_profile": "Ich profil promuje zaburzenia odżywiania",
"report_flow.label.email": "Adres e-mail",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Imagens ou vídeos reais ou gerados por IA de abuso sexual infantil",
"report_flow.label.discovery_listing": "Listagem em Descobrir",
"report_flow.label.dislike": "Não é para mim",
"report_flow.label.dsa": "Conteúdo ilegal na UE (Regulamento dos Serviços Digitais)",
"report_flow.label.eating_disorder_promotion": "Incentivando transtornos alimentares",
"report_flow.label.eating_disorder_promotion_profile": "O perfil dele(a) promove transtornos alimentares",
"report_flow.label.email": "Endereço de e-mail",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Imagini sau videoclipuri reale sau generate de AI cu abuz sexual asupra copiilor",
"report_flow.label.discovery_listing": "Listarea în Discovery",
"report_flow.label.dislike": "Pur și simplu nu este pentru mine",
"report_flow.label.dsa": "Conținut ilegal în UE (Regulamentul privind serviciile digitale)",
"report_flow.label.eating_disorder_promotion": "Încurajarea tulburărilor de alimentație",
"report_flow.label.eating_disorder_promotion_profile": "Profilul său promovează tulburările de alimentație",
"report_flow.label.email": "Adresă de e-mail",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Реальные или созданные ИИ изображения или видео сексуального насилия над детьми",
"report_flow.label.discovery_listing": "Запись в каталоге",
"report_flow.label.dislike": "Мне это не подходит",
"report_flow.label.dsa": "Незаконный в ЕС контент (Закон о цифровых услугах, DSA)",
"report_flow.label.eating_disorder_promotion": "Поощрение расстройств пищевого поведения",
"report_flow.label.eating_disorder_promotion_profile": "Их профиль пропагандирует расстройства пищевого поведения",
"report_flow.label.email": "Адрес электронной почты",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "Verkliga eller AI-genererade bilder eller videor av sexuella övergrepp mot barn",
"report_flow.label.discovery_listing": "Listning i Upptäck",
"report_flow.label.dislike": "Det är bara inget för mig",
"report_flow.label.dsa": "Innehåll som är olagligt i EU (förordningen om digitala tjänster)",
"report_flow.label.eating_disorder_promotion": "Uppmuntrar till ätstörningar",
"report_flow.label.eating_disorder_promotion_profile": "Deras profil främjar ätstörningar",
"report_flow.label.email": "E-postadress",
@@ -41,7 +41,6 @@
"report_flow.label.csam": "ภาพหรือวิดีโอการล่วงละเมิดทางเพศเด็ก ไม่ว่าจะเป็นของจริงหรือสร้างโดย AI",
"report_flow.label.discovery_listing": "รายการใน Discovery",
"report_flow.label.dislike": "ไม่เหมาะกับฉัน",
"report_flow.label.dsa": "เนื้อหาที่ผิดกฎหมายในสหภาพยุโรป (กฎหมายบริการดิจิทัล หรือ DSA)",
"report_flow.label.eating_disorder_promotion": "ส่งเสริมพฤติกรรมการกินที่ผิดปกติ",
"report_flow.label.eating_disorder_promotion_profile": "โปรไฟล์ของพวกเขาส่งเสริมความผิดปกติในการกิน",
"report_flow.label.email": "ที่อยู่อีเมล",
Loaded 100 of 699 files, more files were not shown because too many files have changed in this diff. Show more