Compare commits

..
Author SHA1 Message Date
HampusandGitHub 7e1ca9ed6a fix(api): mirror Stripe billing data using the shapes the pinned API version sends (#1708) 2026-08-18 02:35:58 +02:00
HampusandGitHub 1922d56188 fix(api): keep Stripe expand paths within the four-level limit (#1707) 2026-08-18 01:06:10 +02:00
HampusandGitHub cc105883a5 feat(app): restyle the inbox and message preview surfaces (#1706) 2026-08-18 00:43:18 +02:00
HampusandGitHub 41c7acdd8f feat(api): redirect artifact downloads to presigned storage URLs (#1703) 2026-08-17 21:47:46 +02:00
HampusandGitHub c35d29ea0b fix(app): resolve the unread anchor where channel message loads originate (#1701) 2026-08-17 16:20:49 +02:00
HampusandGitHub 97c29bf1fb fix(app): open channels at the unread boundary when the backlog exceeds one page (#1698) 2026-08-17 15:14:42 +02:00
HampusandGitHub 3ff7189566 fix(app): apply streamer mode to member nicknames and stop guild scope leaking into global surfaces (#1697) 2026-08-17 14:58:56 +02:00
HampusandGitHub 3fa766c39c fix(app): show the full user tag beside display names instead of a bare discriminator (#1696) 2026-08-17 14:46:06 +02:00
HampusandGitHub 10ae4bfe1e ci: publish releases with the Fluxer CI app instead of the Actions token (#1695) 2026-08-17 12:59:29 +02:00
HampusandGitHub d271f3112c fix(app): resend a stalled member list subscription before replacing the session (#1694) 2026-08-17 12:48:17 +02:00
HampusandGitHub 5a13172b46 ci: stop fork clones running repository-scoped workflows (#1693) 2026-08-17 12:06:24 +02:00
HampusandGitHub 2269e1899e fix(app): show friend nicknames on forward targets (#1692) 2026-08-17 12:00:08 +02:00
HampusandGitHub c61fd96df6 fix(app): search forward targets by username and every known nickname (#1691) 2026-08-17 11:51:07 +02:00
HampusandGitHub 6c68202222 fix(api): let SSO accounts without a password satisfy sudo verification (#1690) 2026-08-16 23:35:47 +02:00
HampusandGitHub e0bb10d5b7 fix(api): reject unclaimed guild creation before the email verification gate (#1689) 2026-08-16 23:34:07 +02:00
HampusandGitHub 96643ab20d fix(unfurl): key the cache on available provider credentials (#1688) 2026-08-16 23:19:51 +02:00
HampusandGitHub 40da982f57 fix(app): restore the guild list item hover tooltip (#1687) 2026-08-16 23:04:55 +02:00
HampusandGitHub 8a14281b87 feat(api): return discovery category counts and banners for faceted search (#1686) 2026-08-16 22:39:21 +02:00
HampusandGitHub be69157811 fix(admin): allow single community mode to be turned back on (#1684) 2026-08-16 22:29:23 +02:00
HampusandGitHub 45289b5531 fix(api): let single community setup work without email verification (#1683) 2026-08-16 22:14:59 +02:00
HampusandGitHub 30a1271774 fix(app): keep timers running while the window is visible but unfocused (#1682) 2026-08-16 22:06:09 +02:00
HampusandGitHub 438f11adda perf(app): stop per-frame style recalculation in skeletons and member list (#1681) 2026-08-16 22:00:10 +02:00
HampusandGitHub 170ca805c5 fix(app): stop the service worker serving the unrendered index template (#1680) 2026-08-16 20:52:07 +02:00
HampusandGitHub f4547d11d3 fix(app): dim the disabled discovery nav item through colour only (#1678) 2026-08-16 19:33:35 +02:00
HampusandGitHub ccdd85b099 refactor(desktop): replace the native voice engine with a standalone hardware encoder (#1677) 2026-08-16 19:24:31 +02:00
HampusandGitHub 98c40c6979 feat(app): redesign discovery and align skeletons with rendered UI 2026-08-16 19:17:28 +02:00
HampusandGitHub e054aa96be chore(desktop): upgrade Electron to 43.4.0 (#1674) 2026-08-16 15:42:29 +02:00
HampusandGitHub 3e12466c57 fix(unfurl): restore document title fallback for link embeds (#1673) 2026-08-16 15:02:16 +02:00
HampusandGitHub 039bd1a7df fix(embed): correct link thumbnail placement and harden unfurl parsing (#1672) 2026-08-16 14:10:25 +02:00
HampusandGitHub 7a45d5844d fix(app): scale guild list selection pill with app zoom (#1670) 2026-08-16 01:36:16 +02:00
HampusandGitHub 410fa2dba9 fix(app): scale group DM status indicator with app zoom (#1669) 2026-08-16 01:36:01 +02:00
HampusandGitHub 4cb1808959 fix(media-proxy): register coalescer waiters before checking slot state (#1668) 2026-08-16 01:02:09 +02:00
yidoandGitHub 60a62c24c3 fix(media-proxy): handle coalescer future cancellation with optimal drop guard (#1156)
Refs #1146
2026-08-16 00:59:41 +02:00
HampusandGitHub c06e958eb5 fix(ci): resolve knip unused export and stale entry pattern (#1667) 2026-08-16 00:47:56 +02:00
358b7c88fc fix(settings): require all query words to match (#1665)
Signed-off-by: liamt8d <[email protected]>
Co-authored-by: Hampus <[email protected]>
2026-08-16 00:44:34 +02:00
HampusandGitHub 1bced6abae fix(app): keep typing indicator dots animating under reduced motion (#1666) 2026-08-16 00:32:52 +02:00
1443 changed files with 51020 additions and 400590 deletions
+20 -2
View File
@@ -65,10 +65,19 @@ jobs:
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: set variables
id: vars
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- resolve-calver
@@ -151,9 +160,18 @@ jobs:
"${IMAGE}:${VERSION}-arm64"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
SOURCE_SHA: ${{ github.sha }}
VERSION: ${{ needs.meta.outputs.build_version }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-admin
dockerfile: fluxer_admin/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-api
dockerfile: fluxer_api/Dockerfile
@@ -29,6 +29,7 @@ jobs:
build:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-app-proxy-self-hosted
dockerfile: fluxer_app_proxy/Dockerfile
+10 -1
View File
@@ -187,9 +187,18 @@ jobs:
"${IMAGE}:${VERSION}-arm64"
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub release
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
SOURCE_SHA: ${{ github.sha }}
VERSION: ${{ needs.meta.outputs.build_version }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
+20 -2
View File
@@ -67,10 +67,19 @@ jobs:
with:
toolchain: "1.93.0"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Set metadata
id: meta
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs.build_version }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
@@ -581,9 +590,18 @@ jobs:
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub desktop release
env:
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-docs
dockerfile: fluxer_docs/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-gateway
dockerfile: fluxer_gateway/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-gifs
dockerfile: fluxer_gifs/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-media-proxy
dockerfile: fluxer_media_proxy/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-messages
dockerfile: fluxer_messages/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-snowflakes
dockerfile: fluxer_snowflakes/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-static
dockerfile: fluxer_static/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-unfurl
dockerfile: fluxer_unfurl/Dockerfile
+1
View File
@@ -29,6 +29,7 @@ jobs:
image:
needs: approve
uses: ./.github/workflows/_build-image.yaml
secrets: inherit
with:
image: fluxer-users
dockerfile: fluxer_users/Dockerfile
@@ -17,6 +17,7 @@ concurrency:
jobs:
dispatch:
name: Dispatch regeneration
if: github.repository == 'fluxerapp/fluxer'
runs-on: ubuntu-latest
steps:
- name: Create token
@@ -31,6 +31,7 @@ concurrency:
jobs:
metadata:
name: resolve exact private build metadata
if: github.repository == 'fluxerapp/fluxer'
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
@@ -39,11 +40,20 @@ jobs:
build_version: ${{ steps.inputs.outputs.build_version }}
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
steps:
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Resolve trusted build inputs
id: inputs
env:
EVENT_AFTER: ${{ github.event.after }}
GH_TOKEN: ${{ github.token }}
GH_TOKEN: ${{ steps.create-token.outputs.token }}
PARENT_SHA: ${{ github.sha }}
PUBLIC_REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
+1
View File
@@ -5,6 +5,7 @@ permissions: {}
jobs:
label:
name: Label
if: github.repository == 'fluxerapp/fluxer'
runs-on: ubuntu-latest
steps:
- name: Create token
@@ -41,7 +41,7 @@ concurrency:
jobs:
automatic:
name: Lock closed conversation
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
if: github.repository == 'fluxerapp/fluxer' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- name: Create token
@@ -155,7 +155,7 @@ jobs:
retroactive:
name: Lock closed conversations retroactively
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
if: github.repository == 'fluxerapp/fluxer' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
runs-on: ubuntu-latest
steps:
- name: Create token
+1
View File
@@ -14,6 +14,7 @@
**/*.css.d.ts
**/*.tsbuildinfo
**/.cache/
**/.swc/
**/__pycache__/
**/_build/
**/coverage/
Generated
+10
View File
@@ -1624,6 +1624,15 @@ dependencies = [
"zeroize",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "entities"
version = "1.0.1"
@@ -1931,6 +1940,7 @@ dependencies = [
"anyhow",
"base64",
"chrono",
"encoding_rs",
"entities",
"fluxer-svc",
"hmac 0.13.0",
-2
View File
@@ -19,8 +19,6 @@ members = [
exclude = [
"fluxer_marketing",
"packages/markdown_parser/rust/fuzz",
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.19.16",
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.23.1",
]
resolver = "2"
-2
View File
@@ -12543,7 +12543,6 @@
"type": "object",
"properties": {
"single_community_enabled": {"type": "boolean"},
"single_community_locked": {"type": "boolean"},
"single_community_guild_id": {"nullable": true, "type": "string"},
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean"},
@@ -12578,7 +12577,6 @@
},
"required": [
"single_community_enabled",
"single_community_locked",
"single_community_guild_id",
"direct_messages_disabled",
"direct_messages_locked",
@@ -24,8 +24,6 @@ pub struct InstanceConfigResponse {
pub struct InstancePolicyResponse {
#[serde(default)]
pub single_community_enabled: bool,
#[serde(default)]
pub single_community_locked: bool,
pub single_community_guild_id: Option<String>,
#[serde(default)]
pub direct_messages_disabled: bool,
@@ -45,7 +43,6 @@ impl Default for InstancePolicyResponse {
fn default() -> Self {
Self {
single_community_enabled: false,
single_community_locked: false,
single_community_guild_id: None,
direct_messages_disabled: false,
direct_messages_locked: false,
+7 -3
View File
@@ -216,7 +216,11 @@ pub async fn instance_config_post(
Err(message) => FlashData::error(message),
},
"disable_single_community" => {
let update = build_disable_single_community_update();
let update = build_single_community_update(false);
instance_config_result(client.update_instance_config(&update).await)
}
"enable_single_community" => {
let update = build_single_community_update(true);
instance_config_result(client.update_instance_config(&update).await)
}
"create_registration_url" => match build_create_registration_url_request(&form) {
@@ -696,14 +700,14 @@ fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTes
})
}
fn build_disable_single_community_update() -> InstanceConfigUpdateRequest {
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
gateway_rollout: None,
registration: None,
sso: None,
app_public: None,
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: Some(false),
single_community_enabled: Some(enabled),
single_community_name: None,
direct_messages_disabled: None,
premium_mode: None,
@@ -208,18 +208,14 @@ fn single_community_form(base: &str, csrf_token: &str, policy: &InstancePolicyRe
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Single community" }
(badge(status.0, status.1))
@if policy.single_community_locked {
(badge("Locked", BadgeVariant::Warning))
}
}
@if let Some(guild_id) = policy.single_community_guild_id.as_deref() {
p class="break-all text-xs text-neutral-500" { "Community guild ID: " (guild_id) }
}
@if policy.single_community_enabled && !policy.single_community_locked {
@if policy.single_community_enabled {
p class="text-sm text-neutral-500" {
"This instance funnels every member into a single community. Disabling it is \
permanent: single-community mode can only be enabled again from the \
self-host setup wizard, never from this panel."
"This instance funnels every member into a single community. You can turn this \
off and on again from here. The community itself is kept either way."
}
form method="post" action={(base) "/instance-config?action=disable_single_community"} {
(csrf_input(csrf_token))
@@ -227,15 +223,21 @@ fn single_community_form(base: &str, csrf_token: &str, policy: &InstancePolicyRe
(danger_button("Disable single-community mode"))
}))
}
} @else if policy.single_community_enabled {
} @else if policy.single_community_guild_id.is_some() {
p class="text-sm text-neutral-500" {
"Single-community mode is enabled and locked for this instance. It cannot be \
changed from the admin panel."
"Single-community mode is off. Turning it on again reuses the community above \
if it still exists, otherwise a new one is created."
}
form method="post" action={(base) "/instance-config?action=enable_single_community"} {
(csrf_input(csrf_token))
(form_actions(html! {
(submit_button("Enable single-community mode"))
}))
}
} @else {
p class="text-sm text-neutral-500" {
"Single-community mode is off. It can only be turned on from the self-host \
setup wizard, not from this panel."
"Single-community mode is off. It can only be turned on for the first time \
from the self-host setup wizard."
}
}
}
@@ -10,6 +10,7 @@ import type {ElasticsearchIndexDefinition} from '../ElasticsearchIndexDefinition
const ELASTICSEARCH_MAX_RESULT_WINDOW = 10000;
const DEEP_PAGINATION_BATCH_SIZE = 1000;
const MAX_SEARCH_LIMIT = 1000;
const FACET_TERM_LIMIT = 200;
interface ElasticsearchSearchHit<TResult> {
_source?: TResult;
@@ -17,6 +18,10 @@ interface ElasticsearchSearchHit<TResult> {
sort?: SortResults;
}
interface ElasticsearchTermsAggregation {
buckets?: Array<{key: string | number; doc_count: number}>;
}
interface ElasticsearchSearchResponse<TResult> {
hits: {
total?:
@@ -26,6 +31,7 @@ interface ElasticsearchSearchResponse<TResult> {
};
hits: Array<ElasticsearchSearchHit<TResult>>;
};
aggregations?: Record<string, ElasticsearchTermsAggregation>;
}
type ElasticsearchBaseSearchRequest = Omit<SearchRequest, 'from' | 'search_after' | 'size' | 'track_total_hits'>;
@@ -265,6 +271,7 @@ export class ElasticsearchIndexAdapter<
},
]
: [{match_all: {}}];
const facets = options?.facets;
const searchParams: ElasticsearchBaseSearchRequest = {
index: this.indexDefinition.indexName,
query: {
@@ -273,6 +280,11 @@ export class ElasticsearchIndexAdapter<
filter: filterClauses.length > 0 ? filterClauses : undefined,
},
},
...(facets && facets.length > 0
? {
aggs: Object.fromEntries(facets.map((facet) => [facet, {terms: {field: facet, size: FACET_TERM_LIMIT}}])),
}
: {}),
};
const defaultSort: SortCombinations = {id: {order: 'desc'}};
const effectiveSort: NonNullable<SearchRequest['sort']> =
@@ -357,13 +369,30 @@ export class ElasticsearchIndexAdapter<
}
private toSearchResult(result: ElasticsearchSearchResponse<TResult>): SearchResult<TResult> {
const facetCounts = this.toFacetCounts(result.aggregations);
return {
hits: this.mapHits(result.hits.hits),
total: this.getTotalHits(result),
cursor: result.hits.hits.at(-1)?.sort?.map((value) => String(value)),
...(facetCounts ? {facetCounts} : {}),
};
}
private toFacetCounts(
aggregations: Record<string, ElasticsearchTermsAggregation> | undefined,
): Record<string, Record<string, number>> | undefined {
if (!aggregations) {
return undefined;
}
const counts: Record<string, Record<string, number>> = {};
for (const [facet, aggregation] of Object.entries(aggregations)) {
counts[facet] = Object.fromEntries(
(aggregation.buckets ?? []).map((bucket) => [String(bucket.key), bucket.doc_count]),
);
}
return counts;
}
private mapHits(hits: Array<ElasticsearchSearchHit<TResult>>): Array<TResult> {
return hits.map((hit) => ({...hit._source!, id: hit._id!}));
}
+1
View File
@@ -429,6 +429,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
testHarnessToken: master.dev.test_harness_token,
},
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
attachmentDecayEnabled: master.attachment_decay_enabled,
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
@@ -742,6 +742,9 @@ class BillingAdminControllerService {
refundTarget,
subscription,
});
if (refundDecision.amountCents !== null && !refundTarget) {
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
}
const intentId = await this.billingRepository.actionIntents.create({
userId: BigInt(syncedTargetUser.id),
actorAdminId: BigInt(params.adminUserId),
@@ -758,10 +761,7 @@ class BillingAdminControllerService {
await this.billingRepository.actionIntents.markStage(intentId, 'sub_canceled', {
sub_canceled_at: new Date(),
});
if (refundDecision.amountCents !== null) {
if (!refundTarget) {
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
}
if (refundDecision.amountCents !== null && refundTarget) {
refund = await this.stripe.refunds.create(
{
...(refundTarget.paymentIntentId
@@ -91,7 +91,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
app_public: appPublic,
policy: {
single_community_enabled: policy.single_community_enabled,
single_community_locked: policy.single_community_locked,
single_community_guild_id: policy.single_community_guild_id,
direct_messages_disabled: policy.direct_messages_disabled,
direct_messages_locked: policy.direct_messages_locked,
@@ -551,20 +550,19 @@ async function applyInstancePolicyUpdate(
policy.single_community_enabled !== current.single_community_enabled
) {
if (policy.single_community_enabled) {
if (appPublic.setup.configured || current.single_community_locked) {
if (appPublic.setup.configured && current.single_community_guild_id == null) {
throw new InstancePolicyTransitionNotAllowedError();
}
const adminUser = await ctx.get('userRepository').findUnique(ctx.get('adminUserId'));
if (!adminUser) {
throw new InstancePolicyTransitionNotAllowedError();
}
await ctx.get('singleCommunityService').createStockCommunity({
await ctx.get('singleCommunityService').ensureStockCommunity({
owner: adminUser,
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
});
} else {
patch.single_community_enabled = false;
patch.single_community_locked = true;
}
}
if (
@@ -283,7 +283,7 @@ describe('Admin billing overview', () => {
invoices: {
[params.invoiceId]: {
customer: params.stripeCustomerId,
subscription: params.stripeSubscriptionId,
subscriptionId: params.stripeSubscriptionId,
amount_due: params.amountPaidCents,
amount_paid: params.amountPaidCents,
billing_reason: 'subscription_cycle',
@@ -359,7 +359,7 @@ describe('Admin billing overview', () => {
invoices: {
in_local_checkout_1: {
customer: stripeCustomerId,
subscription: 'sub_billing_target',
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
@@ -396,7 +396,7 @@ describe('Admin billing overview', () => {
},
in_renewal_1: {
customer: stripeCustomerId,
subscription: 'sub_billing_target',
subscriptionId: 'sub_billing_target',
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_cycle',
@@ -545,7 +545,7 @@ describe('Admin billing overview', () => {
invoices: {
[invoiceId]: {
customer: stripeCustomerId,
subscription: stripeSubscriptionId,
subscriptionId: stripeSubscriptionId,
amount_due: 499,
amount_paid: 499,
billing_reason: 'subscription_create',
@@ -31,6 +31,16 @@ export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boo
);
}
export function hasNoVerifiableCredential(
user: {passwordHash: string | null; isBot: boolean},
hasMfa: boolean,
): boolean {
if (user.isBot || hasMfa) {
return false;
}
return user.passwordHash === null;
}
export function deriveSudoMethods(user: {
totpSecret?: string | null;
authenticatorTypes?: Set<number> | null;
@@ -86,8 +96,7 @@ async function verifySudoMode(
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
return {verified: true, sudoToken, method: 'mfa'};
}
const isUnclaimedAccount = user.isUnclaimedAccount();
if (isUnclaimedAccount && !hasMfa) {
if (hasNoVerifiableCredential(user, hasMfa)) {
return {verified: true, method: 'password'};
}
if (body.password && !hasMfa) {
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
import {describe, expect, it} from 'vitest';
import {createUserID} from '../../BrandedTypes';
import {EMPTY_USER_ROW, type UserRow} from '../../database/types/UserTypes';
import {User} from '../../models/User';
import {hasNoVerifiableCredential, userHasMfa} from '../services/SudoVerificationService';
function createUser(overrides: Partial<UserRow> = {}): User {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(1n),
username: 'test_user',
discriminator: 1,
bot: false,
password_hash: 'hash',
traits: new Set<string>(),
...overrides,
});
}
describe('sudo verification credential capability', () => {
it('lets an SSO provisioned account without a password satisfy sudo mode', () => {
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
expect(user.isUnclaimedAccount()).toBe(false);
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
});
it('still lets an unclaimed account satisfy sudo mode', () => {
const user = createUser({password_hash: null});
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
});
it('still requires a password from accounts that have one', () => {
const user = createUser({password_hash: 'hash', traits: new Set<string>(['sso'])});
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
});
it('still requires MFA from an SSO account that enrolled a second factor', () => {
const user = createUser({
password_hash: null,
traits: new Set<string>(['sso']),
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
});
expect(userHasMfa(user)).toBe(true);
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
});
it('never applies to bots', () => {
const user = createUser({password_hash: null, bot: true});
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
});
});
@@ -324,7 +324,7 @@ describe('mapStripeInvoiceToRow', () => {
const inv = stripeFixture<Stripe.Invoice>({
id: 'in_1',
customer: 'cus_1',
subscription: 'sub_1',
parent: {type: 'subscription_details', subscription_details: {subscription: 'sub_1'}},
status: 'paid',
number: 'INV-001',
currency: 'usd',
@@ -94,28 +94,6 @@ export interface StripeSubscriptionPayload {
trial_start?: number | null;
}
interface StripeInvoiceCompatibility extends Stripe.Invoice {
subscription?: StripeExpandableId;
tax?: number | null;
application_fee_amount?: number | null;
}
interface StripePaymentIntentCompatibility extends Stripe.PaymentIntent {
invoice?: StripeExpandableId;
}
interface StripeChargeCompatibility extends Stripe.Charge {
invoice?: StripeExpandableId;
}
type StripeRefundCompatibility = Stripe.Refund & {
livemode?: boolean | null;
};
interface StripeCheckoutSessionCompatibility extends Stripe.Checkout.Session {
completed_at?: number | null;
}
function createBillingSubscriptionItemValue(
itemId: string,
priceId: string,
@@ -280,6 +258,14 @@ export function computeStripeUpdatedAt(
return new Date(max * 1000);
}
function sumInvoiceTotalTaxes(inv: Stripe.Invoice): bigint | null {
const taxes = inv.total_taxes ?? null;
if (taxes === null) {
return null;
}
return BigInt(taxes.reduce((total, tax) => total + (tax.amount ?? 0), 0));
}
function idOf(
ref:
| string
@@ -689,9 +675,7 @@ export function mapStripeInvoiceToRow(
throw new BillingMappingError('Invoice is missing customer', inv.id);
}
const now = new Date();
const invoice = inv as StripeInvoiceCompatibility;
const subscriptionId =
idOf(invoice.subscription ?? null) ?? idOf(invoice.parent?.subscription_details?.subscription ?? null);
const subscriptionId = idOf(inv.parent?.subscription_details?.subscription ?? null);
const userIdFromMetadata = parseUserIdFromMetadata(inv.metadata);
const userId = hints?.knownUserId ?? userIdFromMetadata;
const transitions = inv.status_transitions ?? null;
@@ -718,12 +702,11 @@ export function mapStripeInvoiceToRow(
amount_paid: typeof inv.amount_paid === 'number' ? BigInt(inv.amount_paid) : null,
amount_remaining: typeof inv.amount_remaining === 'number' ? BigInt(inv.amount_remaining) : null,
subtotal: typeof inv.subtotal === 'number' ? BigInt(inv.subtotal) : null,
tax: typeof invoice.tax === 'number' ? BigInt(invoice.tax) : null,
tax: sumInvoiceTotalTaxes(inv),
total: typeof inv.total === 'number' ? BigInt(inv.total) : null,
starting_balance: typeof inv.starting_balance === 'number' ? BigInt(inv.starting_balance) : null,
ending_balance: typeof inv.ending_balance === 'number' ? BigInt(inv.ending_balance) : null,
application_fee_amount:
typeof invoice.application_fee_amount === 'number' ? BigInt(invoice.application_fee_amount) : null,
application_fee_amount: null,
attempt_count: inv.attempt_count ?? null,
attempted: inv.attempted ?? null,
auto_advance: inv.auto_advance ?? null,
@@ -818,7 +801,6 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
throw new BillingMappingError('PaymentIntent is missing id');
}
const now = new Date();
const paymentIntent = pi as StripePaymentIntentCompatibility;
const customerId = idOf(
pi.customer as
| string
@@ -828,7 +810,7 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
| null
| undefined,
);
const invoiceId = idOf(paymentIntent.invoice ?? null);
const invoiceId = null;
const paymentMethodId = idOf(
pi.payment_method as
| string
@@ -892,7 +874,6 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
throw new BillingMappingError('Charge is missing id');
}
const now = new Date();
const charge = c as StripeChargeCompatibility;
const customerId = idOf(
c.customer as
| string
@@ -911,7 +892,7 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
| null
| undefined,
);
const invoiceId = idOf(charge.invoice ?? null);
const invoiceId = null;
const paymentMethodId = c.payment_method ?? null;
const card = c.payment_method_details?.card ?? null;
const billingDetails = c.billing_details ?? null;
@@ -974,6 +955,7 @@ export function mapStripeRefundToRow(
invoiceId?: string;
customerId?: string;
userId?: bigint;
livemode?: boolean;
},
): {
primary: BillingRefundRow;
@@ -990,7 +972,6 @@ export function mapStripeRefundToRow(
const invoiceId = hints?.invoiceId ?? null;
const customerId = hints?.customerId ?? null;
const userId = hints?.userId ?? null;
const refund = r as StripeRefundCompatibility;
const primary: BillingRefundRow = {
provider_id: r.id,
charge_id: chargeId,
@@ -1005,7 +986,7 @@ export function mapStripeRefundToRow(
receipt_number: r.receipt_number ?? null,
failure_reason: r.failure_reason ?? null,
description: r.description ?? null,
livemode: refund.livemode ?? null,
livemode: hints?.livemode ?? null,
metadata: safeMetadata(r.metadata),
stripe_created_at: unixToDate(r.created),
stripe_updated_at: computeStripeUpdatedAt({created: r.created}),
@@ -1049,16 +1030,17 @@ export function mapStripeCheckoutSessionToRow(
cs: Stripe.Checkout.Session,
hints?: {
knownUserId?: bigint;
eventCreated?: number;
},
): {
primary: BillingCheckoutSessionRow;
byCustomer: BillingCheckoutSessionByCustomerRow | null;
} {
const completedAt = cs.status === 'complete' ? (hints?.eventCreated ?? null) : null;
if (!cs.id) {
throw new BillingMappingError('Checkout session is missing id');
}
const now = new Date();
const checkoutSession = cs as StripeCheckoutSessionCompatibility;
const customerId = idOf(
cs.customer as
| string
@@ -1124,14 +1106,14 @@ export function mapStripeCheckoutSessionToRow(
amount_total: typeof cs.amount_total === 'number' ? BigInt(cs.amount_total) : null,
currency: cs.currency ?? null,
expires_at: unixToDate(cs.expires_at),
completed_at: unixToDate(checkoutSession.completed_at ?? null),
completed_at: completedAt === null ? null : unixToDate(completedAt),
livemode: cs.livemode ?? null,
client_reference_id: cs.client_reference_id ?? null,
metadata: safeMetadata(cs.metadata),
stripe_created_at: unixToDate(cs.created),
stripe_updated_at: computeStripeUpdatedAt({
created: cs.created,
completed_at: checkoutSession.completed_at ?? null,
completed_at: completedAt,
}),
mirrored_at: now,
} as BillingCheckoutSessionRow;
@@ -49,6 +49,7 @@ export class BillingCheckoutSessionRepository {
cs: Stripe.Checkout.Session,
hints?: {
knownUserId?: bigint;
eventCreated?: number;
},
): Promise<{
changed: boolean;
@@ -71,6 +71,7 @@ export class BillingRefundRepository {
invoiceId?: string;
customerId?: string;
userId?: bigint;
livemode?: boolean;
},
): Promise<{
changed: boolean;
+1
View File
@@ -327,6 +327,7 @@ export interface APIConfig {
testHarnessToken?: string;
};
presignedAttachmentUploadsEnabled: boolean;
presignedDownloadsEnabled: boolean;
attachmentDecayEnabled: boolean;
deletionGracePeriodHours: number;
inactivityDeletionThresholdDays?: number;
@@ -73,6 +73,8 @@ async function headArtifactResponse(
return new Response(null, {status: 200, headers});
}
const PRESIGNED_DOWNLOAD_TTL_SECONDS = 900;
async function streamArtifactResponse(
ctx: Context<HonoEnv>,
downloadService: DownloadService,
@@ -83,6 +85,24 @@ async function streamArtifactResponse(
if (ctx.req.method === 'HEAD') {
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
}
if (downloadService.isPresignedDownloadEnabled()) {
const location = await downloadService.getPresignedDownloadRedirect({
key,
filename: artifactFilename(key, filenameOverride),
expiresIn: PRESIGNED_DOWNLOAD_TTL_SECONDS,
});
if (!location) {
return ctx.text('Not Found', 404);
}
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': 'no-store',
'Accept-Ranges': 'bytes',
}),
});
}
const range = ctx.req.header('range') ?? undefined;
let result: DownloadStreamResult | null;
try {
@@ -533,6 +533,28 @@ export class DownloadService {
}
}
isPresignedDownloadEnabled(): boolean {
return Config.presignedDownloadsEnabled;
}
async getPresignedDownloadRedirect(params: {
key: string;
filename: string;
expiresIn: number;
}): Promise<string | null> {
const metadata = await this.getDownloadMetadata({key: params.key});
if (!metadata) {
return null;
}
return this.storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.downloads,
key: params.key,
expiresIn: params.expiresIn,
responseContentType: metadata.contentType ?? 'application/octet-stream',
responseContentDisposition: `attachment; filename="${encodeURIComponent(params.filename)}"`,
});
}
async getDownloadMetadata(params: {key: string}): Promise<{
contentLength: number;
contentType?: string | null;
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import type {IStorageService} from '../../infrastructure/IStorageService';
import {DownloadService} from '../DownloadService';
const OBJECT_METADATA = {
contentLength: 285_567_850,
contentType: 'application/x-apple-diskimage',
etag: '"abc123"',
lastModified: new Date('2026-08-17T00:00:00Z'),
};
interface PresignCall {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}
function createService(overrides: {metadata?: typeof OBJECT_METADATA | null} = {}) {
const presignCalls: Array<PresignCall> = [];
const storageService = {
getObjectMetadata: async () => (overrides.metadata === undefined ? OBJECT_METADATA : overrides.metadata),
getPresignedDownloadURL: async (params: PresignCall) => {
presignCalls.push(params);
return `https://storage.example.test/${params.key}?signed=1`;
},
} as unknown as IStorageService;
return {service: new DownloadService(storageService), presignCalls};
}
describe('presigned download redirects', () => {
it('signs the requested object and returns its URL', async () => {
const {service, presignCalls} = createService();
const url = await service.getPresignedDownloadRedirect({
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
filename: 'Fluxer.dmg',
expiresIn: 900,
});
expect(url).toBe('https://storage.example.test/desktop/canary/darwin/universal/Fluxer.dmg?signed=1');
expect(presignCalls).toHaveLength(1);
expect(presignCalls[0]?.key).toBe('desktop/canary/darwin/universal/Fluxer.dmg');
expect(presignCalls[0]?.expiresIn).toBe(900);
});
it('preserves the download filename and content type through the redirect', async () => {
const {service, presignCalls} = createService();
await service.getPresignedDownloadRedirect({
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
filename: 'Fluxer Canary.dmg',
expiresIn: 900,
});
expect(presignCalls[0]?.responseContentType).toBe('application/x-apple-diskimage');
expect(presignCalls[0]?.responseContentDisposition).toBe(
`attachment; filename="${encodeURIComponent('Fluxer Canary.dmg')}"`,
);
});
it('falls back to a binary content type when storage reports none', async () => {
const {service, presignCalls} = createService({
metadata: {...OBJECT_METADATA, contentType: null} as unknown as typeof OBJECT_METADATA,
});
await service.getPresignedDownloadRedirect({key: 'desktop/x.bin', filename: 'x.bin', expiresIn: 900});
expect(presignCalls[0]?.responseContentType).toBe('application/octet-stream');
});
it('returns null for a missing object so the caller can answer 404 without signing', async () => {
const {service, presignCalls} = createService({metadata: null});
const url = await service.getPresignedDownloadRedirect({
key: 'desktop/missing.dmg',
filename: 'missing.dmg',
expiresIn: 900,
});
expect(url).toBeNull();
expect(presignCalls).toHaveLength(0);
});
});
@@ -16,6 +16,7 @@ import {
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
import {GuildResponse, GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {z} from 'zod';
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
import {createGuildID} from '../../BrandedTypes';
import {LoginRequired} from '../../middleware/AuthMiddleware';
@@ -49,6 +50,9 @@ export function GuildBaseController(app: HonoApp) {
if (policy.single_community_enabled) {
throw new SingleCommunityCannotCreateGuildsError();
}
if (!user.isUnclaimedAccount()) {
requireEmailVerified(user, 'guild_creation');
}
const auditLogReason = ctx.get('auditLogReason') ?? null;
const locale = ctx.get('requestLocale') ?? null;
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
@@ -98,13 +98,20 @@ export abstract class IGuildDiscoveryService {
}): Promise<{
guilds: Array<DiscoveryGuildResult>;
total: number;
category_counts: Array<DiscoveryCategoryCount>;
}>;
}
interface DiscoveryCategoryCount {
category_type: number;
count: number;
}
interface DiscoveryGuildResult {
id: string;
name: string;
icon: string | null;
banner: string | null;
description: string | null;
category_type: number;
primary_language: string | null;
@@ -115,6 +122,25 @@ interface DiscoveryGuildResult {
verification_level: number;
}
const DISCOVERY_CATEGORY_FACET = 'discoveryCategory';
function toDiscoveryCategoryCounts(
counts: Readonly<Record<string, number>> | undefined,
): Array<DiscoveryCategoryCount> {
if (!counts) {
return [];
}
const entries: Array<DiscoveryCategoryCount> = [];
for (const [key, count] of Object.entries(counts)) {
const categoryType = Number.parseInt(key, 10);
if (!Number.isInteger(categoryType) || count <= 0) {
continue;
}
entries.push({category_type: categoryType, count});
}
return entries.sort((left, right) => left.category_type - right.category_type);
}
export class GuildDiscoveryService extends IGuildDiscoveryService {
constructor(
private readonly discoveryRepository: IGuildDiscoveryRepository,
@@ -377,6 +403,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
}): Promise<{
guilds: Array<DiscoveryGuildResult>;
total: number;
category_counts: Array<DiscoveryCategoryCount>;
}> {
if (!this.guildSearchService) {
throw new FeatureTemporarilyDisabledError();
@@ -393,14 +420,23 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
sortBy,
sortOrder: 'desc',
};
const results = await this.guildSearchService.searchGuilds(params.query ?? '', filters, {
limit: params.limit,
offset: params.offset,
});
const categoryFacetFilters: GuildSearchFilters = {...filters, discoveryCategory: undefined};
const [results, categoryFacets] = await Promise.all([
this.guildSearchService.searchGuilds(params.query ?? '', filters, {
limit: params.limit,
offset: params.offset,
}),
this.guildSearchService.searchGuilds(params.query ?? '', categoryFacetFilters, {
limit: 0,
facets: [DISCOVERY_CATEGORY_FACET],
}),
]);
const categoryCounts = toDiscoveryCategoryCounts(categoryFacets.facetCounts?.[DISCOVERY_CATEGORY_FACET]);
const guilds: Array<DiscoveryGuildResult> = results.hits.map((hit) => ({
id: hit.id,
name: hit.name,
icon: hit.iconHash,
banner: hit.bannerHash,
description: hit.discoveryDescription,
category_type: hit.discoveryCategory ?? 0,
primary_language: hit.discoveryPrimaryLanguage ?? null,
@@ -429,7 +465,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
);
}
}
return {guilds, total};
return {guilds, total, category_counts: categoryCounts};
}
private async addDiscoverableFeature(guildId: GuildID): Promise<void> {
@@ -36,7 +36,6 @@ import type {
TemplateSerializedGuild,
} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
import {requireEmailVerified} from '../../../auth/EmailVerificationUtils';
import type {ChannelID, GuildID, RoleID, UserID} from '../../../BrandedTypes';
import {createChannelID, createGuildID, createRoleID, guildIdToRoleId} from '../../../BrandedTypes';
import type {IChannelRepository} from '../../../channel/IChannelRepository';
@@ -257,7 +256,6 @@ export class GuildOperationsService {
if (user.isUnclaimedAccount()) {
throw new UnclaimedAccountCannotCreateGuildsError();
}
requireEmailVerified(user, 'guild_creation');
const currentGuildCount = await this.guildRepository.countUserGuilds(user.id);
const ctx = createLimitMatchContext({user});
const maxGuilds = resolveLimitSafe(
@@ -120,6 +120,69 @@ describe('Discovery Search and Join', () => {
expect(found!.category_type).toBe(DiscoveryCategories.GAMING);
expect(found!.verification_level).toBe(GuildVerificationLevel.LOW);
});
test('should report category counts that ignore the selected category filter', async () => {
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
const gamingOwner = await createTestAccount(harness);
const gamingGuild = await createGuild(harness, gamingOwner.token, 'Counted Gaming Guild');
await setGuildMemberCount(harness, gamingGuild.id, 10);
await applyAndApprove(
harness,
gamingOwner.token,
admin.token,
gamingGuild.id,
'A gaming community for counting',
DiscoveryCategories.GAMING,
);
const musicOwner = await createTestAccount(harness);
const musicGuild = await createGuild(harness, musicOwner.token, 'Counted Music Guild');
await setGuildMemberCount(harness, musicGuild.id, 10);
await applyAndApprove(
harness,
musicOwner.token,
admin.token,
musicGuild.id,
'A music community for counting',
DiscoveryCategories.MUSIC,
);
const searcher = await createTestAccount(harness);
const filtered = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
.get(`/discovery/guilds?category=${DiscoveryCategories.GAMING}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(filtered.guilds.every((guild) => guild.category_type === DiscoveryCategories.GAMING)).toBe(true);
const gamingCount = filtered.category_counts.find((entry) => entry.category_type === DiscoveryCategories.GAMING);
const musicCount = filtered.category_counts.find((entry) => entry.category_type === DiscoveryCategories.MUSIC);
expect(gamingCount?.count).toBeGreaterThanOrEqual(1);
expect(musicCount?.count).toBeGreaterThanOrEqual(1);
});
test('should expose the guild banner hash in search results', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Bannered Guild');
await setGuildMemberCount(harness, guild.id, 10);
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
await applyAndApprove(
harness,
owner.token,
admin.token,
guild.id,
'A community with a banner',
DiscoveryCategories.GAMING,
);
const searcher = await createTestAccount(harness);
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
.get('/discovery/guilds')
.expect(HTTP_STATUS.OK)
.execute();
const found = results.guilds.find((entry) => entry.id === guild.id);
expect(found).toBeDefined();
expect(found).toHaveProperty('banner');
});
test('should not return pending guilds in search results', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Pending Guild');
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {describe, expect, it} from 'vitest';
import type {User} from '../../models/User';
import {GuildOperationsService} from '../services/data/GuildOperationsService';
const REACHED_GUILD_COUNT = new Error('reached guild count lookup');
function createServiceStoppingAtGuildCount(): GuildOperationsService {
const guildRepository = {
countUserGuilds: (): never => {
throw REACHED_GUILD_COUNT;
},
};
const unused = null as never;
return new GuildOperationsService(
guildRepository as never,
unused,
unused,
unused,
unused,
unused,
unused,
unused,
unused,
unused,
unused,
unused,
);
}
function createUser(overrides: {emailVerified: boolean; isBot?: boolean}): User {
return {
id: 1n,
isBot: overrides.isBot ?? false,
emailVerified: overrides.emailVerified,
isUnclaimedAccount: () => false,
} as unknown as User;
}
describe('guild creation email verification', () => {
it('does not gate guild creation on email verification inside the domain operation', async () => {
const service = createServiceStoppingAtGuildCount();
await expect(
service.createGuild({user: createUser({emailVerified: false}), data: {name: 'Stock Community'} as never}),
).rejects.toBe(REACHED_GUILD_COUNT);
});
it('reaches the same point for a verified user', async () => {
const service = createServiceStoppingAtGuildCount();
await expect(
service.createGuild({user: createUser({emailVerified: true}), data: {name: 'Stock Community'} as never}),
).rejects.toBe(REACHED_GUILD_COUNT);
});
it('still rejects bots before any guild count lookup', async () => {
const service = createServiceStoppingAtGuildCount();
await expect(
service.createGuild({
user: createUser({emailVerified: true, isBot: true}),
data: {name: 'Stock Community'} as never,
}),
).rejects.toMatchObject({code: APIErrorCodes.BOTS_CANNOT_CREATE_GUILDS});
});
});
@@ -72,7 +72,13 @@ export interface IStorageService {
destinationKey: string;
newContentType?: string;
}): Promise<void>;
getPresignedDownloadURL(params: {bucket: string; key: string; expiresIn?: number}): Promise<string>;
getPresignedDownloadURL(params: {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}): Promise<string>;
getPresignedUploadURL(params: {
bucket: string;
key: string;
@@ -241,14 +241,20 @@ export class StorageService implements IStorageService {
bucket,
key,
expiresIn = seconds('5 minutes'),
responseContentType,
responseContentDisposition,
}: {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
ResponseContentType: responseContentType,
ResponseContentDisposition: responseContentDisposition,
});
return getSignedUrl(this.presignClient, command, {expiresIn});
}
@@ -77,7 +77,6 @@ export type InstancePremiumMode = 'mirror' | 'everyone';
export interface InstancePolicyConfig {
single_community_enabled: boolean;
single_community_locked: boolean;
single_community_guild_id: string | null;
direct_messages_disabled: boolean;
direct_messages_locked: boolean;
@@ -396,7 +395,6 @@ function normalizeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = {
single_community_enabled: false,
single_community_locked: false,
single_community_guild_id: null,
direct_messages_disabled: false,
direct_messages_locked: false,
@@ -420,7 +418,6 @@ function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
}
return {
single_community_enabled: value.single_community_enabled === true,
single_community_locked: value.single_community_locked === true,
single_community_guild_id: normalizeNullableString(value.single_community_guild_id),
direct_messages_disabled: value.direct_messages_disabled === true,
direct_messages_locked: value.direct_messages_locked === true,
@@ -50,6 +50,37 @@ export class SingleCommunityService {
}
}
async ensureStockCommunity(params: {owner: User; name: string}): Promise<GuildID> {
const policy = await this.instanceConfigRepository.getInstancePolicyConfig();
const designatedGuildId = await this.findDesignatedGuild(policy.single_community_guild_id);
if (designatedGuildId != null) {
await this.instanceConfigRepository.setInstancePolicyConfig({
single_community_enabled: true,
single_community_guild_id: designatedGuildId.toString(),
});
return designatedGuildId;
}
return this.createStockCommunity(params);
}
private async findDesignatedGuild(rawGuildId: string | null): Promise<GuildID | null> {
if (!rawGuildId) {
return null;
}
let guildId: GuildID;
try {
guildId = createGuildID(BigInt(rawGuildId));
} catch {
return null;
}
try {
await this.guildDataService.getGuildSystem(guildId);
return guildId;
} catch {
return null;
}
}
async createStockCommunity(params: {owner: User; name: string}): Promise<GuildID> {
const guild = await this.guildDataService.createGuild({user: params.owner, data: {name: params.name}});
const guildId = createGuildID(BigInt(guild.id));
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import type {User} from '../../models/User';
import type {InstancePolicyConfig} from '../InstanceConfigRepository';
import {SingleCommunityService} from '../SingleCommunityService';
const EXISTING_GUILD_ID = '1234567890123456789';
const OWNER = {id: 42n} as unknown as User;
interface Harness {
service: SingleCommunityService;
written: Array<Partial<InstancePolicyConfig>>;
createdNames: Array<string>;
}
function createHarness(params: {designatedGuildId: string | null; designatedGuildExists: boolean}): Harness {
const written: Array<Partial<InstancePolicyConfig>> = [];
const createdNames: Array<string> = [];
let nextCreatedGuildId = 999n;
const instanceConfigRepository = {
getInstancePolicyConfig: async () => ({
single_community_enabled: false,
single_community_guild_id: params.designatedGuildId,
}),
setInstancePolicyConfig: async (patch: Partial<InstancePolicyConfig>) => {
written.push(patch);
},
};
const guildDataService = {
getGuildSystem: async () => {
if (!params.designatedGuildExists) {
throw new Error('unknown guild');
}
return {} as never;
},
createGuild: async ({data}: {data: {name: string}}) => {
createdNames.push(data.name);
nextCreatedGuildId += 1n;
return {id: nextCreatedGuildId.toString()} as never;
},
};
const service = new SingleCommunityService(
instanceConfigRepository as never,
guildDataService as never,
null as never,
);
return {service, written, createdNames};
}
describe('SingleCommunityService.ensureStockCommunity', () => {
it('reuses the designated community when it still exists', async () => {
const harness = createHarness({designatedGuildId: EXISTING_GUILD_ID, designatedGuildExists: true});
const guildId = await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
expect(guildId.toString()).toBe(EXISTING_GUILD_ID);
expect(harness.createdNames).toEqual([]);
expect(harness.written).toEqual([{single_community_enabled: true, single_community_guild_id: EXISTING_GUILD_ID}]);
});
it('creates a fresh community when the designated one was deleted', async () => {
const harness = createHarness({designatedGuildId: EXISTING_GUILD_ID, designatedGuildExists: false});
const guildId = await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
expect(guildId.toString()).not.toBe(EXISTING_GUILD_ID);
expect(harness.createdNames).toEqual(['Fluxer']);
});
it('creates a fresh community when the instance never designated one', async () => {
const harness = createHarness({designatedGuildId: null, designatedGuildExists: false});
await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
expect(harness.createdNames).toEqual(['Fluxer']);
});
it('creates a fresh community when the stored guild id is not a snowflake', async () => {
const harness = createHarness({designatedGuildId: 'not-a-snowflake', designatedGuildExists: true});
await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
expect(harness.createdNames).toEqual(['Fluxer']);
});
});
+15 -2
View File
@@ -29747,6 +29747,7 @@
"id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"},
"name": {"type": "string", "description": "Guild name"},
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild icon hash"},
"banner": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild banner hash"},
"description": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Discovery description"
@@ -29775,9 +29776,21 @@
},
"description": "Discovery guild results"
},
"total": {"type": "number", "description": "Total number of matching guilds"}
"total": {"type": "number", "description": "Total number of matching guilds"},
"category_counts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"category_type": {"type": "number", "description": "Discovery category type"},
"count": {"type": "number", "description": "Number of matching guilds in this category"}
},
"required": ["category_type", "count"]
},
"description": "Match counts per category for the current filters, ignoring the category filter"
}
},
"required": ["guilds", "total"]
"required": ["guilds", "total", "category_counts"]
},
"DonationCheckoutResponse": {
"type": "object",
@@ -2,6 +2,7 @@
import type {
ISearchAdapter as SchemaISearchAdapter,
SearchOptions as SchemaSearchOptions,
SearchResult as SchemaSearchResult,
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
@@ -18,9 +19,6 @@ export interface IGuildSearchService extends SchemaISearchAdapter<GuildSearchFil
searchGuilds(
query: string,
filters: GuildSearchFilters,
options?: {
limit?: number;
offset?: number;
},
options?: SchemaSearchOptions,
): Promise<SchemaSearchResult<SearchableGuild>>;
}
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
import type {
SearchOptions as SchemaSearchOptions,
SearchResult as SchemaSearchResult,
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import {
ElasticsearchGuildAdapter,
@@ -60,10 +63,7 @@ export class ElasticsearchGuildSearchService
searchGuilds(
query: string,
filters: GuildSearchFilters,
options?: {
limit?: number;
offset?: number;
},
options?: SchemaSearchOptions,
): Promise<SchemaSearchResult<SearchableGuild>> {
return this.search(query, filters, options);
}
@@ -23,6 +23,7 @@ interface MeilisearchSearchResponse<TResult> {
totalHits?: number;
limit?: number;
offset?: number;
facetDistribution?: Record<string, Record<string, number>>;
}
export class MeilisearchIndexAdapter<
@@ -139,6 +140,7 @@ export class MeilisearchIndexAdapter<
const limit = Math.min(Math.max(requestedLimit, 0), MAX_SEARCH_LIMIT);
const offset = options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? 25) : 0);
const filter = joinMeiliFilters(this.buildFilters(filters));
const facets = options?.facets;
const result = await this.client.request<MeilisearchSearchResponse<TResult>>(
'POST',
`/indexes/${encodeURIComponent(this.indexDefinition.uid)}/search`,
@@ -150,11 +152,13 @@ export class MeilisearchIndexAdapter<
sort: this.buildSort?.(filters),
attributesToSearchOn: this.indexDefinition.searchableAttributes,
showRankingScore: false,
...(facets && facets.length > 0 ? {facets} : {}),
},
);
return {
hits: result.hits,
total: result.totalHits ?? result.estimatedTotalHits ?? result.hits.length,
...(result.facetDistribution ? {facetCounts: result.facetDistribution} : {}),
};
}
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {SearchResult as SchemaSearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
import type {
SearchOptions as SchemaSearchOptions,
SearchResult as SchemaSearchResult,
} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
import type {
AuditLogSearchFilters,
GuildMemberSearchFilters,
@@ -171,10 +174,7 @@ export class MeilisearchGuildSearchService
searchGuilds(
query: string,
filters: GuildSearchFilters,
options?: {
limit?: number;
offset?: number;
},
options?: SchemaSearchOptions,
): Promise<SchemaSearchResult<SearchableGuild>> {
return this.search(query, filters, options);
}
@@ -115,15 +115,3 @@ export function getFirstInvoicePaymentIntentId(invoice: Stripe.Invoice | null):
}
return paymentIntent.id ?? null;
}
export function getFirstInvoicePaymentIntentLatestChargeId(invoice: Stripe.Invoice | null): string | null {
const paymentIntent = getFirstInvoicePaymentIntent(invoice);
if (!paymentIntent || typeof paymentIntent === 'string') {
return null;
}
const latestCharge = paymentIntent.latest_charge ?? null;
if (typeof latestCharge === 'string') {
return latestCharge;
}
return latestCharge?.id ?? null;
}
@@ -21,7 +21,6 @@ import {mapUserToPrivateResponse} from '../../user/UserMappers';
import type {ProductInfo, ProductRegistry} from '../ProductRegistry';
import {
getFirstInvoicePaymentIntentId,
getFirstInvoicePaymentIntentLatestChargeId,
getPrimarySubscriptionItem,
getSubscriptionItemPeriodEnd,
getSubscriptionPremiumPeriodEnd,
@@ -600,11 +599,16 @@ export class StripeCheckoutWebhookHandler {
if (!this.stripe) return null;
try {
const subscription = await this.stripe.subscriptions.retrieve(subscriptionId, {
expand: ['latest_invoice.payments.data.payment.payment_intent.latest_charge'],
expand: ['latest_invoice.payments.data.payment'],
});
const latestInvoice =
typeof subscription.latest_invoice === 'string' ? null : (subscription.latest_invoice ?? null);
return getFirstInvoicePaymentIntentLatestChargeId(latestInvoice);
const paymentIntentId = getFirstInvoicePaymentIntentId(latestInvoice);
if (!paymentIntentId) {
return null;
}
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId);
return extractId(paymentIntent.latest_charge);
} catch (err) {
Logger.warn({err, subscriptionId}, 'Failed to resolve latest charge for duplicate-subscription refund');
return null;
@@ -739,7 +743,7 @@ export class StripeCheckoutWebhookHandler {
};
try {
const subscription = (await this.stripe.subscriptions.retrieve(subscriptionId, {
expand: ['default_payment_method', 'latest_invoice.payments.data.payment.payment_intent'],
expand: ['default_payment_method', 'latest_invoice.payments.data.payment'],
})) as StripeSubscriptionWithFallbackPaymentState;
const latestInvoice =
typeof subscription.latest_invoice === 'string' ? null : (subscription.latest_invoice ?? null);
@@ -39,7 +39,6 @@ interface RefundTarget {
type StripeInvoiceWithPayments = Stripe.Invoice & {
customer?: string | Stripe.Customer | null;
subscription?: string | Stripe.Subscription | null;
payments?: {
data?: Array<{
payment?: {
@@ -54,6 +53,10 @@ type StripeInvoiceWithPayments = Stripe.Invoice & {
} | null;
};
function resolveInvoiceSubscriptionId(invoice: Stripe.Invoice): string | null {
return extractId(invoice.parent?.subscription_details?.subscription ?? null);
}
function getInvoicePaymentRef(invoice: Stripe.Invoice): {
chargeId: string | null;
paymentIntentId: string | null;
@@ -113,7 +116,7 @@ export class StripeRefundService {
const list = await this.stripe.invoices.list({
customer: user.stripeCustomerId,
limit: 5,
expand: ['data.payments.data.payment.payment_intent'],
expand: ['data.payments.data.payment'],
});
for (const invoice of list.data) {
if (!invoice.id || invoice.status !== 'paid' || invoice.amount_paid <= 0) {
@@ -132,7 +135,7 @@ export class StripeRefundService {
chargeId: ref.chargeId,
paymentIntentId: ref.paymentIntentId,
paidAt,
subscriptionId: extractId((invoice as StripeInvoiceWithPayments).subscription),
subscriptionId: resolveInvoiceSubscriptionId(invoice),
};
}
} catch (error) {
@@ -260,37 +260,13 @@ export class StripeSubscriptionReconciler {
}
getPriceIdFromInvoice(invoice: Stripe.Invoice): string | null {
type InvoiceLineWithPrice = Stripe.InvoiceLineItem & {
price?: string | Stripe.Price | null;
pricing?: {
price_details?: {
price?: string;
};
};
parent?: {
subscription_item_details?: {
price?: {
price?: string;
};
};
};
};
if (!invoice.lines?.data?.length) {
return null;
}
for (const line of invoice.lines.data) {
const lineWithPrice = line as InvoiceLineWithPrice;
const directPriceId = extractId(lineWithPrice.price);
if (directPriceId) {
return directPriceId;
}
const nestedPriceId = lineWithPrice.pricing?.price_details?.price;
if (nestedPriceId) {
return extractId(nestedPriceId);
}
const parentNestedPriceId = lineWithPrice.parent?.subscription_item_details?.price?.price;
if (parentNestedPriceId) {
return extractId(parentNestedPriceId);
const priceId = extractId(line.pricing?.price_details?.price ?? null);
if (priceId) {
return priceId;
}
}
return null;
@@ -328,39 +304,15 @@ export class StripeSubscriptionReconciler {
}
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
type InvoiceWithSubscription = Stripe.Invoice & {
subscription?: string | Stripe.Subscription;
};
const invoiceWithSubscription = invoice as InvoiceWithSubscription;
const directSubscription = invoiceWithSubscription.subscription;
if (directSubscription) {
return extractId(directSubscription);
}
type InvoiceWithParent = Stripe.Invoice & {
parent?: {
subscription_details?: {
subscription?: string;
};
};
};
type InvoiceLineWithParent = Stripe.InvoiceLineItem & {
parent?: {
subscription_item_details?: {
subscription?: string;
};
};
};
const invoiceWithParent = invoice as InvoiceWithParent;
const parentSubscription = invoiceWithParent.parent?.subscription_details?.subscription;
const parentSubscription = extractId(invoice.parent?.subscription_details?.subscription ?? null);
if (parentSubscription) {
return extractId(parentSubscription);
return parentSubscription;
}
if (invoice.lines?.data?.length) {
for (const line of invoice.lines.data) {
const lineWithParent = line as InvoiceLineWithParent;
const subscriptionId = lineWithParent.parent?.subscription_item_details?.subscription;
const subscriptionId = line.parent?.subscription_item_details?.subscription ?? null;
if (subscriptionId) {
return extractId(subscriptionId);
return subscriptionId;
}
}
}
@@ -150,7 +150,7 @@ export class StripeWebhookService {
case 'checkout.session.completed': {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
await this.safeMirrorUpsert(event, () =>
this.billingRepository.checkoutSessions.upsertFromStripe(checkoutSession),
this.billingRepository.checkoutSessions.upsertFromStripe(checkoutSession, {eventCreated: event.created}),
);
if (checkoutSession.metadata?.verification_type === 'uk_age_verification' && this.ageVerificationService) {
await this.ageVerificationService.completeVerification(checkoutSession);
@@ -165,44 +165,48 @@ export class StripeWebhookService {
}
case 'checkout.session.async_payment_succeeded': {
const cs = event.data.object as Stripe.Checkout.Session;
await this.safeMirrorUpsert(event, () => this.billingRepository.checkoutSessions.upsertFromStripe(cs));
await this.safeMirrorUpsert(event, () =>
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
);
await this.checkoutHandler.handleAsyncPaymentSucceeded(cs);
break;
}
case 'checkout.session.async_payment_failed': {
const cs = event.data.object as Stripe.Checkout.Session;
await this.safeMirrorUpsert(event, () => this.billingRepository.checkoutSessions.upsertFromStripe(cs));
await this.safeMirrorUpsert(event, () =>
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
);
await this.checkoutHandler.handleAsyncPaymentFailed(cs);
break;
}
case 'invoice.paid':
case 'invoice.payment_succeeded': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoicePaymentSucceeded(event.id, inv);
break;
}
case 'invoice.payment_failed': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoicePaymentFailed(inv);
break;
}
case 'invoice.payment_action_required': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoicePaymentActionRequired(inv);
break;
}
case 'invoice.finalization_failed': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoiceFinalizationFailed(inv);
break;
}
case 'invoice.updated': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
await this.subscriptionHandler.handleInvoiceUpdated(inv);
break;
}
@@ -253,11 +257,12 @@ export class StripeWebhookService {
case 'charge.refunded': {
const c = event.data.object as Stripe.Charge;
await this.safeMirrorUpsert(event, () => this.billingRepository.charges.upsertFromStripe(c));
const refunds = c.refunds?.data ?? [];
const refunds = await this.listChargeRefunds(c);
for (const r of refunds) {
await this.safeMirrorUpsert(event, () =>
this.billingRepository.refunds.upsertFromStripe(r, {
customerId: typeof c.customer === 'string' ? c.customer : (c.customer?.id ?? undefined),
livemode: event.livemode,
}),
);
}
@@ -324,7 +329,13 @@ export class StripeWebhookService {
case 'refund.updated':
case 'refund.failed': {
const r = event.data.object as Stripe.Refund;
await this.safeMirrorUpsert(event, () => this.billingRepository.refunds.upsertFromStripe(r));
const customerId = await this.resolveRefundCustomerId(r);
await this.safeMirrorUpsert(event, () =>
this.billingRepository.refunds.upsertFromStripe(r, {
customerId: customerId ?? undefined,
livemode: event.livemode,
}),
);
break;
}
case 'invoice.created':
@@ -332,12 +343,14 @@ export class StripeWebhookService {
case 'invoice.voided':
case 'invoice.marked_uncollectible': {
const inv = event.data.object as Stripe.Invoice;
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(inv));
await this.mirrorInvoice(event, inv);
break;
}
case 'checkout.session.expired': {
const cs = event.data.object as Stripe.Checkout.Session;
await this.safeMirrorUpsert(event, () => this.billingRepository.checkoutSessions.upsertFromStripe(cs));
await this.safeMirrorUpsert(event, () =>
this.billingRepository.checkoutSessions.upsertFromStripe(cs, {eventCreated: event.created}),
);
break;
}
case 'charge.dispute.updated':
@@ -353,6 +366,54 @@ export class StripeWebhookService {
}
}
private async resolveRefundCustomerId(refund: Stripe.Refund): Promise<string | null> {
const chargeId = typeof refund.charge === 'string' ? refund.charge : (refund.charge?.id ?? null);
if (chargeId !== null) {
const chargeRow = await this.billingRepository.charges.findById(chargeId);
if (chargeRow?.customer_id != null) {
return chargeRow.customer_id;
}
}
const paymentIntentId =
typeof refund.payment_intent === 'string' ? refund.payment_intent : (refund.payment_intent?.id ?? null);
if (paymentIntentId !== null) {
const paymentIntentRow = await this.billingRepository.paymentIntents.findById(paymentIntentId);
if (paymentIntentRow?.customer_id != null) {
return paymentIntentRow.customer_id;
}
}
return null;
}
private async listChargeRefunds(charge: Stripe.Charge): Promise<Array<Stripe.Refund>> {
const inlined = charge.refunds?.data ?? [];
if (inlined.length > 0 || charge.id == null || this.stripe == null) {
return inlined;
}
try {
const listed = await this.stripe.refunds.list({charge: charge.id, limit: 100});
return listed.data;
} catch (listErr) {
Logger.warn({listErr, chargeId: charge.id}, 'Failed to list refunds for charge; skipping refund mirror');
return [];
}
}
private async mirrorInvoice(event: Stripe.Event, inv: Stripe.Invoice): Promise<void> {
let hydrated = inv;
if (inv.payments === undefined && inv.id != null && this.stripe != null) {
try {
hydrated = await this.stripe.invoices.retrieve(inv.id, {expand: ['payments.data.payment']});
} catch (hydrateErr) {
Logger.warn(
{hydrateErr, eventId: event.id, invoiceId: inv.id},
'Failed to hydrate invoice payments; mirroring invoice without payment rows',
);
}
}
await this.safeMirrorUpsert(event, () => this.billingRepository.invoices.upsertFromStripe(hydrated));
}
private async safeMirrorUpsert(event: Stripe.Event, fn: () => Promise<unknown>): Promise<void> {
try {
await fn();
@@ -22,7 +22,7 @@ interface MockStripeInvoice {
id: string;
object: 'invoice';
customer: string;
subscription: string | null;
parent: {subscription_details: {subscription: string}} | null;
amount_due: number;
amount_paid: number;
currency: string;
@@ -66,7 +66,7 @@ function buildInvoice(opts: {
id: opts.id,
object: 'invoice',
customer: customerId,
subscription: subscriptionId,
parent: subscriptionId == null ? null : {subscription_details: {subscription: subscriptionId}},
amount_due: 2500,
amount_paid: 2500,
currency: 'usd',
@@ -166,7 +166,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -217,7 +217,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -252,7 +252,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: invoiceId,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -300,7 +300,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_create',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -351,7 +351,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -372,7 +372,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -422,7 +422,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_cycle',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
},
},
};
@@ -460,7 +460,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_update',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
amount_paid: 0,
amount_due: 0,
total: 0,
@@ -503,7 +503,7 @@ describe('Stripe Webhook - Invoice Events', () => {
object: {
id: `in_test_${Date.now()}`,
billing_reason: 'subscription_update',
subscription: subscriptionId,
parent: {subscription_details: {subscription: subscriptionId}},
amount_paid: 1250,
amount_due: 1250,
total: 1250,
@@ -305,7 +305,13 @@ export class MockStorageService implements IStorageService {
await this.deleteObject(params.sourceBucket, params.sourceKey);
}
async getPresignedDownloadURL(_params: {bucket: string; key: string; expiresIn?: number}): Promise<string> {
async getPresignedDownloadURL(_params: {
bucket: string;
key: string;
expiresIn?: number;
responseContentType?: string;
responseContentDisposition?: string;
}): Promise<string> {
this.getPresignedDownloadURLSpy(_params);
return 'https://presigned.url/test';
}
@@ -59,7 +59,7 @@ interface StripeApiMockConfig {
subscriptionsListEmpty?: boolean;
charges?: Record<string, Partial<MockStripeCharge>>;
customers?: Record<string, Partial<MockStripeCustomer>>;
invoices?: Record<string, Partial<MockStripeInvoice>>;
invoices?: Record<string, Partial<MockStripeInvoice> & {subscriptionId?: string}>;
paymentIntents?: Record<string, Partial<MockStripePaymentIntent>>;
paymentMethods?: Record<string, Partial<MockStripePaymentMethod>>;
setupIntents?: Record<string, Partial<MockStripeSetupIntent>>;
@@ -284,7 +284,10 @@ interface MockStripeInvoice {
url: string;
};
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void' | null;
subscription: string | null;
parent: {
type: 'subscription_details';
subscription_details: {subscription: string};
} | null;
}
interface MockStripeValueList {
@@ -571,13 +574,14 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
});
}
for (const [invoiceId, overrides] of Object.entries(config.invoices ?? {})) {
const {subscriptionId, ...invoiceOverrides} = overrides;
const defaultInvoice = createDefaultInvoice(invoiceId, {
customerId: overrides.customer ?? 'cus_test_1',
subscriptionId: overrides.subscription ?? 'sub_test_1',
subscriptionId: subscriptionId ?? 'sub_test_1',
});
invoiceStore.set(invoiceId, {
...defaultInvoice,
...overrides,
...invoiceOverrides,
id: invoiceId,
object: 'invoice',
payments: overrides.payments ?? defaultInvoice.payments,
@@ -739,7 +743,10 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
url: `/v1/invoices/${id}/payments`,
},
status: 'paid',
subscription: subscriptionId,
parent:
subscriptionId === null
? null
: {type: 'subscription_details', subscription_details: {subscription: subscriptionId}},
};
}
function getPaymentIntent(paymentIntentId: string): MockStripePaymentIntent {
@@ -838,8 +845,6 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
object: 'subscription',
customer: subState.customer,
status: subState.status,
current_period_start: subState.current_period_start,
current_period_end: subState.current_period_end,
latest_invoice: subState.latest_invoice ? getInvoice(subState.latest_invoice) : null,
trial_end: subState.trial_end,
items: {
@@ -1070,7 +1075,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
const limit = Number.parseInt(requestUrl.searchParams.get('limit') ?? '10', 10);
const sortedInvoices = [...invoiceStore.values()]
.filter((invoice) => !customerId || invoice.customer === customerId)
.filter((invoice) => !subscriptionId || invoice.subscription === subscriptionId)
.filter((invoice) => !subscriptionId || invoice.parent?.subscription_details?.subscription === subscriptionId)
.sort((left, right) => right.created - left.created);
const startIndex = startingAfter ? sortedInvoices.findIndex((invoice) => invoice.id === startingAfter) + 1 : 0;
const pagedInvoices = sortedInvoices.slice(Math.max(startIndex, 0), Math.max(startIndex, 0) + limit);
@@ -1460,7 +1465,6 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
object: 'subscription',
customer,
status: 'active',
current_period_start: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60,
trial_end: null,
items: {
object: 'list',
@@ -1482,6 +1486,7 @@ export function createStripeApiHandlers(config: StripeApiMockConfig = {}): Strip
livemode: false,
},
quantity: 1,
current_period_start: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60,
current_period_end: currentPeriodEnd,
},
],
@@ -1879,11 +1884,13 @@ export function createInvoicePaidEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
amount_paid: options.amountPaid ?? 2500,
currency: options.currency ?? 'usd',
status: 'paid',
paid: true,
},
},
};
@@ -1902,7 +1909,10 @@ export function createInvoicePaymentFailedEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
amount_due: options.amountDue ?? 2500,
status: 'open',
paid: false,
@@ -1926,7 +1936,10 @@ export function createInvoicePaymentActionRequiredEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
amount_due: options.amountDue ?? 2500,
status: 'open',
paid: false,
@@ -1950,7 +1963,10 @@ export function createInvoiceFinalizationFailedEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
status: 'draft',
paid: false,
last_finalization_error: {
@@ -1980,7 +1996,10 @@ export function createInvoiceUpdatedEvent(options: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
parent: {
type: 'subscription_details',
subscription_details: {subscription: options.subscriptionId ?? 'sub_test_1'},
},
amount_due: options.amountDue ?? 2500,
status: options.status ?? 'open',
paid: options.paid ?? false,
@@ -163,13 +163,34 @@ class InMemorySearchServiceBase<TFilters, TDocument extends SearchableDocument>
return trimmed.length === 0 || stringArrayContainsText(this.collectText(doc).filter(isString), trimmed);
})
.sort((left, right) => this.sortDocuments(left, right, filters, query));
const facets = options?.facets;
return {
hits: paginate(hits, options),
total: hits.length,
...(facets && facets.length > 0 ? {facetCounts: countFacets(hits, facets)} : {}),
};
}
}
function countFacets<TDocument>(docs: Array<TDocument>, facets: Array<string>): Record<string, Record<string, number>> {
const counts: Record<string, Record<string, number>> = {};
for (const facet of facets) {
const facetCounts: Record<string, number> = {};
for (const doc of docs) {
const value = (doc as Record<string, unknown>)[facet];
if (value == null) {
continue;
}
for (const entry of Array.isArray(value) ? value : [value]) {
const key = String(entry);
facetCounts[key] = (facetCounts[key] ?? 0) + 1;
}
}
counts[facet] = facetCounts;
}
return counts;
}
function isString(value: string | null): value is string {
return value !== null;
}
@@ -452,7 +473,7 @@ class InMemoryGuildSearchService
await this.deleteDocuments(guildIds.map((id) => id.toString()));
}
searchGuilds(query: string, filters: GuildSearchFilters, options?: {limit?: number; offset?: number}) {
searchGuilds(query: string, filters: GuildSearchFilters, options?: SearchOptions) {
return this.search(query, filters, options);
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ const client: HttpClient = createHttpClient({
});
const scopedClients = new Map<RequestUrlPolicy, Map<number, HttpClient>>();
export interface SendRequestOptions {
interface SendRequestOptions {
maxRedirects?: number;
requestUrlPolicy?: RequestUrlPolicy;
}
+1
View File
@@ -23,6 +23,7 @@
<!--{{FLUXER_BOOTSTRAP}}-->
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -200,6 +200,7 @@
"@radix-ui/react-switch": "catalog:",
"@simplewebauthn/browser": "catalog:",
"@tanstack/react-virtual": "^3.13.23",
"animejs": "4.5.0",
"bowser": "catalog:",
"clsx": "catalog:",
"codemirror": "^6.0.2",
@@ -2,6 +2,7 @@
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {dirname, join, relative, resolve} from 'node:path';
import {SKELETON_SURFACE_TOKENS} from '@app/features/app/components/skeleton/SkeletonSurfaceContract';
type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
@@ -407,6 +408,11 @@ function renderValueMap(name: string, values: ReadonlyMap<string, string>): stri
return `export const ${name}: Readonly<Record<string, string>> = {\n${body}\n};`;
}
function renderNameUnion(definitions: ReadonlyArray<VariableDefinition>): string {
const body = definitions.map((definition) => `\t| ${JSON.stringify(definition.name)}`).join('\n');
return `export type ThemeVariableName =\n${body};`;
}
function renderDefinitions(definitions: ReadonlyArray<VariableDefinition>): string {
const body = definitions
.map(
@@ -439,6 +445,8 @@ export interface ThemeVariableDefinition {
\tsource: string;
}
${renderNameUnion(definitions)}
${renderDefinitions(definitions)}
${renderStringArray(
@@ -456,9 +464,361 @@ ${renderValueMap('THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES', lightDefaults)}
`;
}
interface SkeletonSurfaceInvariant {
file: string;
requires?: ReadonlyArray<string>;
requiresPattern?: ReadonlyArray<string>;
forbids?: ReadonlyArray<string>;
forbidsPattern?: ReadonlyArray<string>;
counts?: Readonly<Record<string, number>>;
minimums?: Readonly<Record<string, number>>;
}
const SKELETON_CHROME_BORDER_TOKEN = 'var(--skeleton-chrome-border)';
const SKELETON_SURFACE_INVARIANTS: ReadonlyArray<SkeletonSurfaceInvariant> = [
{
file: 'src/app/globals.css',
counts: {
'--skeleton-chrome-border-color': 2,
'--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);': 1,
'--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);': 1,
'--chat-horizontal-padding-default: 1rem;': 1,
'--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);': 1,
'--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);': 1,
},
},
{
file: 'src/features/app/components/skeleton/ChatSkeleton.module.css',
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 1},
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
forbids: ['scopeBadge', 'scope-badge'],
},
{
file: 'src/features/app/components/skeleton/ChatSkeleton.tsx',
requires: [
"import composerWrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';",
'composerWrapperStyles.composerRoot',
"'--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx),",
"'--font-size': remFromPx(messagePresentation.fontSizePx),",
"'--message-group-spacing': remFromPx(messagePresentation.groupSpacingPx),",
"'--message-compact-timestamp-width': remFromPx(messagePresentation.compactTimestampWidthPx),",
],
forbids: ['scopeBadge', 'scope-badge', '@tanstack/react-virtual'],
},
{
file: 'src/features/app/components/skeleton/DiscoverySkeleton.module.css',
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 2},
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
},
{
file: 'src/features/app/components/skeleton/FriendsSkeleton.module.css',
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 4},
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
forbids: ['.activeNowPreview'],
},
{
file: 'src/features/app/components/skeleton/FriendsSkeleton.tsx',
forbids: ['LIVE_BADGE', 'CONTEXT_CHEVRON', 'ACTIVE_NOW_CONTEXT_ICON_SIZE'],
requires: ['function resolveFriendsListSections('],
},
{
file: 'src/features/app/components/skeleton/GuildRailSkeleton.module.css',
requires: [
'.outageSlot {\n\tmin-height: var(--guild-list-item-box-size);\n}',
'.sectionTrailingGap {\n\tpadding-bottom: var(--guild-list-item-gap);\n}',
'.itemsTrailingGapCancel {\n\tmargin-bottom: calc(-1 * var(--guild-list-item-gap));\n}',
'background-color: var(--guilds-layout-item-bg);',
".item[data-selected='true'] .fluxerIcon",
'clip-path: inset(0);',
'\ttransform: translateY(0rem);',
],
forbids: ['--guild-list-indicator-', '::before'],
counts: {'z-index: 0;': 3, 'z-index: 1;': 3},
},
{
file: 'src/features/app/components/skeleton/GuildRailSkeleton.tsx',
requires: [
"import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css';",
'guildStyles.guildIndicator',
'guildStyles.guildIndicatorBar',
'resolveGuildListIndicatorBarTarget(',
'const InlineDMPlaceholder = ',
],
requiresPattern: ['<ChatCircleIcon[^>]*weight="fill"[^>]*className=\\{styles\\.fluxerIconGlyph\\}'],
forbids: ['data-indicator', 'GUILD_RAIL_INDICATOR_METRICS', 'Math.round'],
counts: {'styles.sectionTrailingGap': 2, 'styles.itemsTrailingGapCancel': 1},
},
{
file: 'src/features/ui/components/Scroller.module.css',
counts: {'clip-path: inset(0);': 1},
},
{
file: 'src/features/channel/components/MemberListSkeleton.tsx',
counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2},
},
{
file: 'src/features/channel/components/textarea/InputWrapper.module.css',
requires: ['.composerRoot:has(.statusTypingSlot)::before'],
},
{
file: 'src/features/app/components/layout/GuildsLayout.module.css',
requires: [
'.guildListScrollContainer.guildListScrollContainer {\n\toverflow-anchor: auto;\n}',
'--layout-user-area-overlay-height',
'\tpadding-bottom: calc(\n\t\tvar(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +\n\t\tvar(--spacing-2)\n\t);',
'.messageBubbleIcon {\n\theight: 1.75rem;\n\twidth: 1.75rem;',
'.guildIndicator {',
'.guildIndicatorBar {',
],
forbids: ['.fluxerSymbolIcon'],
},
{
file: 'src/features/app/components/layout/GuildsLayout.tsx',
forbids: ['useListScrollAnchor', 'usePersistentScrollAnchor', '@tanstack/react-virtual'],
counts: {'scrollNode.scrollTop = ': 2},
},
{
file: 'src/features/discovery/discovery/DiscoveryPage.tsx',
counts: {"import {useVirtualizer} from '@tanstack/react-virtual';": 1},
},
{
file: 'src/features/app/components/LongPressable.ts',
forbids: ['useImperativeHandle'],
requires: ['useMergeRefs'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildListIndicator.ts',
requires: ['height: remFromPx(resolveGuildListIndicatorHeight(request))'],
forbids: ['transform', 'scale', 'getAppZoomFactor', 'getAppRemScale'],
counts: {
'function resolveGuildListIndicatorHeight': 1,
'export function resolveGuildListIndicatorBarTarget': 1,
'export ': 3,
},
},
{
file: 'src/features/app/components/layout/sidebar_nav/FluxerButton.tsx',
requires: [
'const shouldShowHoverState = isHovering || contextMenuOpen;',
'ChatCircleIcon',
'styles.messageBubbleIcon',
'styles.contextMenuHover',
'resolveGuildListIndicatorBarTarget(',
],
forbids: ['scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/FavoritesButton.tsx',
requires: ['resolveGuildListIndicatorBarTarget('],
forbids: ['scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx',
requires: [
'const shouldShowHoverState = isHovering || contextMenuOpen;',
'guildStyles.contextMenuHover',
'resolveGuildListIndicatorBarTarget(',
],
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx',
requires: [
'const shouldShowHoverState = isHovering || contextMenuOpen;',
'const FOLDER_BACKGROUND_FADE_TARGET = Object.freeze({opacity: 0});',
"const FOLDER_GUILDS_COLLAPSE_TARGET = Object.freeze({opacity: 0, translateY: '-0.5rem'});",
"to={{opacity: 1, translateY: '0rem'}}",
'resolveGuildListIndicatorBarTarget(',
'guildStyles.guildIndicatorBar',
],
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css',
forbids: ['.folderIndicator'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemContent.tsx',
requires: [
'const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.GUILD_RAIL);',
'if (isInitialMount || selectedFromThisRow || !props.isSelected) return;',
],
forbids: ['preserveInitialScrollPosition', '@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx',
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
},
{
file: 'src/features/channel/components/ChannelMembers.tsx',
forbids: ['@tanstack/react-virtual'],
requires: ['styles.virtualRow'],
},
];
const SKELETON_STYLESHEET_DIRECTORIES: ReadonlyArray<string> = [
'src/features/app/components/skeleton',
'src/features/channel/components',
'src/features/user/components/profile',
];
const SKELETON_STYLESHEET_SUFFIX = 'Skeleton.module.css';
const BORDER_DECLARATION_PATTERN =
/(?<![\w-])border(?:-(?:top|right|bottom|left|block|inline|block-start|block-end|inline-start|inline-end))?\s*:\s*([^;}]+)/g;
const RAW_COLOR_PATTERN = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\(/;
function collectSkeletonStylesheets(appDir: string): ReadonlyArray<string> {
const files: Array<string> = [];
const visit = (directory: string) => {
for (const entry of readdirSync(directory, {withFileTypes: true})) {
const absolutePath = join(directory, entry.name);
if (entry.isDirectory()) {
visit(absolutePath);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(SKELETON_STYLESHEET_SUFFIX)) continue;
files.push(toPosixPath(relative(appDir, absolutePath)));
}
};
for (const directory of SKELETON_STYLESHEET_DIRECTORIES) {
visit(join(appDir, directory));
}
return files.sort();
}
interface SkeletonSharedRuleSource {
readonly file: string;
readonly selectors: ReadonlyArray<string>;
}
const SKELETON_SHARED_RULE_SOURCES: ReadonlyArray<SkeletonSharedRuleSource> = [
{
file: 'src/features/app/components/layout/GuildsLayout.module.css',
selectors: ['.guildIndicator', '.guildIndicatorBar'],
},
];
const CUSTOM_PROPERTY_REFERENCE_PATTERN = /var\(\s*(--[a-zA-Z0-9-]+)/g;
function assertSkeletonSharedRuleTokens(appDir: string): void {
const violations: Array<string> = [];
const declaredTokens = new Set<string>(SKELETON_SURFACE_TOKENS);
for (const source of SKELETON_SHARED_RULE_SOURCES) {
const contents = readFileSync(join(appDir, source.file), 'utf8');
for (const selector of source.selectors) {
const rule = new RegExp(`^${selector.replace('.', '\\.')}\\s*\\{([^}]*)\\}`, 'mu').exec(contents);
if (rule == null) {
violations.push(`${source.file} must declare ${selector}; skeleton markup renders through that rule.`);
continue;
}
for (const match of rule[1].matchAll(CUSTOM_PROPERTY_REFERENCE_PATTERN)) {
const token = match[1];
if (declaredTokens.has(token)) continue;
violations.push(
`${source.file} ${selector} reads ${token}, which skeleton markup also renders through; ` +
'list it in SKELETON_SURFACE_TOKENS.',
);
}
}
}
if (violations.length > 0) {
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
}
}
const SKELETON_CHROME_BORDER_COLOR_DECLARATION = /--skeleton-chrome-border-color\s*:\s*([^;}]+)/g;
function assertSkeletonChromeBorderColourIsSoftened(appDir: string, violations: Array<string>): void {
const file = 'src/app/globals.css';
const contents = readFileSync(join(appDir, file), 'utf8');
for (const match of contents.matchAll(SKELETON_CHROME_BORDER_COLOR_DECLARATION)) {
const value = match[1].trim();
if (value.startsWith('var(--background-modifier-accent')) {
violations.push(
`${file} declares --skeleton-chrome-border-color as ${JSON.stringify(value)}; ` +
'skeleton chrome must use the softened colour, never the raw accent.',
);
}
}
}
function assertSkeletonBorderTokens(appDir: string): void {
const violations: Array<string> = [];
assertSkeletonChromeBorderColourIsSoftened(appDir, violations);
for (const file of collectSkeletonStylesheets(appDir)) {
const contents = readFileSync(join(appDir, file), 'utf8');
if (contents.includes('--skeleton-chrome-border-color')) {
violations.push(
`${file} names --skeleton-chrome-border-color directly; skeleton chrome must use ${SKELETON_CHROME_BORDER_TOKEN}.`,
);
}
for (const match of contents.matchAll(BORDER_DECLARATION_PATTERN)) {
const value = match[1].trim();
if (RAW_COLOR_PATTERN.test(value) || value.includes('--background-modifier-accent')) {
violations.push(
`${file} writes a literal border colour in ${JSON.stringify(`border: ${value}`)}; ` +
'skeleton borders must reference a shared token.',
);
}
}
}
if (violations.length > 0) {
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
}
}
function assertSkeletonSurfaceInvariants(appDir: string): void {
const violations: Array<string> = [];
for (const invariant of SKELETON_SURFACE_INVARIANTS) {
const path = join(appDir, invariant.file);
if (!existsSync(path)) {
violations.push(`${invariant.file} is missing; a skeleton surface invariant depends on it.`);
continue;
}
const contents = readFileSync(path, 'utf8');
for (const required of invariant.requires ?? []) {
if (!contents.includes(required)) {
violations.push(`${invariant.file} must contain ${JSON.stringify(required)}.`);
}
}
for (const forbidden of invariant.forbids ?? []) {
if (contents.includes(forbidden)) {
violations.push(`${invariant.file} must not contain ${JSON.stringify(forbidden)}.`);
}
}
for (const source of invariant.requiresPattern ?? []) {
if (!new RegExp(source, 'u').test(contents)) {
violations.push(`${invariant.file} must match /${source}/.`);
}
}
for (const source of invariant.forbidsPattern ?? []) {
if (new RegExp(source, 'u').test(contents)) {
violations.push(`${invariant.file} must not match /${source}/.`);
}
}
for (const [needle, expected] of Object.entries(invariant.counts ?? {})) {
const actual = contents.split(needle).length - 1;
if (actual !== expected) {
violations.push(`${invariant.file} must contain ${needle} exactly ${expected} time(s); found ${actual}.`);
}
}
for (const [needle, minimum] of Object.entries(invariant.minimums ?? {})) {
const actual = contents.split(needle).length - 1;
if (actual < minimum) {
violations.push(`${invariant.file} must contain ${needle} at least ${minimum} time(s); found ${actual}.`);
}
}
}
if (violations.length > 0) {
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
}
}
function main(): void {
const scriptDir = import.meta.dirname;
const appDir = resolve(scriptDir, '..');
assertSkeletonSurfaceInvariants(appDir);
assertSkeletonSharedRuleTokens(appDir);
assertSkeletonBorderTokens(appDir);
const outputPath = join(appDir, 'src', 'features', 'theme', 'variables', 'ThemeVariableManifest.ts');
const contents = render(appDir);
if (process.argv.includes('--check')) {
@@ -2,15 +2,11 @@
import {promises as fs} from 'node:fs';
import * as path from 'node:path';
import type {PrecacheEntry} from '@app/features/platform/service_worker/WorkerAppShell';
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
import * as esbuild from 'esbuild';
interface PrecacheEntry {
url: string;
revision: string;
}
const PRECACHE_ROOT_FILES = ['index.html', 'manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
const PRECACHE_ROOT_FILES = ['manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
const NEVER_PRECACHED_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf', '.eot'];
@@ -38,11 +34,7 @@ async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
for (const file of PRECACHE_ROOT_FILES) {
const filePath = path.join(DIST_DIR, file);
try {
const revision = await fileRevision(filePath);
entries.set(`/${file}`, revision);
if (file === 'index.html') {
entries.set('/', revision);
}
entries.set(`/${file}`, await fileRevision(filePath));
} catch {}
}
try {
+2 -8
View File
@@ -19,11 +19,8 @@ import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
import {usePlatformClasses} from '@app/features/app/hooks/usePlatformClasses';
import {useServiceWorkerBadge} from '@app/features/app/hooks/useServiceWorkerBadge';
import {useTabKeyFocusGuard} from '@app/features/app/hooks/useTabKeyFocusGuard';
import Initialization from '@app/features/app/state/Initialization';
import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext';
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
import Authentication from '@app/features/auth/state/Authentication';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import MemberSidebar from '@app/features/member/state/MemberSidebar';
@@ -35,6 +32,7 @@ import {startDesktopLocaleBridge} from '@app/features/platform/utils/DesktopLoca
import {PremiumCheckoutReturnWatcher} from '@app/features/premium/components/PremiumCheckoutReturnWatcher';
import {QUICK_SWITCHER_PORTAL_ID} from '@app/features/search/components/quick_switcher/QuickSwitcherConstants';
import {useCustomThemeStyle} from '@app/features/theme/hooks/useCustomThemeStyle';
import {useRemScaleTracking} from '@app/features/theme/hooks/useRemScaleTracking';
import {useThemeCssVariables} from '@app/features/theme/hooks/useThemeCssVariables';
import Theme from '@app/features/theme/state/Theme';
import ThemeLibrary from '@app/features/theme/state/ThemeLibrary';
@@ -127,12 +125,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
}, []),
);
const handleSkipLinkFocus = useTabKeyFocusGuard();
const isSplashScreenActive =
Authentication.isAuthenticated &&
!DeveloperOptions.bypassSplashScreen &&
(GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes);
useInertBackground(ringsContainerRef, hasBlockingModal || topPopoutRequiresBackdrop);
useInertBackground(overlayScopeRef, isSplashScreenActive);
useEffect(() => {
showMyselfTypingHelper.start();
return () => showMyselfTypingHelper.stop();
@@ -177,6 +170,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
}, [isNative, firstClickPassThroughWhenUnfocused]);
useDesktopAllowTransparency(isNative);
useWindowEventListeners({preventDocumentScroll: !isNative});
useRemScaleTracking();
usePlatformClasses(platform, isNative);
useThemeCssVariables({
effectiveTheme: Theme.effectiveTheme,
+147 -2
View File
@@ -103,6 +103,25 @@ var {
--input-wrapper-padding-bottom: 0.5rem;
--textarea-top-bar-height: 2.5rem;
--chat-horizontal-padding-default: 1rem;
--message-compact-horizontal-padding: min(
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)),
1rem
);
--textarea-font-size: var(--font-size, 1rem);
--textarea-button-height: 2rem;
--textarea-button-icon-size: 1.375rem;
--textarea-button-compact-height: 2rem;
--textarea-button-compact-icon-size: 1.25rem;
--textarea-container-padding-x: 0px;
--textarea-line-height: calc(var(--textarea-font-size) * 1.375);
--textarea-content-offset: max(0rem, calc((var(--textarea-button-height) - var(--textarea-line-height)) / 2));
--textarea-upload-gap: var(--message-gutter, 1rem);
--textarea-side-button-padding: max(
0px,
calc((var(--message-avatar-size, 2.5rem) - var(--textarea-button-height)) / 2)
);
--footer-box-height: 3.625rem;
--footer-box-inset: 0.375rem;
--footer-box-inset-inline: var(--footer-box-inset);
@@ -111,8 +130,38 @@ var {
--outline-frame-border-width: 0.0625rem;
--composer-mobile-box-height: 3rem;
--composer-action-gap: 0.25rem;
--composer-surface-color: var(--background-secondary-lighter);
--composer-status-line-height: 1.125rem;
--composer-status-safe-gap: 0.5rem;
--composer-status-safe-area: calc(
var(--composer-status-line-height) +
var(--composer-status-safe-gap) +
var(--composer-status-safe-gap)
);
--guild-list-item-box-size: 3rem;
--guild-list-item-gap: 0.375rem;
--guild-list-item-target-size: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
--guild-list-item-target-half-extra: calc((var(--guild-list-item-target-size) - var(--guild-list-item-box-size)) / 2);
--guild-folder-expanded-surface-size: var(--guild-list-item-box-size);
--folder-radius: calc(var(--guild-folder-expanded-surface-size) * 0.3);
--mention-badge-small-size: 1.25rem;
--mention-badge-medium-size: 1.5rem;
--outline-frame-radius-native: clamp(0.5rem, 1.2vw, 0.875rem);
--guild-list-divider-line-size: 0.125rem;
--guild-list-divider-base-gap: var(--guild-list-item-gap);
--guild-members-columns: minmax(15rem, 2.2fr) minmax(7.5rem, 1fr) minmax(7.5rem, 1fr) minmax(9.375rem, 1.15fr)
minmax(10rem, 1.35fr) 4.5rem;
--guild-members-min-width: calc(15rem + 7.5rem + 7.5rem + 9.375rem + 10rem + 4.5rem);
--guild-members-select-column-width: 2.75rem;
--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);
--guild-members-min-width-selectable: calc(var(--guild-members-select-column-width) + var(--guild-members-min-width));
--floating-surface-ring-color: color-mix(in srgb, var(--background-modifier-accent) 20%, transparent);
--floating-surface-ring-color-strong: color-mix(in srgb, var(--background-modifier-accent) 45%, transparent);
--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);
--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);
--skeleton-opacity-strong: 0.55;
--skeleton-opacity-default: 0.45;
--skeleton-opacity-muted: 0.35;
--footer-box-padding-y: max(0rem, calc((var(--footer-box-height) - var(--textarea-button-height, 2rem)) / 2));
--composer-mobile-padding-y: max(
@@ -124,10 +173,14 @@ var {
--textarea-min-height: var(--footer-box-height);
--textarea-padding-y: var(--footer-box-padding-y);
--composer-box-inset: max(0rem, calc((var(--input-container-min-height) - var(--textarea-min-height)) / 2));
--composer-box-inset-inline: min(var(--footer-box-inset), var(--chat-horizontal-padding, var(--spacing-4)));
--messages-bottom-clearance: var(--composer-status-safe-area);
--composer-box-inset-inline: min(
var(--footer-box-inset),
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))
);
--composer-box-padding-inline: max(
0rem,
calc(var(--chat-horizontal-padding, var(--spacing-4)) - var(--composer-box-inset-inline))
calc(var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) - var(--composer-box-inset-inline))
);
--typing-indicator-height: 1rem;
@@ -167,6 +220,7 @@ var {
--layout-header-height: 3.5rem;
--layout-user-area-height: var(--footer-box-height);
--layout-user-area-reserved-height: 0px;
--layout-voice-connection-height: 0px;
--layout-mobile-bottom-nav-reserved-height: 0px;
--user-area-box-inset-block-end: calc(var(--footer-box-inset) + var(--outline-frame-border-width));
--user-area-content-height: var(--textarea-button-height, 2rem);
@@ -193,7 +247,94 @@ var {
--mobile-bottom-nav-height: 3.75rem;
--message-compact-text-indent-from-username: 2rem;
--guilds-layout-item-bg: var(--guild-list-foreground);
--guild-badge-surface: var(--background-secondary);
--layout-member-list-width: 16.5rem;
--layout-header-height-mobile: 4rem;
--channel-header-gap: var(--spacing-4);
--channel-header-padding-inline: var(--spacing-4);
--channel-header-action-size: 2rem;
--channel-header-action-size-mobile: 2.5rem;
--channel-header-actions-gap: var(--spacing-2);
--channel-header-back-button-size: 2rem;
--channel-header-back-button-gap: var(--spacing-3);
--channel-header-icon-size: 1.5rem;
--channel-header-back-icon-size: 1.5rem;
--channel-header-caret-size: 1rem;
--channel-header-action-icon-size: 1.5rem;
--channel-header-name-gap: var(--spacing-3);
--channel-header-name-gap-group-dm: var(--spacing-2);
--channel-header-caret-gap: var(--spacing-1);
--channel-header-topic-divider-gap: var(--spacing-2);
--chat-mobile-horizontal-padding: 0.75rem;
--dm-sidebar-mobile-header-height: 3.5rem;
--message-search-bar-width: 15.25rem;
--message-search-bar-height: 2.25rem;
--message-search-bar-icon-size: 1rem;
--message-search-bar-icon-gap: 0.5rem;
--member-list-item-content-height: 2rem;
--member-list-item-padding-block: 0.25rem;
--member-list-item-padding-inline: 0.5rem;
--member-list-item-content-gap: 0.625rem;
--member-list-item-name-line-height: 1.25rem;
--member-list-item-status-line-height: 0.875rem;
--member-list-row-gap: 0.125rem;
--member-list-group-spacer-height: 0.25rem;
--member-list-scroller-padding-block-start: 0.25rem;
--member-list-scroller-padding-block-end: var(--spacing-4);
--member-list-scroller-padding-inline: var(--spacing-2);
--friend-row-padding-block: 0.75rem;
--friend-row-padding-inline: 1rem;
--friend-row-separator-inset: 0 0.4rem 0 var(--spacing-2);
--friend-row-separator-radius: 0.375rem;
--friend-row-content-gap: 0.75rem;
--friend-row-name-line-height: 1.25rem;
--friend-row-subtext-line-height: 1rem;
--friend-row-subtext-offset: -0.0625rem;
--friend-row-avatar-size: 2.25rem;
--friend-row-action-size: 2.25rem;
--friend-row-action-gap: 0.5rem;
--avatar-stack-extra-height: 0.375rem;
--active-now-sidebar-width: 22rem;
--active-now-sidebar-header-padding-inline: 1rem;
--active-now-sidebar-header-padding-block-start: 1rem;
--active-now-sidebar-header-padding-block-end: 0.75rem;
--active-now-sidebar-content-gap: 0.5rem;
--active-now-sidebar-content-padding-inline: 0.75rem;
--active-now-sidebar-content-padding-block-end: 0.75rem;
--active-now-card-gap: 0.75rem;
--active-now-card-padding-block: 0.75rem;
--active-now-card-padding-inline: 0.875rem;
--active-now-card-radius: var(--radius-md);
--active-now-card-shadow: 0 0.375rem 1.125rem rgba(0, 0, 0, 0.08);
--active-now-context-gap: 0.375rem;
--active-now-action-height: 2rem;
--active-now-action-radius: 0.5rem;
--active-now-action-gap: 0.375rem;
--active-now-action-padding-block: 0.3125rem;
--active-now-action-padding-inline: 0.625rem;
--discovery-card-description-min-height: 5.25rem;
--guild-list-indicator-inset: -0.15rem;
--guild-list-indicator-track-width: 0.5rem;
--guild-list-indicator-bar-width: 0.35rem;
--count-indicator-gap: 0.25rem;
--guild-members-table-row-min-height: 3rem;
--guild-members-table-row-border-width: 0.0625rem;
--form-surface-background: var(--background-tertiary);
--message-preview-card-background: hsla(0, 0%, 100%, 0.022);
--message-preview-card-border: hsla(0, 0%, 100%, 0.07);
--message-preview-card-divider: hsla(0, 0%, 100%, 0.05);
--surface-interactive-hover-bg: var(--background-modifier-hover);
--surface-interactive-selected-bg: var(--background-modifier-selected);
--surface-interactive-selected-color: var(--text-primary);
@@ -209,7 +350,11 @@ html.platform-native.platform-macos {
}
.theme-light {
--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);
--form-surface-background: var(--background-primary);
--message-preview-card-background: hsl(0, 0%, 100%);
--message-preview-card-border: hsla(0, 0%, 0%, 0.07);
--message-preview-card-divider: hsla(0, 0%, 0%, 0.05);
--surface-interactive-hover-bg: var(--background-modifier-hover);
--surface-interactive-selected-bg: var(--background-modifier-selected);
--surface-interactive-selected-color: var(--text-primary);
@@ -4,15 +4,21 @@ import {Routes} from '@app/app/Routes';
import {isAutoRedirectExemptPath} from '@app/app/router/RouterConstants';
import {KeyboardModeListener} from '@app/features/app/components/layout/KeyboardModeListener';
import {MobileBottomNav} from '@app/features/app/components/layout/MobileBottomNav';
import {SplashScreen} from '@app/features/app/components/layout/SplashScreen';
import {SelfHostedSetupWizardGate} from '@app/features/app/components/setup/SelfHostedSetupWizardGate';
import {AppSkeleton} from '@app/features/app/components/skeleton/AppSkeleton';
import {useLoadingSkeletonRetention} from '@app/features/app/hooks/useLoadingSkeletonRetention';
import {useSkeletonLayoutMemoryCapture} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
import {
shouldShowLoadingSkeleton as getShouldShowLoadingSkeleton,
shouldShowMobileBottomNav,
} from '@app/features/app/state/AppShellPresentationPolicy';
import {isClientBooting} from '@app/features/app/state/ClientReadiness';
import Initialization from '@app/features/app/state/Initialization';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
import AccountManager from '@app/features/auth/state/AccountManager';
import Authentication from '@app/features/auth/state/Authentication';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
@@ -40,6 +46,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
const requiresSelfHostedSetup = RuntimeConfig.requiresSelfHostedSetup();
const mobileLayoutState = MobileLayout;
const [hasRestoredLocation, setHasRestoredLocation] = useState(false);
const [effectiveSkeletonPathname, setEffectiveSkeletonPathname] = useState<string | null>(null);
const currentUser = Users.currentUser;
const [hasHandledNotificationNav, setHasHandledNotificationNav] = useState(false);
const previousMobileLayoutStateRef = useRef(mobileLayoutState.enabled);
@@ -77,13 +84,57 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
const shouldShowSelfHostedSetup = requiresSelfHostedSetup && !isStandaloneRoute;
const shouldBypassGateway = requiresSelfHostedSetup || isStandaloneRoute;
const authToken = Authentication.authToken;
const protectedRouteBlocked = !canNavigateToProtectedRoutes && !shouldBypassGateway;
const splashScreenCoversProtectedRoute =
const shouldShowLoadingSkeleton = getShouldShowLoadingSkeleton({
booting: isClientBooting(),
isAuthenticated,
isAuthSessionInitialized: SessionManager.isInitialized,
shouldBypassGateway,
});
useSkeletonLayoutMemoryCapture(
isAuthenticated && Initialization.isReady && !shouldBypassGateway && !shouldShowLoadingSkeleton,
);
const shouldDismissTransientOverlays = !isAuthenticated || shouldShowLoadingSkeleton;
const {isLoadingSkeletonRetained, handleLoadingSkeletonTransitionEnd} = useLoadingSkeletonRetention({
shouldShowLoadingSkeleton,
});
let pendingRestorableSkeletonPathname: string | null = null;
if (
isAuthenticated &&
!DeveloperOptions.bypassSplashScreen &&
!shouldBypassGateway &&
(GatewayConnection.isConnectionInterrupted || !canNavigateToProtectedRoutes);
const shouldDismissTransientOverlays = !isAuthenticated || protectedRouteBlocked || splashScreenCoversProtectedRoute;
isLocationStateHydrated &&
!AccountManager.isSwitching &&
pathname === Routes.ME &&
!hasRestoredLocation
) {
const lastLocation = Location.getLastLocation();
if (lastLocation != null && lastLocation !== pathname) {
pendingRestorableSkeletonPathname = lastLocation;
}
}
useLayoutEffect(() => {
if (
!isAuthenticated ||
!isLocationStateHydrated ||
AccountManager.isSwitching ||
pathname !== Routes.ME ||
hasRestoredLocation
) {
return;
}
const lastLocation = Location.getLastLocation();
if (lastLocation && lastLocation !== pathname) {
setEffectiveSkeletonPathname(lastLocation);
}
}, [hasRestoredLocation, isAuthenticated, isLocationStateHydrated, pathname]);
useLayoutEffect(() => {
if (effectiveSkeletonPathname != null && pathname !== Routes.ME) {
setEffectiveSkeletonPathname(null);
}
}, [effectiveSkeletonPathname, pathname]);
useLayoutEffect(() => {
if (!shouldShowLoadingSkeleton && !isLoadingSkeletonRetained && effectiveSkeletonPathname != null) {
setEffectiveSkeletonPathname(null);
}
}, [effectiveSkeletonPathname, isLoadingSkeletonRetained, shouldShowLoadingSkeleton]);
useEffect(() => {
if (!isAuthenticated) return;
void PushSubscriptionService.cleanupNativeDesktopWebPushSubscriptions('desktop-authenticated');
@@ -298,25 +349,32 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
navigateWithHistoryStack,
normalizeInternalUrl,
]);
const showBottomNav =
mobileLayoutState.enabled &&
(location.pathname === Routes.ME ||
location.pathname === Routes.FAVORITES ||
location.pathname === Routes.NOTIFICATIONS ||
location.pathname === Routes.YOU ||
(Routes.isGuildChannelRoute(location.pathname) && location.pathname.split('/').length === 3));
const showBottomNav = shouldShowMobileBottomNav({
mobileLayoutEnabled: mobileLayoutState.enabled,
pathname,
});
if (shouldShowSelfHostedSetup) {
return <SelfHostedSetupWizardGate />;
}
if (isAuthenticated && !canNavigateToProtectedRoutes && !shouldBypassGateway) {
return <SplashScreen data-flx="app.router.root-component.splash-screen" />;
return <SelfHostedSetupWizardGate data-flx="app.router.root-component.self-hosted-setup-wizard-gate" />;
}
return (
<>
<KeyboardModeListener data-flx="app.router.root-component.keyboard-mode-listener" />
{children}
{showBottomNav && currentUser && (
<MobileBottomNav currentUser={currentUser} data-flx="app.router.root-component.mobile-bottom-nav" />
{!shouldShowLoadingSkeleton && (
<>
<KeyboardModeListener data-flx="app.router.root-component.keyboard-mode-listener" />
{children}
{showBottomNav && currentUser && (
<MobileBottomNav currentUser={currentUser} data-flx="app.router.root-component.mobile-bottom-nav" />
)}
</>
)}
{(shouldShowLoadingSkeleton || isLoadingSkeletonRetained) && (
<AppSkeleton
key={`loading-skeleton:${AccountManager.currentUserId ?? 'unauthenticated'}`}
effectivePathname={effectiveSkeletonPathname ?? pendingRestorableSkeletonPathname ?? pathname}
isExiting={!shouldShowLoadingSkeleton}
onTransitionEnd={handleLoadingSkeletonTransitionEnd}
data-flx="app.router.root-component.app-skeleton"
/>
)}
</>
);
@@ -6,6 +6,7 @@ import {
getMessageGroupSpacingForDisplayMode,
migrateLegacyMessageGroupSpacing,
} from '@app/features/accessibility/state/MessageGroupSpacing';
import {DEFAULT_MESSAGE_GUTTER_PX} from '@app/features/accessibility/state/MessagePresentationDefaults';
import type {AnimatedMediaKind} from '@app/features/accessibility/state/MotionPreferencesMachine';
import {
AuthSessionStorageKey,
@@ -667,7 +668,7 @@ class Accessibility {
keepStickerAnimationUnderReducedMotion = false;
messageGroupSpacing = COMFY_MESSAGE_GROUP_SPACING_DEFAULT;
compactMessageGroupSpacing = COMPACT_MESSAGE_GROUP_SPACING_DEFAULT;
messageGutter = 16;
messageGutter = DEFAULT_MESSAGE_GUTTER_PX;
fontSize = 16;
showUserAvatarsInCompactMode = false;
mobileStickerAnimationOverridden = false;
@@ -0,0 +1,3 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DEFAULT_MESSAGE_GUTTER_PX = 16;
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import AccountManager from '@app/features/auth/state/AccountManager';
import SessionManager from '@app/features/platform/state/AuthSession';
class StalledConnectionAccountSwitchUnavailableError extends Error {
constructor() {
super('Cannot switch accounts from the current authentication session state');
this.name = 'StalledConnectionAccountSwitchUnavailableError';
}
}
export function canSwitchAccountFromStalledConnection(): boolean {
return SessionManager.canSwitchAccount() || SessionManager.isConnecting;
}
export async function switchAccountFromStalledConnection(userId: string): Promise<void> {
if (!canSwitchAccountFromStalledConnection()) {
throw new StalledConnectionAccountSwitchUnavailableError();
}
SessionManager.requireAccountOnCurrentInstance(userId);
if (SessionManager.isConnecting) {
SessionManager.handleConnectionFailed();
}
await AccountManager.switchToAccount(userId, null);
}
@@ -1,45 +0,0 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-2);
width: 100%;
}
.prompt {
margin: 0;
font-size: 0.8125rem;
line-height: 1.4;
color: var(--text-secondary);
text-align: center;
}
.links {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--spacing-2);
margin: 0;
}
.link {
color: var(--text-link);
font-size: 0.8125rem;
font-weight: 400;
text-decoration: none;
text-underline-offset: 0.125rem;
transition: color 0.15s ease;
}
.link:hover {
text-decoration: underline;
text-decoration-thickness: 0.125rem;
}
.separator {
color: var(--text-tertiary);
user-select: none;
}
@@ -1,48 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/ConnectionIssuesLinks.module.css';
import type {StatusPageIncident} from '@app/features/user/state/StatusPage';
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
import {Trans} from '@lingui/react/macro';
interface ConnectionIssuesLinksProps {
incident: StatusPageIncident | null;
className?: string;
}
const STATUS_HISTORY_URL = `${ExternalUrls.SERVICE_STATUS}/history`;
export function ConnectionIssuesLinks({incident, className}: ConnectionIssuesLinksProps) {
const containerClassName = className != null ? `${styles.container} ${className}` : styles.container;
const incidentUrl = incident?.url ?? STATUS_HISTORY_URL;
return (
<div className={containerClassName} data-flx="app.connection-issues-links.div">
<p className={styles.prompt} data-flx="app.connection-issues-links.prompt">
<Trans>Connection issues?</Trans>
</p>
<p className={styles.links} data-flx="app.connection-issues-links.links">
<a
href={ExternalUrls.SERVICE_STATUS}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
data-flx="app.connection-issues-links.link"
>
<Trans>Status page</Trans>
</a>
<span aria-hidden="true" className={styles.separator} data-flx="app.connection-issues-links.separator">
·
</span>
<a
href={incidentUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
data-flx="app.connection-issues-links.link--2"
>
{incident ? <Trans>Read incident</Trans> : <Trans>Incident history</Trans>}
</a>
</p>
</div>
);
}
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import React, {useCallback, useEffect, useRef, useState} from 'react';
const LONG_PRESS_MOVEMENT_THRESHOLD = 10;
const SWIPE_VELOCITY_THRESHOLD = 0.4;
@@ -9,6 +10,7 @@ const MIN_VELOCITY_SAMPLES = 2;
const MAX_VELOCITY_SAMPLE_AGE = 100;
const PRESS_HIGHLIGHT_DELAY_MS = 100;
const LONG_PRESS_DURATION_MS = 500;
const LONG_PRESS_CLICK_SUPPRESSION_MS = 750;
const HAS_POINTER_EVENTS = typeof window !== 'undefined' && 'PointerEvent' in window;
interface VelocitySample {
@@ -53,8 +55,12 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
const storedEvent = useRef<LongPressEvent | null>(null);
const velocitySamples = useRef<Array<VelocitySample>>([]);
const isPressIntent = useRef(false);
const pressScrollCancelHandler = useRef<(() => void) | null>(null);
const clearTimerRef = useRef<() => void>(() => {});
const suppressNextClickRef = useRef(false);
const suppressClickTimer = useRef<NodeJS.Timeout | null>(null);
const [isPressed, setIsPressed] = useState(false);
useImperativeHandle(forwardedRef, () => innerRef.current as HTMLElement);
const composedRef = useMergeRefs<HTMLElement>([forwardedRef, innerRef]);
const setPressed = useCallback(
(pressed: boolean) => {
setIsPressed(pressed);
@@ -62,6 +68,21 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
},
[onPressStateChange],
);
const clearSuppressedClick = useCallback(() => {
suppressNextClickRef.current = false;
if (suppressClickTimer.current) {
clearTimeout(suppressClickTimer.current);
suppressClickTimer.current = null;
}
}, []);
const suppressNextClick = useCallback(() => {
suppressNextClickRef.current = true;
if (suppressClickTimer.current) clearTimeout(suppressClickTimer.current);
suppressClickTimer.current = setTimeout(() => {
suppressClickTimer.current = null;
suppressNextClickRef.current = false;
}, LONG_PRESS_CLICK_SUPPRESSION_MS);
}, []);
const calculateVelocity = useCallback((): number => {
const samples = velocitySamples.current;
if (samples.length < MIN_VELOCITY_SAMPLES) return 0;
@@ -83,7 +104,24 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
const distance = Math.sqrt(dx * dx + dy * dy);
return distance / dt;
}, []);
const detachPressScrollCancel = useCallback(() => {
const handler = pressScrollCancelHandler.current;
if (handler == null) return;
pressScrollCancelHandler.current = null;
window.removeEventListener('scroll', handler, {capture: true});
}, []);
const attachPressScrollCancel = useCallback(() => {
if (pressScrollCancelHandler.current != null) return;
const handler = () => {
if (isPressIntent.current) {
clearTimerRef.current();
}
};
pressScrollCancelHandler.current = handler;
window.addEventListener('scroll', handler, {capture: true, passive: true});
}, []);
const clearTimer = useCallback(() => {
detachPressScrollCancel();
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
@@ -103,7 +141,8 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
velocitySamples.current = [];
isPressIntent.current = false;
setPressed(false);
}, [setPressed]);
}, [detachPressScrollCancel, setPressed]);
clearTimerRef.current = clearTimer;
const {
onPointerDown: userOnPointerDown,
onPointerMove: userOnPointerMove,
@@ -136,6 +175,7 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
pointerIdRef.current = pointerId ?? null;
velocitySamples.current = [{x, y, timestamp: performance.now()}];
isPressIntent.current = true;
attachPressScrollCancel();
if (capturePointer && pointerId != null && innerRef.current?.setPointerCapture) {
try {
innerRef.current.setPointerCapture(pointerId);
@@ -150,13 +190,14 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
}, PRESS_HIGHLIGHT_DELAY_MS);
longPressTimer.current = setTimeout(() => {
if (!disabled && onLongPress && storedEvent.current && isPressIntent.current) {
suppressNextClick();
onLongPress(storedEvent.current);
setPressed(false);
}
clearTimer();
}, delay);
},
[clearTimer, delay, disabled, onLongPress, setPressed],
[attachPressScrollCancel, clearTimer, delay, disabled, onLongPress, setPressed, suppressNextClick],
);
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLElement>) => {
@@ -255,26 +296,35 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
},
[clearTimer, userOnTouchCancel],
);
useEffect(() => {
const handleScroll = () => {
if (isPressIntent.current) {
clearTimer();
const handleClick = useCallback(
(event: React.MouseEvent<HTMLElement>) => {
if (suppressNextClickRef.current) {
event.preventDefault();
event.stopPropagation();
clearSuppressedClick();
return;
}
};
window.addEventListener('scroll', handleScroll, {capture: true, passive: true});
userOnClick?.(event);
},
[clearSuppressedClick, userOnClick],
);
useEffect(() => {
return () => {
window.removeEventListener('scroll', handleScroll, {capture: true});
detachPressScrollCancel();
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
}
if (highlightTimer.current) {
clearTimeout(highlightTimer.current);
}
if (suppressClickTimer.current) {
clearTimeout(suppressClickTimer.current);
}
};
}, [clearTimer]);
}, [detachPressScrollCancel]);
const finalClassName = isPressed && pressedClassName ? `${className ?? ''} ${pressedClassName}`.trim() : className;
return React.createElement(Component, {
ref: innerRef,
ref: composedRef,
className: finalClassName,
onPointerDown: HAS_POINTER_EVENTS ? handlePointerDown : undefined,
onPointerMove: HAS_POINTER_EVENTS ? handlePointerMove : undefined,
@@ -284,7 +334,7 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
onTouchMove: !HAS_POINTER_EVENTS ? handleTouchMove : undefined,
onTouchEnd: !HAS_POINTER_EVENTS ? handleTouchEnd : undefined,
onTouchCancel: !HAS_POINTER_EVENTS ? handleTouchCancel : undefined,
onClick: userOnClick,
onClick: userOnClick ? handleClick : undefined,
onKeyDown: handleKeyDown,
...restWithoutPointer,
'data-long-press-owner': !disabled && onLongPress ? 'true' : undefined,
@@ -1,18 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {OutageSplashScreen} from '@app/features/app/components/layout/SplashScreen';
import {useEffect} from 'react';
const AUTO_RELOAD_BASE_MS = 15_000;
const AUTO_RELOAD_JITTER_MS = 5_000;
export function NetworkErrorScreen() {
useEffect(() => {
const delay = AUTO_RELOAD_BASE_MS + Math.random() * AUTO_RELOAD_JITTER_MS;
const timer = setTimeout(() => {
window.location.reload();
}, delay);
return () => clearTimeout(timer);
}, []);
return <OutageSplashScreen data-flx="app.network-error-screen.outage-splash-screen" />;
}
@@ -9,6 +9,7 @@ import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {isForwardableChannelType} from './ForwardChannelEligibility';
import {matchesForwardChannelSearch} from './ForwardChannelSearchMatch';
const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({
message: 'Sending messages is disabled in this community',
@@ -92,6 +93,7 @@ export interface ForwardChannelObservation {
readonly guildMessagesDisabled: boolean;
readonly guildName: string | null;
readonly memberTimedOut: boolean;
readonly searchAliases: ReadonlyArray<string>;
readonly slowmodeEnabled: boolean;
readonly slowmodeRemainingMs: number;
}
@@ -113,6 +115,7 @@ interface IndexedForwardChannelOption extends ForwardChannelOption {
readonly isPersonalNotes: boolean;
readonly isSource: boolean;
readonly recentRank: number | null;
readonly searchAliasValues: ReadonlyArray<string>;
}
interface BuildForwardChannelIndexRequest {
@@ -194,10 +197,7 @@ export class ForwardChannelIndex {
readonly personalNotesSearchValue: string;
readonly option: IndexedForwardChannelOption;
}): boolean {
if (option.isPersonalNotes && personalNotesSearchValue.includes(normalizedQuery)) return true;
if (option.displayNameSearchValue.includes(normalizedQuery)) return true;
if (option.channelNameSearchValue.includes(normalizedQuery)) return true;
return option.guildNameSearchValue.includes(normalizedQuery);
return matchesForwardChannelSearch({normalizedQuery, personalNotesSearchValue, values: option});
}
private buildOption({
@@ -232,6 +232,7 @@ export class ForwardChannelIndex {
isPersonalNotes: observation.channel.type === ChannelTypes.DM_PERSONAL_NOTES,
isSource: observation.channel.id === excludedChannelId,
recentRank,
searchAliasValues: Object.freeze(observation.searchAliases.map((alias) => alias.toLowerCase())),
slowmodeEnabled: observation.slowmodeEnabled,
slowmodeRemainingMs: observation.slowmodeRemainingMs,
});
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {type ForwardChannelSearchValues, matchesForwardChannelSearch} from './ForwardChannelSearchMatch';
const PERSONAL_NOTES = 'personal notes';
function recipient(overrides: Partial<ForwardChannelSearchValues> = {}): ForwardChannelSearchValues {
return {
channelNameSearchValue: '',
displayNameSearchValue: 'ada lovelace',
guildNameSearchValue: '',
isPersonalNotes: false,
searchAliasValues: ['ada_dev', 'ada lovelace', 'countess', 'raid lead'],
...overrides,
};
}
function matches(query: string, values: ForwardChannelSearchValues = recipient()): boolean {
return matchesForwardChannelSearch({
normalizedQuery: query,
personalNotesSearchValue: PERSONAL_NOTES,
values,
});
}
describe('forward channel search matching', () => {
it('matches the rendered global display name', () => {
expect(matches('ada lovelace')).toBe(true);
});
it('matches the username', () => {
expect(matches('ada_dev')).toBe(true);
});
it('matches a nickname from a guild the reader is not currently viewing', () => {
expect(matches('raid lead')).toBe(true);
});
it('matches a relationship nickname', () => {
expect(matches('countess')).toBe(true);
});
it('does not match an unrelated query', () => {
expect(matches('grace hopper')).toBe(false);
});
it('does not match a recipient with no aliases indexed beyond the display name', () => {
const values = recipient({searchAliasValues: []});
expect(matches('ada_dev', values)).toBe(false);
expect(matches('ada lovelace', values)).toBe(true);
});
it('still matches channel and guild names', () => {
const guildChannel = recipient({
channelNameSearchValue: 'general',
displayNameSearchValue: 'general',
guildNameSearchValue: 'fluxer hq',
searchAliasValues: [],
});
expect(matches('general', guildChannel)).toBe(true);
expect(matches('fluxer hq', guildChannel)).toBe(true);
});
it('still matches personal notes', () => {
const notes = recipient({displayNameSearchValue: '', isPersonalNotes: true, searchAliasValues: []});
expect(matches('personal', notes)).toBe(true);
});
});
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export interface ForwardChannelSearchValues {
readonly channelNameSearchValue: string;
readonly displayNameSearchValue: string;
readonly guildNameSearchValue: string;
readonly isPersonalNotes: boolean;
readonly searchAliasValues: ReadonlyArray<string>;
}
interface MatchesForwardChannelSearchRequest {
readonly normalizedQuery: string;
readonly personalNotesSearchValue: string;
readonly values: ForwardChannelSearchValues;
}
export function matchesForwardChannelSearch({
normalizedQuery,
personalNotesSearchValue,
values,
}: MatchesForwardChannelSearchRequest): boolean {
if (values.isPersonalNotes && personalNotesSearchValue.includes(normalizedQuery)) return true;
if (values.displayNameSearchValue.includes(normalizedQuery)) return true;
if (values.channelNameSearchValue.includes(normalizedQuery)) return true;
if (values.searchAliasValues.some((alias) => alias.includes(normalizedQuery))) return true;
return values.guildNameSearchValue.includes(normalizedQuery);
}
@@ -138,7 +138,7 @@ export const RecipientList = observer((props: RecipientListProps) => {
const query = searchQuery.toLowerCase();
return props.recipients.filter((item) => {
const username = item.user.username.toLowerCase();
const displayName = NicknameUtils.getNickname(item.user).toLowerCase();
const displayName = NicknameUtils.getNickname(item.user, null).toLowerCase();
const channelName = (item.channelName || '').toLowerCase();
return username.includes(query) || displayName.includes(query) || channelName.includes(query);
});
@@ -186,7 +186,8 @@ export const RecipientList = observer((props: RecipientListProps) => {
const userId = item.type === 'group_dm' ? item.id : item.user.id;
const isSending = props.sendingTo.has(userId);
const isSent = props.sentTo.has(userId);
const displayName = item.type === 'group_dm' ? item.channelName : NicknameUtils.getNickname(item.user);
const displayName =
item.type === 'group_dm' ? item.channelName : NicknameUtils.getNickname(item.user, null);
const secondaryText = (() => {
if (item.type === 'dm') return i18n._(DIRECT_MESSAGE_DESCRIPTOR);
if (item.type === 'group_dm') return i18n._(GROUP_DM_DESCRIPTOR);
@@ -9,8 +9,11 @@ import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import Guilds from '@app/features/guild/state/Guilds';
import GuildMembers from '@app/features/member/state/GuildMembers';
import Permission from '@app/features/permissions/state/Permission';
import Relationships from '@app/features/relationship/state/Relationships';
import Slowmode from '@app/features/slowmode/state/Slowmode';
import {useNow} from '@app/features/ui/state/Tick';
import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants';
@@ -19,6 +22,8 @@ import {msg} from '@lingui/core/macro';
import {computed, type IComputedValue} from 'mobx';
import {useMemo, useRef} from 'react';
const EMPTY_SEARCH_ALIASES: ReadonlyArray<string> = Object.freeze([]);
const CHANNEL_DESCRIPTOR = msg({
message: 'Channel {id}',
comment: 'Short label in the settings dialog forward channel selection. Preserve {id}; it is inserted by code.',
@@ -40,6 +45,7 @@ interface ForwardChannelObservationBase {
readonly guildMessagesDisabled: boolean;
readonly guildName: string | null;
readonly memberTimedOut: boolean;
readonly searchAliases: ReadonlyArray<string>;
readonly slowmodeEnabled: boolean;
}
@@ -55,6 +61,12 @@ interface ForwardChannelObservationSelection {
}
function resolveChannelDisplayName(channel: Channel, i18n: I18n): string {
if (channel.type === ChannelTypes.DM) {
const recipient = channel.recipientIds.length > 0 ? Users.getUser(channel.recipientIds[0]) : null;
if (recipient) {
return NicknameUtils.getNickname(recipient, null);
}
}
if (
channel.type === ChannelTypes.DM_PERSONAL_NOTES ||
channel.type === ChannelTypes.DM ||
@@ -66,6 +78,28 @@ function resolveChannelDisplayName(channel: Channel, i18n: I18n): string {
return i18n._(CHANNEL_DESCRIPTOR, {id: channel.id});
}
function collectRecipientSearchAliases(channel: Channel): ReadonlyArray<string> {
if (channel.type !== ChannelTypes.DM && channel.type !== ChannelTypes.GROUP_DM) {
return EMPTY_SEARCH_ALIASES;
}
const aliases = new Set<string>();
const guilds = Guilds.getGuilds();
for (const recipientId of channel.recipientIds) {
const recipient = Users.getUser(recipientId);
if (!recipient) continue;
aliases.add(recipient.username);
aliases.add(recipient.displayName);
if (recipient.globalName) aliases.add(recipient.globalName);
const relationshipNickname = Relationships.getRelationship(recipientId)?.nickname;
if (relationshipNickname) aliases.add(relationshipNickname);
for (const guild of guilds) {
const nick = GuildMembers.getMember(guild.id, recipientId)?.nick;
if (nick) aliases.add(nick);
}
}
return Object.freeze([...aliases].filter((alias) => alias.length > 0));
}
function resolveChannelCategoryName(channel: Channel): string | null {
if (!channel.parentId) return null;
const category = Channels.getChannel(channel.parentId);
@@ -99,6 +133,8 @@ function areForwardChannelObservationBasesEqual(
return false;
}
if (left.canSendMessages !== right.canSendMessages || left.canEmbedLinks !== right.canEmbedLinks) return false;
if (left.searchAliases.length !== right.searchAliases.length) return false;
if (left.searchAliases.some((alias, index) => alias !== right.searchAliases[index])) return false;
return left.canAttachFiles === right.canAttachFiles && left.slowmodeEnabled === right.slowmodeEnabled;
}
@@ -185,6 +221,7 @@ class ForwardChannelObservationsOwner {
guildMessagesDisabled: false,
guildName: null,
memberTimedOut: false,
searchAliases: collectRecipientSearchAliases(channel),
slowmodeEnabled: false,
});
}
@@ -213,6 +250,7 @@ class ForwardChannelObservationsOwner {
guildMessagesDisabled,
guildName,
memberTimedOut: isCurrentMemberTimedOut(channel, this.request.currentUserId),
searchAliases: collectRecipientSearchAliases(channel),
slowmodeEnabled,
});
}
@@ -243,6 +281,7 @@ function buildForwardChannelObservations(
displayName: base.displayName,
guildMessagesDisabled: base.guildMessagesDisabled,
guildName: base.guildName,
searchAliases: base.searchAliases,
memberTimedOut: base.memberTimedOut,
slowmodeEnabled: base.slowmodeEnabled,
slowmodeRemainingMs: remainingMs === undefined ? 0 : remainingMs,
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {describe, expect, it} from 'vitest';
import {mergeFrozenUnreadOrder} from './UnreadChannelOrder';
function ids(channels: ReadonlyArray<{id: string}>): Array<string> {
return channels.map((channel) => channel.id);
}
describe('mergeFrozenUnreadOrder', () => {
it('keeps channels in their frozen position while the list is open', () => {
const order = new Map([
['a', 0],
['b', 1],
['c', 2],
]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'c'}, {id: 'a'}, {id: 'b'}]);
expect(ids(merged)).toEqual(['a', 'b', 'c']);
});
it('shows a channel that becomes unread after the order was frozen', () => {
const order = new Map([['a', 0]]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]);
expect(ids(merged)).toEqual(['a', 'late']);
});
it('appends late arrivals after existing entries rather than reshuffling', () => {
const order = new Map([
['a', 0],
['b', 1],
]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'b'}, {id: 'a'}]);
expect(ids(merged)).toEqual(['a', 'b', 'late']);
});
it('remembers a late arrival position once assigned', () => {
const order = new Map([['a', 0]]);
mergeFrozenUnreadOrder(order, [{id: 'a'}, {id: 'late'}]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'late'}, {id: 'a'}]);
expect(ids(merged)).toEqual(['a', 'late']);
});
it('drops nothing when every channel is new', () => {
const order = new Map<string, number>();
const merged = mergeFrozenUnreadOrder(order, [{id: 'x'}, {id: 'y'}, {id: 'z'}]);
expect(ids(merged)).toEqual(['x', 'y', 'z']);
});
it('does not resurrect channels that are no longer unread', () => {
const order = new Map([
['a', 0],
['gone', 1],
]);
const merged = mergeFrozenUnreadOrder(order, [{id: 'a'}]);
expect(ids(merged)).toEqual(['a']);
});
});
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
function resolveUnreadOrderIndex(order: Map<string, number>, channelId: string): number {
const index = order.get(channelId);
if (index == null) {
return Number.POSITIVE_INFINITY;
}
return index;
}
export function mergeFrozenUnreadOrder<T extends {id: string}>(
order: Map<string, number>,
current: ReadonlyArray<T>,
): Array<T> {
for (const channel of current) {
if (!order.has(channel.id)) {
order.set(channel.id, order.size);
}
}
return [...current].sort((a, b) => resolveUnreadOrderIndex(order, a.id) - resolveUnreadOrderIndex(order, b.id));
}
@@ -5,14 +5,6 @@
--inbox-scroller-padding-bottom: 0.75rem;
--inbox-section-gap: 0.25rem;
--inbox-card-gap: 1rem;
--inbox-heading-padding-x: 0.25rem;
--inbox-heading-padding-y: 0.75rem;
--inbox-sticky-bg: var(--form-surface-background);
--inbox-sticky-radius: 0.375rem;
--inbox-sticky-separator: 0 0.0625rem 0 var(--background-modifier-accent);
--inbox-sticky-group-heading-height: 2.25rem;
--inbox-sticky-channel-heading-height: 3rem;
--inbox-sticky-channel-top: var(--inbox-sticky-group-heading-height);
--inbox-icon-button-size: 1.75rem;
--inbox-icon-button-radius: 0.375rem;
--inbox-collapse-button-size: 1.375rem;
@@ -21,64 +13,32 @@
min-height: 0;
min-width: 0;
padding: 0 var(--inbox-scroller-padding-x) var(--inbox-scroller-padding-bottom);
scroll-padding-top: calc(
var(--inbox-sticky-group-heading-height) +
var(--inbox-sticky-channel-heading-height) +
0.5rem
);
}
.guildGroup {
margin-bottom: var(--inbox-section-gap);
min-width: 0;
}
.guildGroup:last-child {
margin-bottom: 0;
.list {
display: block;
min-width: 0;
}
.guildGroupHeading {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: center;
.row {
display: block;
box-sizing: border-box;
min-height: var(--inbox-sticky-group-heading-height);
margin: 0;
padding: var(--inbox-heading-padding-y) var(--inbox-heading-padding-x) 0.5rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-primary-muted);
border-radius: var(--inbox-sticky-radius);
background-color: var(--inbox-sticky-bg);
transition: color 0.12s ease;
padding-bottom: var(--inbox-card-gap);
}
.rowGroupEnd {
padding-bottom: calc(var(--inbox-card-gap) + var(--inbox-section-gap));
}
.channelCard {
margin-bottom: var(--inbox-card-gap);
}
.channelCard:focus {
min-width: 0;
outline: none;
}
.channelStickyHeader {
position: sticky;
top: var(--inbox-sticky-channel-top);
z-index: 2;
margin: 0 0 0.5rem;
padding: 0 var(--inbox-heading-padding-x);
border-radius: var(--inbox-sticky-radius);
background-color: var(--inbox-sticky-bg);
box-shadow: var(--inbox-sticky-separator);
transition:
background-color 0.12s ease,
box-shadow 0.12s ease;
}
.srOnly {
position: absolute;
width: 0.0625rem;
@@ -107,16 +67,13 @@
}
.messageStream {
--chat-horizontal-padding: 0.75rem;
padding: 0 0.5rem;
min-width: 0;
overflow: hidden;
}
.previewFooter {
display: flex;
justify-content: flex-start;
padding: 0.5rem 0.75rem 0.625rem;
padding: 0.375rem 0.5rem 0.25rem;
}
.viewAllButton {
@@ -148,8 +105,8 @@
.messageActions {
display: none;
position: absolute;
top: 0.5rem;
right: 0.75rem;
top: 0.375rem;
right: 0.5rem;
gap: 0.25rem;
}
@@ -222,7 +179,7 @@
}
.previewDateDivider :global(.text) {
background-color: var(--inbox-sticky-bg);
background-color: var(--form-surface-background);
border-radius: 0.375rem;
padding: 0.125rem 0.625rem;
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility from '@app/features/accessibility/state/Accessibility';
import {mergeFrozenUnreadOrder} from '@app/features/app/components/floating/UnreadChannelOrder';
import styles from '@app/features/app/components/floating/UnreadChannelsContent.module.css';
import previewStyles from '@app/features/app/components/shared/MessagePreview.module.css';
import {Endpoints} from '@app/features/app/constants/Endpoints';
@@ -38,6 +39,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import UserSettings from '@app/features/user/state/UserSettings';
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {compare as compareSnowflakes, extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
import {msg} from '@lingui/core/macro';
@@ -50,7 +52,7 @@ import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
const MENTIONS_DESCRIPTOR = msg({
message: '{channelHeading}, {mentionCount} mentions',
message: '{channelHeading}, {mentionCount, plural, one {# mention} other {# mentions}}',
comment:
'Short label in the unread channels content. Preserve placeholders {channelHeading}, {mentionCount}; they are inserted by code.',
});
@@ -223,13 +225,13 @@ function buildPreviewDataFromMessages(
oldestUnreadMessageId: plan.oldestUnreadMessageId,
};
}
return buildEmptyPreview(plan.channel);
}
const sorted = [...records].sort((a, b) => compareSnowflakes(a.id, b.id));
const latest = sorted.slice(Math.max(0, sorted.length - UNREAD_PREVIEW_MESSAGE_LIMIT));
return {
channel: plan.channel,
messages: sorted.slice(0, UNREAD_PREVIEW_MESSAGE_LIMIT),
oldestUnreadMessageId: sorted[0]?.id ?? null,
messages: latest,
oldestUnreadMessageId: latest[0]?.id ?? null,
};
}
@@ -239,13 +241,37 @@ async function fetchPreviewBatch(plans: Array<PreviewFetchPlan>): Promise<void>
body: {requests: plans.map((plan) => plan.request)},
});
const responseChannels = new Map((response.body?.channels ?? []).map((entry) => [entry.channel_id, entry.messages]));
const retryPlans: Array<PreviewFetchPlan> = [];
for (const plan of plans) {
const messages = responseChannels.get(plan.channel.id) ?? [];
const isAnchored = plan.request.around != null || plan.request.after != null;
if (messages.length === 0 && isAnchored) {
retryPlans.push({
channel: plan.channel,
cacheKey: plan.cacheKey,
oldestUnreadMessageId: null,
request: {channel_id: plan.channel.id, limit: UNREAD_PREVIEW_MESSAGE_LIMIT},
});
continue;
}
previewCache.set(plan.channel.id, {
cacheKey: plan.cacheKey,
data: buildPreviewDataFromMessages(plan, messages),
});
}
if (retryPlans.length === 0) return;
const retryResponse = await http.post<BulkPreviewResponse>(Endpoints.CHANNEL_MESSAGES_BULK, {
body: {requests: retryPlans.map((plan) => plan.request)},
});
const retryChannels = new Map(
(retryResponse.body?.channels ?? []).map((entry) => [entry.channel_id, entry.messages]),
);
for (const plan of retryPlans) {
previewCache.set(plan.channel.id, {
cacheKey: plan.cacheKey,
data: buildPreviewDataFromMessages(plan, retryChannels.get(plan.channel.id) ?? []),
});
}
}
function chunkPlans(plans: Array<PreviewFetchPlan>): Array<Array<PreviewFetchPlan>> {
@@ -423,7 +449,8 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
messageRowClassName: styles.messageRow,
messageActionsClassName: styles.messageActions,
renderMessageActions,
readonlyPreview: true,
suppressMessageActions: true,
previewContext: MessagePreviewContext.LIST_POPOUT,
dateDividerClassName: styles.previewDateDivider,
suppressUnreadIndicator: true,
getMessageHeadingActivate,
@@ -468,7 +495,6 @@ const UnreadChannelCard = observer(function UnreadChannelCard({
</h2>
<InboxMessageHeader
channel={channel}
className={styles.channelStickyHeader}
onClick={handleHeaderClick}
mentionCount={mentionCount}
leftAdornment={
@@ -627,15 +653,11 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
frozenOrderRef.current = new Map(getUnreadChannels().map((channel, index) => [channel.id, index]));
}
const allUnreadChannels = useMemo(() => {
const order = frozenOrderRef.current!;
const current = getUnreadChannels();
return current
.filter((channel) => order.has(channel.id))
.sort((a, b) => {
const aIdx = order.get(a.id) ?? Number.POSITIVE_INFINITY;
const bIdx = order.get(b.id) ?? Number.POSITIVE_INFINITY;
return aIdx - bIdx;
});
const order = frozenOrderRef.current;
if (order == null) {
return [];
}
return mergeFrozenUnreadOrder(order, getUnreadChannels());
}, [readStateVersion, settingsVersion]);
const [loadedCount, setLoadedCount] = useState(INITIAL_VISIBLE_CHANNELS);
const visibleChannels = useMemo(() => allUnreadChannels.slice(0, loadedCount), [allUnreadChannels, loadedCount]);
@@ -672,6 +694,20 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
}
return groups;
}, [visibleChannels, channelPreviews, i18n.locale]);
const unreadRows = useMemo(() => {
const rows: Array<{channel: Channel; endsGroup: boolean; groupLabel: string; key: string}> = [];
for (const group of groupedChannels) {
for (const [index, channel] of group.channels.entries()) {
rows.push({
channel,
endsGroup: index === group.channels.length - 1,
groupLabel: group.label,
key: channel.id,
});
}
}
return rows;
}, [groupedChannels]);
const hasMore = loadedCount < allUnreadChannels.length;
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
@@ -751,34 +787,31 @@ export const UnreadChannelsContent = observer(function UnreadChannelsContent() {
data-message-selection-root="true"
data-flx="app.floating.unread-channels-content.scroller"
>
{groupedChannels.map((group) => {
const groupHeadingId = `inbox-unread-group-${group.key}`;
return (
<section
key={group.key}
className={styles.guildGroup}
aria-labelledby={groupHeadingId}
data-flx="app.floating.unread-channels-content.guild-group"
<div className={styles.list} role="list" data-flx="app.floating.unread-channels-content.list">
{unreadRows.map((row, rowIndex) => (
<div
key={row.key}
className={clsx(styles.row, row.endsGroup && styles.rowGroupEnd)}
role="listitem"
aria-posinset={rowIndex + 1}
aria-setsize={unreadRows.length}
data-flx="app.floating.unread-channels-content.row"
>
<h1
id={groupHeadingId}
className={styles.guildGroupHeading}
data-flx="app.floating.unread-channels-content.guild-group-heading"
<section
className={styles.guildGroup}
aria-label={row.groupLabel}
data-flx="app.floating.unread-channels-content.guild-group"
>
{group.label}
</h1>
{group.channels.map((channel) => (
<UnreadChannelCard
key={channel.id}
channel={channel}
headingId={`inbox-unread-channel-${channel.id}`}
previewData={channelPreviews.get(channel.id) ?? null}
channel={row.channel}
headingId={`inbox-unread-channel-${row.channel.id}`}
previewData={channelPreviews.get(row.channel.id) ?? null}
data-flx="app.floating.unread-channels-content.unread-channel-card"
/>
))}
</section>
);
})}
</section>
</div>
))}
</div>
</Scroller>
);
});
@@ -236,6 +236,8 @@
}
.accountMenuDiscriminator {
margin-left: 0.25rem;
font-size: 0.75rem;
color: var(--text-tertiary);
font-weight: 400;
}
@@ -301,7 +301,10 @@ const SwitchAccountsMenu = observer(
const isCurrent = account.userId === currentAccountId;
const avatarUrl = getAccountAvatarUrl(account);
const displayName = getAccountDisplayName(account, '???');
const discriminator = account.userData?.discriminator ?? '0000';
const userData = account.userData;
const accountTag = userData
? NicknameUtils.formatTagForStreamerMode(`${userData.username}#${userData.discriminator}`)
: displayName;
return (
<FocusRing
key={account.userId}
@@ -344,7 +347,7 @@ const SwitchAccountsMenu = observer(
className={styles.accountMenuDiscriminator}
data-flx="app.floating.user-area-popout.switch-accounts-menu.account-menu-discriminator"
>
#{discriminator}
{accountTag}
</span>
</span>
{isCurrent && (
@@ -515,7 +518,7 @@ export const UserAreaPopout = observer(() => {
const accentColor = getUserAccentColor(currentUser, profileData?.accent_color);
const borderColor = accentColor;
const bannerColor = accentColor;
const displayName = currentUser ? NicknameUtils.getNickname(currentUser) : '';
const displayName = currentUser ? NicknameUtils.getNickname(currentUser, null) : '';
const customStatus = currentUserId ? Presence.getCustomStatus(currentUserId) : null;
const hasCustomStatus = Boolean(normalizeCustomStatus(customStatus));
const popoutContainerRef = useRef<HTMLDivElement | null>(null);
@@ -15,11 +15,3 @@
height: 100%;
min-height: 0;
}
.appLayoutSplashCovered,
.appLayoutSplashCovered * {
user-select: none !important;
-webkit-user-select: none !important;
-moz-user-select: none !important;
-ms-user-select: none !important;
}
@@ -3,12 +3,10 @@
import styles from '@app/features/app/components/layout/AppLayout.module.css';
import {useAppLayoutState} from '@app/features/app/components/layout/app_layout/AppLayoutHooks';
import RequiredActionGate from '@app/features/app/components/layout/RequiredActionGate';
import {SplashScreen} from '@app/features/app/components/layout/SplashScreen';
import Initialization from '@app/features/app/state/Initialization';
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
import AccountManager from '@app/features/auth/state/AccountManager';
import Authentication from '@app/features/auth/state/Authentication';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager';
import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager';
@@ -21,10 +19,6 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
const isAuthenticated = Authentication.isAuthenticated;
const socket = GatewayConnection.socket;
const appState = useAppLayoutState();
const isSplashScreenActive =
isAuthenticated &&
!DeveloperOptions.bypassSplashScreen &&
(GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes);
useEffect(() => {
if (Initialization.isLoading) {
return;
@@ -40,16 +34,11 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
]);
return (
<>
{isAuthenticated && <SplashScreen data-flx="app.app-layout.splash-screen" />}
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
{isAuthenticated && <NewDeviceMonitoringManager data-flx="app.app-layout.new-device-monitoring-manager" />}
{isAuthenticated && <RequiredActionGate data-flx="app.app-layout.required-action-gate" />}
<div
className={clsx(
styles.appLayout,
appState.isStandalone && styles.appLayoutStandalone,
isSplashScreenActive && styles.appLayoutSplashCovered,
)}
className={clsx(styles.appLayout, appState.isStandalone && styles.appLayoutStandalone)}
data-flx="app.app-layout.app-layout"
>
{children}
@@ -5,6 +5,11 @@ import styles from '@app/features/app/components/layout/ChannelItem.module.css';
import {ChannelItemContent} from '@app/features/app/components/layout/ChannelItemContent';
import {ChannelItemIcon} from '@app/features/app/components/layout/ChannelItemIcon';
import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css';
import {
DirectSelectionSurface,
markDirectSelection,
peekDirectSelection,
} from '@app/features/app/components/layout/DirectSelectionOrigin';
import {
type ChannelReorderTarget,
canChannelDropOnTarget,
@@ -12,7 +17,7 @@ import {
} from '@app/features/app/components/layout/dnd/ChannelReorderStateMachine';
import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem';
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
import {isCategory, isTextChannel} from '@app/features/app/components/layout/utils/ChannelOrganization';
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
import {VoiceChannelUserCount} from '@app/features/app/components/layout/VoiceChannelUserCount';
@@ -221,7 +226,7 @@ export const ChannelItem = observer(
defaultHiddenForChannel: channelIsVoice,
enabled: !channelIsCategory,
});
const draggingChannel = activeDragItem?.type === DND_TYPES.CHANNEL ? activeDragItem : null;
const draggingChannel = activeDragItem?.type === DragItemType.CHANNEL ? activeDragItem : null;
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
const unreadCount = ReadStates.getUnreadCount(channel.id);
@@ -283,7 +288,7 @@ export const ChannelItem = observer(
const [dropIndicator, setDropIndicator] = useState<{position: 'top' | 'bottom'; isValid: boolean} | null>(null);
const dragItemData = useMemo<DragItem>(
() => ({
type: channelIsCategory ? DND_TYPES.CATEGORY : DND_TYPES.CHANNEL,
type: channelIsCategory ? DragItemType.CATEGORY : DragItemType.CHANNEL,
id: channel.id,
channelType: channel.type,
parentId: channel.parentId,
@@ -319,7 +324,7 @@ export const ChannelItem = observer(
);
const [{isOver}, dropRef] = useDrop(
() => ({
accept: [DND_TYPES.CHANNEL, DND_TYPES.CATEGORY, DND_TYPES.VOICE_PARTICIPANT],
accept: [DragItemType.CHANNEL, DragItemType.CATEGORY, DragItemType.VOICE_PARTICIPANT],
canDrop: (item: DragItem) => canChannelDropOnTarget(item, dropTargetData),
hover: (item: DragItem, monitor) => {
const node = dropTargetRef.current;
@@ -335,7 +340,7 @@ export const ChannelItem = observer(
setDropIndicator(null);
return;
}
if (item.type === DND_TYPES.VOICE_PARTICIPANT && channelIsVoice) {
if (item.type === DragItemType.VOICE_PARTICIPANT && channelIsVoice) {
const canMove = Permission.can(Permissions.MOVE_MEMBERS, {guildId: guild.id});
if (!canMove || item.currentChannelId === channel.id) {
setDropIndicator(null);
@@ -405,6 +410,7 @@ export const ChannelItem = observer(
CompactVoiceCallHeight.setExpandedForKey(getGuildVoiceCallExpansionKey(channel.id), false);
}, [channelIsVoice, channel.id]);
const handleSelect = useCallback(() => {
markDirectSelection(DirectSelectionSurface.CHANNEL_LIST);
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
onToggle?.();
return;
@@ -465,7 +471,8 @@ export const ChannelItem = observer(
const shouldShowSelectedState = !channelIsCategory && isSelected && (!channelIsVoice || isSelectedByPath);
const hasMountedRef = useRef(false);
useEffect(() => {
if (shouldShowSelectedState && hasMountedRef.current) {
const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.CHANNEL_LIST);
if (shouldShowSelectedState && hasMountedRef.current && !selectedFromThisRow) {
elementRef.current?.scrollIntoView({block: 'nearest'});
}
hasMountedRef.current = true;
@@ -14,7 +14,7 @@ import {GenericChannelItem} from '@app/features/app/components/layout/GenericCha
import {GuildDetachedBanner} from '@app/features/app/components/layout/GuildDetachedBanner';
import {NullSpaceDropIndicator} from '@app/features/app/components/layout/NullSpaceDropIndicator';
import {ScrollIndicatorOverlay} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
import {
shouldShowChannelInCollapsedCategory,
shouldShowChannelWhenHidingMutedChannels,
@@ -23,7 +23,17 @@ import {createChannelMoveOperation} from '@app/features/app/components/layout/ut
import {organizeChannels} from '@app/features/app/components/layout/utils/ChannelOrganization';
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
import {VoiceParticipantsList} from '@app/features/app/components/layout/VoiceParticipantsList';
import {
type RememberedSkeletonGuildChannelGroup,
type RememberedSkeletonGuildChannelRow,
reportSkeletonGuildChannelList,
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList';
import {
measureSkeletonTextWidthPx,
useSkeletonLayoutReport,
} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels';
import * as GuildCommands from '@app/features/guild/commands/GuildCommands';
import type {Guild} from '@app/features/guild/models/Guild';
@@ -90,6 +100,66 @@ const MEMBERS_PAGE_PERMISSIONS =
Permissions.MODERATE_MEMBERS |
Permissions.KICK_MEMBERS;
const EMPTY_ARRAY: ReadonlyArray<never> = Object.freeze([]);
interface ResolvedChannelGroup {
readonly key: string;
readonly category: Channel | undefined;
readonly isCollapsed: boolean;
readonly connectedChannelInGroup: boolean;
readonly filteredVoiceChannels: Array<Channel>;
readonly visibleTextChannels: ReadonlyArray<Channel>;
readonly visibleVoiceChannels: ReadonlyArray<Channel>;
readonly showTextChannels: boolean;
readonly showVoiceChannels: boolean;
}
function createChannelListProjectionKey(
groups: ReadonlyArray<ResolvedChannelGroup>,
membersRowVisible: boolean,
): string {
const groupKeys = groups.map((group) => {
const channelKeys = [...group.visibleTextChannels, ...group.visibleVoiceChannels].map(
(channel) => `${channel.id}~${channel.name}`,
);
return `${group.category?.id ?? ''}~${group.category?.name ?? ''}~${group.isCollapsed}~${channelKeys.join(',')}`;
});
return `${membersRowVisible}|${groupKeys.join('|')}`;
}
function createRememberedChannelGroups(
groups: ReadonlyArray<ResolvedChannelGroup>,
container: HTMLElement | null,
): ReadonlyArray<RememberedSkeletonGuildChannelGroup> {
const categoryNameElements = container?.querySelectorAll(`.${channelItemStyles.categoryName}`);
const channelNameElements = container?.querySelectorAll(`.${channelItemStyles.channelName}`);
let categoryNameIndex = 0;
let channelNameIndex = 0;
const readNextChannelWidth = (): number => {
const width = measureSkeletonTextWidthPx(channelNameElements?.item(channelNameIndex) ?? null);
channelNameIndex += 1;
return width;
};
return groups.map((group) => {
let categoryNameWidthPx = measureSkeletonTextWidthPx(null);
if (group.category != null) {
categoryNameWidthPx = measureSkeletonTextWidthPx(categoryNameElements?.item(categoryNameIndex) ?? null);
categoryNameIndex += 1;
}
const channels: Array<RememberedSkeletonGuildChannelRow> = [];
for (const _textChannel of group.visibleTextChannels) {
channels.push({voice: false, nameWidthPx: readNextChannelWidth()});
}
for (const _voiceChannel of group.visibleVoiceChannels) {
channels.push({voice: true, nameWidthPx: readNextChannelWidth()});
}
return {
categoryHeaderVisible: group.category != null,
collapsed: group.isCollapsed,
categoryNameWidthPx,
channels,
};
});
}
export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scrollY: MotionValue<number>}) => {
const {i18n} = useLingui();
const channels = Channels.getGuildChannels(guild.id);
@@ -99,11 +169,14 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
if (!monitor.isDragging()) return false;
const itemType = monitor.getItemType();
return (
itemType === DND_TYPES.CHANNEL || itemType === DND_TYPES.CATEGORY || itemType === DND_TYPES.VOICE_PARTICIPANT
itemType === DragItemType.CHANNEL ||
itemType === DragItemType.CATEGORY ||
itemType === DragItemType.VOICE_PARTICIPANT
);
});
const [activeDragItem, setActiveDragItem] = useState<DragItem | null>(null);
const scrollerRef = useRef<ScrollerHandle>(null);
const channelGroupsContainerRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(false);
const pendingScrollTopRef = useRef<number | null>(null);
const scrollPersistRafRef = useRef<number | null>(null);
@@ -283,6 +356,142 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
});
return unreadState.hasVisibleUnread;
};
const resolvedGroups: Array<ResolvedChannelGroup> = [];
for (const group of channelGroups) {
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
const isNullSpace = !group.category;
const isCategoryMuted = group.category ? UserGuildSettings.isChannelMuted(guild.id, group.category.id) : false;
let filteredTextChannels: Array<Channel>;
let filteredVoiceChannels: Array<Channel>;
if (hideMutedChannels) {
filteredTextChannels = [];
for (const ch of group.textChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
isSelected: ch.id === selectedChannelInGuildId,
isConnected: false,
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
})
) {
filteredTextChannels.push(ch);
}
}
filteredVoiceChannels = [];
for (const ch of group.voiceChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
isSelected: ch.id === selectedChannelInGuildId,
isConnected: ch.id === connectedChannelId,
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
})
) {
filteredVoiceChannels.push(ch);
}
}
} else {
filteredTextChannels = group.textChannels;
filteredVoiceChannels = group.voiceChannels;
}
let visibleTextChannels: ReadonlyArray<Channel> = filteredTextChannels;
let visibleVoiceChannels: ReadonlyArray<Channel> = filteredVoiceChannels;
let connectedChannelInGroup = false;
if (isCollapsed) {
const showTextSelected = selectedChannelInGuildId;
const showSet = new Set<string>();
for (const ch of filteredTextChannels) {
if (
shouldShowChannelInCollapsedCategory({
isCategoryMuted,
isSelected: ch.id === showTextSelected,
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
})
) {
showSet.add(ch.id);
}
}
if (showSet.size === 0) {
visibleTextChannels = EMPTY_ARRAY;
} else if (showSet.size === filteredTextChannels.length) {
visibleTextChannels = filteredTextChannels;
} else {
const next: Array<Channel> = [];
for (const ch of filteredTextChannels) if (showSet.has(ch.id)) next.push(ch);
visibleTextChannels = next;
}
const voiceSet = new Set<string>();
if (selectedChannelInGuildId) voiceSet.add(selectedChannelInGuildId);
if (connectedChannelId) {
for (const ch of filteredVoiceChannels) {
if (ch.id === connectedChannelId) {
connectedChannelInGroup = true;
voiceSet.add(connectedChannelId);
break;
}
}
}
for (const ch of filteredVoiceChannels) {
if (
shouldShowChannelInCollapsedCategory({
isCategoryMuted,
isSelected: ch.id === selectedChannelInGuildId,
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
})
) {
voiceSet.add(ch.id);
}
}
if (voiceSet.size === 0) {
visibleVoiceChannels = EMPTY_ARRAY;
} else {
const next: Array<Channel> = [];
let connectedRow: Channel | null = null;
for (const ch of filteredVoiceChannels) {
if (!voiceSet.has(ch.id)) continue;
if (ch.id === connectedChannelId) {
connectedRow = ch;
} else {
next.push(ch);
}
}
visibleVoiceChannels = connectedRow ? [connectedRow, ...next] : next;
}
}
if (isNullSpace && filteredTextChannels.length === 0 && filteredVoiceChannels.length === 0) {
continue;
}
if (
hideMutedChannels &&
group.category &&
filteredTextChannels.length === 0 &&
filteredVoiceChannels.length === 0
) {
continue;
}
resolvedGroups.push({
key: group.category?.id || 'null-space',
category: group.category,
isCollapsed,
connectedChannelInGroup,
filteredVoiceChannels,
visibleTextChannels,
visibleVoiceChannels,
showTextChannels: !isCollapsed || visibleTextChannels.length > 0,
showVoiceChannels: !isCollapsed || visibleVoiceChannels.length > 0,
});
}
useSkeletonLayoutReport(
() => {
reportSkeletonGuildChannelList(guild.id, {
membersRowVisible: canViewMembers,
groups: createRememberedChannelGroups(resolvedGroups, channelGroupsContainerRef.current),
});
},
`${guild.id}|${createChannelListProjectionKey(resolvedGroups, canViewMembers)}`,
);
return (
<div
className={styles.channelListScrollerWrapper}
@@ -352,156 +561,53 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
<div className={styles.membersSeparator} data-flx="app.channel-list-content.members-separator" />
</>
)}
<div className={styles.channelGroupsContainer} data-flx="app.channel-list-content.channel-groups-container">
{channelGroups.map((group) => {
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
const isNullSpace = !group.category;
const isCategoryMuted = group.category
? UserGuildSettings.isChannelMuted(guild.id, group.category.id)
: false;
type ChannelRow = (typeof group.textChannels)[number];
let filteredTextChannels: Array<ChannelRow>;
let filteredVoiceChannels: Array<ChannelRow>;
if (hideMutedChannels) {
filteredTextChannels = [];
for (const ch of group.textChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
isSelected: ch.id === selectedChannelInGuildId,
isConnected: false,
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
})
) {
filteredTextChannels.push(ch);
}
}
filteredVoiceChannels = [];
for (const ch of group.voiceChannels) {
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
if (
shouldShowChannelWhenHidingMutedChannels({
isMuted,
isSelected: ch.id === selectedChannelInGuildId,
isConnected: ch.id === connectedChannelId,
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
})
) {
filteredVoiceChannels.push(ch);
}
}
} else {
filteredTextChannels = group.textChannels;
filteredVoiceChannels = group.voiceChannels;
}
let visibleTextChannels: ReadonlyArray<ChannelRow> = filteredTextChannels;
let visibleVoiceChannels: ReadonlyArray<ChannelRow> = filteredVoiceChannels;
let connectedChannelInGroup = false;
if (isCollapsed) {
const showTextSelected = selectedChannelInGuildId;
const showSet = new Set<string>();
for (const ch of filteredTextChannels) {
if (
shouldShowChannelInCollapsedCategory({
isCategoryMuted,
isSelected: ch.id === showTextSelected,
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
})
) {
showSet.add(ch.id);
}
}
if (showSet.size === 0) {
visibleTextChannels = EMPTY_ARRAY;
} else if (showSet.size === filteredTextChannels.length) {
visibleTextChannels = filteredTextChannels;
} else {
const next: Array<ChannelRow> = [];
for (const ch of filteredTextChannels) if (showSet.has(ch.id)) next.push(ch);
visibleTextChannels = next;
}
const voiceSet = new Set<string>();
if (selectedChannelInGuildId) voiceSet.add(selectedChannelInGuildId);
if (connectedChannelId) {
for (const ch of filteredVoiceChannels) {
if (ch.id === connectedChannelId) {
connectedChannelInGroup = true;
voiceSet.add(connectedChannelId);
break;
}
}
}
for (const ch of filteredVoiceChannels) {
if (
shouldShowChannelInCollapsedCategory({
isCategoryMuted,
isSelected: ch.id === selectedChannelInGuildId,
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
})
) {
voiceSet.add(ch.id);
}
}
if (voiceSet.size === 0) {
visibleVoiceChannels = EMPTY_ARRAY;
} else {
const next: Array<ChannelRow> = [];
let connectedRow: ChannelRow | null = null;
for (const ch of filteredVoiceChannels) {
if (!voiceSet.has(ch.id)) continue;
if (ch.id === connectedChannelId) {
connectedRow = ch;
} else {
next.push(ch);
}
}
visibleVoiceChannels = connectedRow ? [connectedRow, ...next] : next;
}
}
if (isNullSpace && filteredTextChannels.length === 0 && filteredVoiceChannels.length === 0) {
return null;
}
if (
hideMutedChannels &&
group.category &&
filteredTextChannels.length === 0 &&
filteredVoiceChannels.length === 0
) {
return null;
}
const showTextChannels = !isCollapsed || visibleTextChannels.length > 0;
const showVoiceChannels = !isCollapsed || visibleVoiceChannels.length > 0;
return (
<div
key={group.category?.id || 'null-space'}
className={styles.channelGroup}
data-flx="app.channel-list-content.channel-group"
>
{group.category && (
<div
ref={channelGroupsContainerRef}
className={styles.channelGroupsContainer}
data-flx="app.channel-list-content.channel-groups-container"
>
{resolvedGroups.map((group) => (
<div key={group.key} className={styles.channelGroup} data-flx="app.channel-list-content.channel-group">
{group.category && (
<ChannelItem
guild={guild}
channel={group.category}
isCollapsed={group.isCollapsed}
onToggle={() => toggleCategory(group.category!.id)}
isDraggingAnything={isDraggingAnything}
activeDragItem={activeDragItem}
onChannelDrop={handleChannelDrop}
onDragStateChange={setActiveDragItem}
isSelectedByPath={selectedChannelInGuildId === group.category.id}
isOnMembersRoute={isMembersSelected}
data-flx="app.channel-list-content.channel-item"
/>
)}
{group.isCollapsed && group.category && !group.connectedChannelInGroup && (
<CollapsedCategoryVoiceParticipants
guild={guild}
voiceChannels={group.filteredVoiceChannels}
data-flx="app.channel-list-content.collapsed-category-voice-participants"
/>
)}
{group.showTextChannels &&
group.visibleTextChannels.map((ch) => (
<ChannelItem
key={ch.id}
guild={guild}
channel={group.category}
isCollapsed={isCollapsed}
onToggle={() => toggleCategory(group.category!.id)}
channel={ch}
isDraggingAnything={isDraggingAnything}
activeDragItem={activeDragItem}
onChannelDrop={handleChannelDrop}
onDragStateChange={setActiveDragItem}
isSelectedByPath={selectedChannelInGuildId === group.category.id}
isSelectedByPath={selectedChannelInGuildId === ch.id}
isOnMembersRoute={isMembersSelected}
data-flx="app.channel-list-content.channel-item"
data-flx="app.channel-list-content.channel-item--2"
/>
)}
{isCollapsed && group.category && !connectedChannelInGroup && (
<CollapsedCategoryVoiceParticipants
guild={guild}
voiceChannels={filteredVoiceChannels}
data-flx="app.channel-list-content.collapsed-category-voice-participants"
/>
)}
{showTextChannels &&
visibleTextChannels.map((ch) => (
))}
{group.showVoiceChannels &&
group.visibleVoiceChannels.map((ch) => {
const channelRow = (
<ChannelItem
key={ch.id}
guild={guild}
@@ -512,53 +618,36 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
onDragStateChange={setActiveDragItem}
isSelectedByPath={selectedChannelInGuildId === ch.id}
isOnMembersRoute={isMembersSelected}
data-flx="app.channel-list-content.channel-item--2"
data-flx="app.channel-list-content.channel-item--3"
/>
))}
{showVoiceChannels &&
visibleVoiceChannels.map((ch) => {
const channelRow = (
<ChannelItem
key={ch.id}
guild={guild}
channel={ch}
isDraggingAnything={isDraggingAnything}
activeDragItem={activeDragItem}
onChannelDrop={handleChannelDrop}
onDragStateChange={setActiveDragItem}
isSelectedByPath={selectedChannelInGuildId === ch.id}
isOnMembersRoute={isMembersSelected}
data-flx="app.channel-list-content.channel-item--3"
/>
);
if (isCollapsed && connectedChannelId && ch.id === connectedChannelId) {
return (
<React.Fragment key={ch.id}>
{channelRow}
<CollapsedChannelAvatarStack
guild={guild}
channel={ch}
data-flx="app.channel-list-content.collapsed-channel-avatar-stack"
/>
</React.Fragment>
);
}
);
if (group.isCollapsed && connectedChannelId && ch.id === connectedChannelId) {
return (
<React.Fragment key={ch.id}>
{channelRow}
{!isCollapsed && (
<VoiceParticipantsList
guild={guild}
channel={ch}
data-flx="app.channel-list-content.voice-participants-list"
/>
)}
<CollapsedChannelAvatarStack
guild={guild}
channel={ch}
data-flx="app.channel-list-content.collapsed-channel-avatar-stack"
/>
</React.Fragment>
);
})}
</div>
);
})}
}
return (
<React.Fragment key={ch.id}>
{channelRow}
{!group.isCollapsed && (
<VoiceParticipantsList
guild={guild}
channel={ch}
data-flx="app.channel-list-content.voice-participants-list"
/>
)}
</React.Fragment>
);
})}
</div>
))}
</div>
{showTrailingDropZone && (
<div className={styles.bottomDropZone} data-flx="app.channel-list-content.bottom-drop-zone">
@@ -1,47 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import styles from '@app/features/app/components/layout/GuildNavbarSkeleton.module.css';
import type React from 'react';
export const ChannelListSkeleton: React.FC = () => {
return (
<div className={styles.skeletonContent} data-flx="app.channel-list-skeleton.skeleton-content">
<div className={styles.skeletonCategory} data-flx="app.channel-list-skeleton.skeleton-category">
<div className={styles.skeletonCategoryPill} data-flx="app.channel-list-skeleton.skeleton-category-pill" />
</div>
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel">
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill" />
</div>
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--2">
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--2" />
</div>
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--3">
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--3" />
</div>
<div className={styles.skeletonCategory} data-flx="app.channel-list-skeleton.skeleton-category--2">
<div className={styles.skeletonCategoryPill} data-flx="app.channel-list-skeleton.skeleton-category-pill--2" />
</div>
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--4">
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--4" />
</div>
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--5">
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--5" />
</div>
<div className={styles.skeletonCategory} data-flx="app.channel-list-skeleton.skeleton-category--3">
<div className={styles.skeletonCategoryPill} data-flx="app.channel-list-skeleton.skeleton-category-pill--3" />
</div>
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--6">
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--6" />
</div>
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--7">
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--7" />
</div>
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--8">
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--8" />
</div>
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--9">
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--9" />
</div>
</div>
);
};
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const DirectSelectionSurface = Object.freeze({
GUILD_RAIL: 'guild-rail',
CHANNEL_LIST: 'channel-list',
FAVORITES_LIST: 'favorites-list',
} as const);
export type DirectSelectionSurface = (typeof DirectSelectionSurface)[keyof typeof DirectSelectionSurface];
const markedSurfaces = new Set<DirectSelectionSurface>();
export function markDirectSelection(surface: DirectSelectionSurface): void {
markedSurfaces.add(surface);
}
export function peekDirectSelection(surface: DirectSelectionSurface): boolean {
return markedSurfaces.has(surface);
}
export function consumeDirectSelection(surface: DirectSelectionSurface): boolean {
const marked = markedSurfaces.has(surface);
markedSurfaces.delete(surface);
return marked;
}
@@ -1,11 +1,13 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
.headerIconContainer {
display: flex;
align-items: center;
gap: 0.5rem;
}
.headerIcon {
.headerTitle {
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.75rem;
color: var(--text-primary);
}
@@ -5,8 +5,6 @@ import guildHeaderStyles from '@app/features/app/components/layout/GuildHeader.m
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {CompassIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
const EXPLORE_DESCRIPTOR = msg({
message: 'Explore',
@@ -17,7 +15,7 @@ export function DiscoveryGuildHeader() {
const {i18n} = useLingui();
return (
<div
className={clsx(guildHeaderStyles.headerContainer, guildHeaderStyles.headerContainerNoBanner)}
className={guildHeaderStyles.headerContainer}
style={{height: remFromPx(56)}}
data-flx="app.discovery-guild-header.div"
>
@@ -26,16 +24,9 @@ export function DiscoveryGuildHeader() {
style={{cursor: 'default'}}
data-flx="app.discovery-guild-header.div--2"
>
<div className={styles.headerIconContainer} data-flx="app.discovery-guild-header.header-icon-container">
<CompassIcon
weight="fill"
className={clsx(guildHeaderStyles.verifiedIconDefault, styles.headerIcon)}
data-flx="app.discovery-guild-header.header-icon"
/>
<span className={guildHeaderStyles.guildNameDefault} data-flx="app.discovery-guild-header.span">
{i18n._(EXPLORE_DESCRIPTOR)}
</span>
</div>
<h1 className={styles.headerTitle} data-flx="app.discovery-guild-header.header-title">
{i18n._(EXPLORE_DESCRIPTOR)}
</h1>
</div>
</div>
);

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