mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a14281b87 | ||
|
|
be69157811 | ||
|
|
45289b5531 | ||
|
|
30a1271774 | ||
|
|
438f11adda | ||
|
|
170ca805c5 | ||
|
|
f4547d11d3 | ||
|
|
ccdd85b099 | ||
|
|
98c40c6979 | ||
|
|
e054aa96be | ||
|
|
3e12466c57 | ||
|
|
039bd1a7df | ||
|
|
7a45d5844d | ||
|
|
410fa2dba9 | ||
|
|
4cb1808959 | ||
|
|
60a62c24c3 | ||
|
|
c06e958eb5 | ||
|
|
358b7c88fc | ||
|
|
1bced6abae |
@@ -14,6 +14,7 @@
|
||||
**/*.css.d.ts
|
||||
**/*.tsbuildinfo
|
||||
**/.cache/
|
||||
**/.swc/
|
||||
**/__pycache__/
|
||||
**/_build/
|
||||
**/coverage/
|
||||
|
||||
Generated
+10
@@ -1624,6 +1624,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "entities"
|
||||
version = "1.0.1"
|
||||
@@ -1931,6 +1940,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"encoding_rs",
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"hmac 0.13.0",
|
||||
|
||||
@@ -19,8 +19,6 @@ members = [
|
||||
exclude = [
|
||||
"fluxer_marketing",
|
||||
"packages/markdown_parser/rust/fuzz",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.19.16",
|
||||
"fluxer_desktop/native/webrtc-sender/vendor/tract-linalg-0.23.1",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!}));
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {GuildResponse, GuildVanityURLResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {z} from 'zod';
|
||||
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
|
||||
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
|
||||
import {createGuildID} from '../../BrandedTypes';
|
||||
import {LoginRequired} from '../../middleware/AuthMiddleware';
|
||||
@@ -49,6 +50,7 @@ export function GuildBaseController(app: HonoApp) {
|
||||
if (policy.single_community_enabled) {
|
||||
throw new SingleCommunityCannotCreateGuildsError();
|
||||
}
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const locale = ctx.get('requestLocale') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<!--{{FLUXER_BOOTSTRAP}}-->
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -200,6 +200,7 @@
|
||||
"@radix-ui/react-switch": "catalog:",
|
||||
"@simplewebauthn/browser": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
"animejs": "4.5.0",
|
||||
"bowser": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"codemirror": "^6.0.2",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {dirname, join, relative, resolve} from 'node:path';
|
||||
import {SKELETON_SURFACE_TOKENS} from '@app/features/app/components/skeleton/SkeletonSurfaceContract';
|
||||
|
||||
type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
|
||||
|
||||
@@ -407,6 +408,11 @@ function renderValueMap(name: string, values: ReadonlyMap<string, string>): stri
|
||||
return `export const ${name}: Readonly<Record<string, string>> = {\n${body}\n};`;
|
||||
}
|
||||
|
||||
function renderNameUnion(definitions: ReadonlyArray<VariableDefinition>): string {
|
||||
const body = definitions.map((definition) => `\t| ${JSON.stringify(definition.name)}`).join('\n');
|
||||
return `export type ThemeVariableName =\n${body};`;
|
||||
}
|
||||
|
||||
function renderDefinitions(definitions: ReadonlyArray<VariableDefinition>): string {
|
||||
const body = definitions
|
||||
.map(
|
||||
@@ -439,6 +445,8 @@ export interface ThemeVariableDefinition {
|
||||
\tsource: string;
|
||||
}
|
||||
|
||||
${renderNameUnion(definitions)}
|
||||
|
||||
${renderDefinitions(definitions)}
|
||||
|
||||
${renderStringArray(
|
||||
@@ -456,9 +464,361 @@ ${renderValueMap('THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES', lightDefaults)}
|
||||
`;
|
||||
}
|
||||
|
||||
interface SkeletonSurfaceInvariant {
|
||||
file: string;
|
||||
requires?: ReadonlyArray<string>;
|
||||
requiresPattern?: ReadonlyArray<string>;
|
||||
forbids?: ReadonlyArray<string>;
|
||||
forbidsPattern?: ReadonlyArray<string>;
|
||||
counts?: Readonly<Record<string, number>>;
|
||||
minimums?: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
const SKELETON_CHROME_BORDER_TOKEN = 'var(--skeleton-chrome-border)';
|
||||
|
||||
const SKELETON_SURFACE_INVARIANTS: ReadonlyArray<SkeletonSurfaceInvariant> = [
|
||||
{
|
||||
file: 'src/app/globals.css',
|
||||
counts: {
|
||||
'--skeleton-chrome-border-color': 2,
|
||||
'--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);': 1,
|
||||
'--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);': 1,
|
||||
'--chat-horizontal-padding-default: 1rem;': 1,
|
||||
'--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);': 1,
|
||||
'--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);': 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/ChatSkeleton.module.css',
|
||||
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 1},
|
||||
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
|
||||
forbids: ['scopeBadge', 'scope-badge'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/ChatSkeleton.tsx',
|
||||
requires: [
|
||||
"import composerWrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';",
|
||||
'composerWrapperStyles.composerRoot',
|
||||
"'--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx),",
|
||||
"'--font-size': remFromPx(messagePresentation.fontSizePx),",
|
||||
"'--message-group-spacing': remFromPx(messagePresentation.groupSpacingPx),",
|
||||
"'--message-compact-timestamp-width': remFromPx(messagePresentation.compactTimestampWidthPx),",
|
||||
],
|
||||
forbids: ['scopeBadge', 'scope-badge', '@tanstack/react-virtual'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/DiscoverySkeleton.module.css',
|
||||
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 2},
|
||||
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/FriendsSkeleton.module.css',
|
||||
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 4},
|
||||
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
|
||||
forbids: ['.activeNowPreview'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/FriendsSkeleton.tsx',
|
||||
forbids: ['LIVE_BADGE', 'CONTEXT_CHEVRON', 'ACTIVE_NOW_CONTEXT_ICON_SIZE'],
|
||||
requires: ['function resolveFriendsListSections('],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/GuildRailSkeleton.module.css',
|
||||
requires: [
|
||||
'.outageSlot {\n\tmin-height: var(--guild-list-item-box-size);\n}',
|
||||
'.sectionTrailingGap {\n\tpadding-bottom: var(--guild-list-item-gap);\n}',
|
||||
'.itemsTrailingGapCancel {\n\tmargin-bottom: calc(-1 * var(--guild-list-item-gap));\n}',
|
||||
'background-color: var(--guilds-layout-item-bg);',
|
||||
".item[data-selected='true'] .fluxerIcon",
|
||||
'clip-path: inset(0);',
|
||||
'\ttransform: translateY(0rem);',
|
||||
],
|
||||
forbids: ['--guild-list-indicator-', '::before'],
|
||||
counts: {'z-index: 0;': 3, 'z-index: 1;': 3},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/skeleton/GuildRailSkeleton.tsx',
|
||||
requires: [
|
||||
"import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css';",
|
||||
'guildStyles.guildIndicator',
|
||||
'guildStyles.guildIndicatorBar',
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
'const InlineDMPlaceholder = ',
|
||||
],
|
||||
requiresPattern: ['<ChatCircleIcon[^>]*weight="fill"[^>]*className=\\{styles\\.fluxerIconGlyph\\}'],
|
||||
forbids: ['data-indicator', 'GUILD_RAIL_INDICATOR_METRICS', 'Math.round'],
|
||||
counts: {'styles.sectionTrailingGap': 2, 'styles.itemsTrailingGapCancel': 1},
|
||||
},
|
||||
{
|
||||
file: 'src/features/ui/components/Scroller.module.css',
|
||||
counts: {'clip-path: inset(0);': 1},
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/MemberListSkeleton.tsx',
|
||||
counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2},
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/textarea/InputWrapper.module.css',
|
||||
requires: ['.composerRoot:has(.statusTypingSlot)::before'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.module.css',
|
||||
requires: [
|
||||
'.guildListScrollContainer.guildListScrollContainer {\n\toverflow-anchor: auto;\n}',
|
||||
'--layout-user-area-overlay-height',
|
||||
'\tpadding-bottom: calc(\n\t\tvar(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +\n\t\tvar(--spacing-2)\n\t);',
|
||||
'.messageBubbleIcon {\n\theight: 1.75rem;\n\twidth: 1.75rem;',
|
||||
'.guildIndicator {',
|
||||
'.guildIndicatorBar {',
|
||||
],
|
||||
forbids: ['.fluxerSymbolIcon'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.tsx',
|
||||
forbids: ['useListScrollAnchor', 'usePersistentScrollAnchor', '@tanstack/react-virtual'],
|
||||
counts: {'scrollNode.scrollTop = ': 2},
|
||||
},
|
||||
{
|
||||
file: 'src/features/discovery/discovery/DiscoveryPage.tsx',
|
||||
counts: {"import {useVirtualizer} from '@tanstack/react-virtual';": 1},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/LongPressable.ts',
|
||||
forbids: ['useImperativeHandle'],
|
||||
requires: ['useMergeRefs'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListIndicator.ts',
|
||||
requires: ['height: remFromPx(resolveGuildListIndicatorHeight(request))'],
|
||||
forbids: ['transform', 'scale', 'getAppZoomFactor', 'getAppRemScale'],
|
||||
counts: {
|
||||
'function resolveGuildListIndicatorHeight': 1,
|
||||
'export function resolveGuildListIndicatorBarTarget': 1,
|
||||
'export ': 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/FluxerButton.tsx',
|
||||
requires: [
|
||||
'const shouldShowHoverState = isHovering || contextMenuOpen;',
|
||||
'ChatCircleIcon',
|
||||
'styles.messageBubbleIcon',
|
||||
'styles.contextMenuHover',
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
],
|
||||
forbids: ['scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/FavoritesButton.tsx',
|
||||
requires: ['resolveGuildListIndicatorBarTarget('],
|
||||
forbids: ['scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx',
|
||||
requires: [
|
||||
'const shouldShowHoverState = isHovering || contextMenuOpen;',
|
||||
'guildStyles.contextMenuHover',
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
],
|
||||
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx',
|
||||
requires: [
|
||||
'const shouldShowHoverState = isHovering || contextMenuOpen;',
|
||||
'const FOLDER_BACKGROUND_FADE_TARGET = Object.freeze({opacity: 0});',
|
||||
"const FOLDER_GUILDS_COLLAPSE_TARGET = Object.freeze({opacity: 0, translateY: '-0.5rem'});",
|
||||
"to={{opacity: 1, translateY: '0rem'}}",
|
||||
'resolveGuildListIndicatorBarTarget(',
|
||||
'guildStyles.guildIndicatorBar',
|
||||
],
|
||||
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css',
|
||||
forbids: ['.folderIndicator'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemContent.tsx',
|
||||
requires: [
|
||||
'const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.GUILD_RAIL);',
|
||||
'if (isInitialMount || selectedFromThisRow || !props.isSelected) return;',
|
||||
],
|
||||
forbids: ['preserveInitialScrollPosition', '@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx',
|
||||
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
|
||||
},
|
||||
{
|
||||
file: 'src/features/channel/components/ChannelMembers.tsx',
|
||||
forbids: ['@tanstack/react-virtual'],
|
||||
requires: ['styles.virtualRow'],
|
||||
},
|
||||
];
|
||||
|
||||
const SKELETON_STYLESHEET_DIRECTORIES: ReadonlyArray<string> = [
|
||||
'src/features/app/components/skeleton',
|
||||
'src/features/channel/components',
|
||||
'src/features/user/components/profile',
|
||||
];
|
||||
const SKELETON_STYLESHEET_SUFFIX = 'Skeleton.module.css';
|
||||
const BORDER_DECLARATION_PATTERN =
|
||||
/(?<![\w-])border(?:-(?:top|right|bottom|left|block|inline|block-start|block-end|inline-start|inline-end))?\s*:\s*([^;}]+)/g;
|
||||
const RAW_COLOR_PATTERN = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\(/;
|
||||
|
||||
function collectSkeletonStylesheets(appDir: string): ReadonlyArray<string> {
|
||||
const files: Array<string> = [];
|
||||
const visit = (directory: string) => {
|
||||
for (const entry of readdirSync(directory, {withFileTypes: true})) {
|
||||
const absolutePath = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(SKELETON_STYLESHEET_SUFFIX)) continue;
|
||||
files.push(toPosixPath(relative(appDir, absolutePath)));
|
||||
}
|
||||
};
|
||||
for (const directory of SKELETON_STYLESHEET_DIRECTORIES) {
|
||||
visit(join(appDir, directory));
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
interface SkeletonSharedRuleSource {
|
||||
readonly file: string;
|
||||
readonly selectors: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
const SKELETON_SHARED_RULE_SOURCES: ReadonlyArray<SkeletonSharedRuleSource> = [
|
||||
{
|
||||
file: 'src/features/app/components/layout/GuildsLayout.module.css',
|
||||
selectors: ['.guildIndicator', '.guildIndicatorBar'],
|
||||
},
|
||||
];
|
||||
const CUSTOM_PROPERTY_REFERENCE_PATTERN = /var\(\s*(--[a-zA-Z0-9-]+)/g;
|
||||
|
||||
function assertSkeletonSharedRuleTokens(appDir: string): void {
|
||||
const violations: Array<string> = [];
|
||||
const declaredTokens = new Set<string>(SKELETON_SURFACE_TOKENS);
|
||||
for (const source of SKELETON_SHARED_RULE_SOURCES) {
|
||||
const contents = readFileSync(join(appDir, source.file), 'utf8');
|
||||
for (const selector of source.selectors) {
|
||||
const rule = new RegExp(`^${selector.replace('.', '\\.')}\\s*\\{([^}]*)\\}`, 'mu').exec(contents);
|
||||
if (rule == null) {
|
||||
violations.push(`${source.file} must declare ${selector}; skeleton markup renders through that rule.`);
|
||||
continue;
|
||||
}
|
||||
for (const match of rule[1].matchAll(CUSTOM_PROPERTY_REFERENCE_PATTERN)) {
|
||||
const token = match[1];
|
||||
if (declaredTokens.has(token)) continue;
|
||||
violations.push(
|
||||
`${source.file} ${selector} reads ${token}, which skeleton markup also renders through; ` +
|
||||
'list it in SKELETON_SURFACE_TOKENS.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const SKELETON_CHROME_BORDER_COLOR_DECLARATION = /--skeleton-chrome-border-color\s*:\s*([^;}]+)/g;
|
||||
|
||||
function assertSkeletonChromeBorderColourIsSoftened(appDir: string, violations: Array<string>): void {
|
||||
const file = 'src/app/globals.css';
|
||||
const contents = readFileSync(join(appDir, file), 'utf8');
|
||||
for (const match of contents.matchAll(SKELETON_CHROME_BORDER_COLOR_DECLARATION)) {
|
||||
const value = match[1].trim();
|
||||
if (value.startsWith('var(--background-modifier-accent')) {
|
||||
violations.push(
|
||||
`${file} declares --skeleton-chrome-border-color as ${JSON.stringify(value)}; ` +
|
||||
'skeleton chrome must use the softened colour, never the raw accent.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertSkeletonBorderTokens(appDir: string): void {
|
||||
const violations: Array<string> = [];
|
||||
assertSkeletonChromeBorderColourIsSoftened(appDir, violations);
|
||||
for (const file of collectSkeletonStylesheets(appDir)) {
|
||||
const contents = readFileSync(join(appDir, file), 'utf8');
|
||||
if (contents.includes('--skeleton-chrome-border-color')) {
|
||||
violations.push(
|
||||
`${file} names --skeleton-chrome-border-color directly; skeleton chrome must use ${SKELETON_CHROME_BORDER_TOKEN}.`,
|
||||
);
|
||||
}
|
||||
for (const match of contents.matchAll(BORDER_DECLARATION_PATTERN)) {
|
||||
const value = match[1].trim();
|
||||
if (RAW_COLOR_PATTERN.test(value) || value.includes('--background-modifier-accent')) {
|
||||
violations.push(
|
||||
`${file} writes a literal border colour in ${JSON.stringify(`border: ${value}`)}; ` +
|
||||
'skeleton borders must reference a shared token.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSkeletonSurfaceInvariants(appDir: string): void {
|
||||
const violations: Array<string> = [];
|
||||
for (const invariant of SKELETON_SURFACE_INVARIANTS) {
|
||||
const path = join(appDir, invariant.file);
|
||||
if (!existsSync(path)) {
|
||||
violations.push(`${invariant.file} is missing; a skeleton surface invariant depends on it.`);
|
||||
continue;
|
||||
}
|
||||
const contents = readFileSync(path, 'utf8');
|
||||
for (const required of invariant.requires ?? []) {
|
||||
if (!contents.includes(required)) {
|
||||
violations.push(`${invariant.file} must contain ${JSON.stringify(required)}.`);
|
||||
}
|
||||
}
|
||||
for (const forbidden of invariant.forbids ?? []) {
|
||||
if (contents.includes(forbidden)) {
|
||||
violations.push(`${invariant.file} must not contain ${JSON.stringify(forbidden)}.`);
|
||||
}
|
||||
}
|
||||
for (const source of invariant.requiresPattern ?? []) {
|
||||
if (!new RegExp(source, 'u').test(contents)) {
|
||||
violations.push(`${invariant.file} must match /${source}/.`);
|
||||
}
|
||||
}
|
||||
for (const source of invariant.forbidsPattern ?? []) {
|
||||
if (new RegExp(source, 'u').test(contents)) {
|
||||
violations.push(`${invariant.file} must not match /${source}/.`);
|
||||
}
|
||||
}
|
||||
for (const [needle, expected] of Object.entries(invariant.counts ?? {})) {
|
||||
const actual = contents.split(needle).length - 1;
|
||||
if (actual !== expected) {
|
||||
violations.push(`${invariant.file} must contain ${needle} exactly ${expected} time(s); found ${actual}.`);
|
||||
}
|
||||
}
|
||||
for (const [needle, minimum] of Object.entries(invariant.minimums ?? {})) {
|
||||
const actual = contents.split(needle).length - 1;
|
||||
if (actual < minimum) {
|
||||
violations.push(`${invariant.file} must contain ${needle} at least ${minimum} time(s); found ${actual}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) {
|
||||
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = resolve(scriptDir, '..');
|
||||
assertSkeletonSurfaceInvariants(appDir);
|
||||
assertSkeletonSharedRuleTokens(appDir);
|
||||
assertSkeletonBorderTokens(appDir);
|
||||
const outputPath = join(appDir, 'src', 'features', 'theme', 'variables', 'ThemeVariableManifest.ts');
|
||||
const contents = render(appDir);
|
||||
if (process.argv.includes('--check')) {
|
||||
|
||||
@@ -2,15 +2,11 @@
|
||||
|
||||
import {promises as fs} from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type {PrecacheEntry} from '@app/features/platform/service_worker/WorkerAppShell';
|
||||
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
interface PrecacheEntry {
|
||||
url: string;
|
||||
revision: string;
|
||||
}
|
||||
|
||||
const PRECACHE_ROOT_FILES = ['index.html', 'manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
|
||||
const PRECACHE_ROOT_FILES = ['manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
|
||||
|
||||
const NEVER_PRECACHED_EXTENSIONS = ['.woff', '.woff2', '.ttf', '.otf', '.eot'];
|
||||
|
||||
@@ -38,11 +34,7 @@ async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
|
||||
for (const file of PRECACHE_ROOT_FILES) {
|
||||
const filePath = path.join(DIST_DIR, file);
|
||||
try {
|
||||
const revision = await fileRevision(filePath);
|
||||
entries.set(`/${file}`, revision);
|
||||
if (file === 'index.html') {
|
||||
entries.set('/', revision);
|
||||
}
|
||||
entries.set(`/${file}`, await fileRevision(filePath));
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -19,11 +19,8 @@ import {useNativePlatform} from '@app/features/app/hooks/useNativePlatform';
|
||||
import {usePlatformClasses} from '@app/features/app/hooks/usePlatformClasses';
|
||||
import {useServiceWorkerBadge} from '@app/features/app/hooks/useServiceWorkerBadge';
|
||||
import {useTabKeyFocusGuard} from '@app/features/app/hooks/useTabKeyFocusGuard';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext';
|
||||
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
@@ -35,6 +32,7 @@ import {startDesktopLocaleBridge} from '@app/features/platform/utils/DesktopLoca
|
||||
import {PremiumCheckoutReturnWatcher} from '@app/features/premium/components/PremiumCheckoutReturnWatcher';
|
||||
import {QUICK_SWITCHER_PORTAL_ID} from '@app/features/search/components/quick_switcher/QuickSwitcherConstants';
|
||||
import {useCustomThemeStyle} from '@app/features/theme/hooks/useCustomThemeStyle';
|
||||
import {useRemScaleTracking} from '@app/features/theme/hooks/useRemScaleTracking';
|
||||
import {useThemeCssVariables} from '@app/features/theme/hooks/useThemeCssVariables';
|
||||
import Theme from '@app/features/theme/state/Theme';
|
||||
import ThemeLibrary from '@app/features/theme/state/ThemeLibrary';
|
||||
@@ -127,12 +125,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
}, []),
|
||||
);
|
||||
const handleSkipLinkFocus = useTabKeyFocusGuard();
|
||||
const isSplashScreenActive =
|
||||
Authentication.isAuthenticated &&
|
||||
!DeveloperOptions.bypassSplashScreen &&
|
||||
(GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes);
|
||||
useInertBackground(ringsContainerRef, hasBlockingModal || topPopoutRequiresBackdrop);
|
||||
useInertBackground(overlayScopeRef, isSplashScreenActive);
|
||||
useEffect(() => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
@@ -177,6 +170,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
}, [isNative, firstClickPassThroughWhenUnfocused]);
|
||||
useDesktopAllowTransparency(isNative);
|
||||
useWindowEventListeners({preventDocumentScroll: !isNative});
|
||||
useRemScaleTracking();
|
||||
usePlatformClasses(platform, isNative);
|
||||
useThemeCssVariables({
|
||||
effectiveTheme: Theme.effectiveTheme,
|
||||
|
||||
@@ -103,6 +103,25 @@ var {
|
||||
--input-wrapper-padding-bottom: 0.5rem;
|
||||
--textarea-top-bar-height: 2.5rem;
|
||||
|
||||
--chat-horizontal-padding-default: 1rem;
|
||||
--message-compact-horizontal-padding: min(
|
||||
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)),
|
||||
1rem
|
||||
);
|
||||
--textarea-font-size: var(--font-size, 1rem);
|
||||
--textarea-button-height: 2rem;
|
||||
--textarea-button-icon-size: 1.375rem;
|
||||
--textarea-button-compact-height: 2rem;
|
||||
--textarea-button-compact-icon-size: 1.25rem;
|
||||
--textarea-container-padding-x: 0px;
|
||||
--textarea-line-height: calc(var(--textarea-font-size) * 1.375);
|
||||
--textarea-content-offset: max(0rem, calc((var(--textarea-button-height) - var(--textarea-line-height)) / 2));
|
||||
--textarea-upload-gap: var(--message-gutter, 1rem);
|
||||
--textarea-side-button-padding: max(
|
||||
0px,
|
||||
calc((var(--message-avatar-size, 2.5rem) - var(--textarea-button-height)) / 2)
|
||||
);
|
||||
|
||||
--footer-box-height: 3.625rem;
|
||||
--footer-box-inset: 0.375rem;
|
||||
--footer-box-inset-inline: var(--footer-box-inset);
|
||||
@@ -111,8 +130,38 @@ var {
|
||||
--outline-frame-border-width: 0.0625rem;
|
||||
--composer-mobile-box-height: 3rem;
|
||||
--composer-action-gap: 0.25rem;
|
||||
--composer-surface-color: var(--background-secondary-lighter);
|
||||
--composer-status-line-height: 1.125rem;
|
||||
--composer-status-safe-gap: 0.5rem;
|
||||
--composer-status-safe-area: calc(
|
||||
var(--composer-status-line-height) +
|
||||
var(--composer-status-safe-gap) +
|
||||
var(--composer-status-safe-gap)
|
||||
);
|
||||
--guild-list-item-box-size: 3rem;
|
||||
--guild-list-item-gap: 0.375rem;
|
||||
--guild-list-item-target-size: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
|
||||
--guild-list-item-target-half-extra: calc((var(--guild-list-item-target-size) - var(--guild-list-item-box-size)) / 2);
|
||||
--guild-folder-expanded-surface-size: var(--guild-list-item-box-size);
|
||||
--folder-radius: calc(var(--guild-folder-expanded-surface-size) * 0.3);
|
||||
--mention-badge-small-size: 1.25rem;
|
||||
--mention-badge-medium-size: 1.5rem;
|
||||
--outline-frame-radius-native: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
--guild-list-divider-line-size: 0.125rem;
|
||||
--guild-list-divider-base-gap: var(--guild-list-item-gap);
|
||||
--guild-members-columns: minmax(15rem, 2.2fr) minmax(7.5rem, 1fr) minmax(7.5rem, 1fr) minmax(9.375rem, 1.15fr)
|
||||
minmax(10rem, 1.35fr) 4.5rem;
|
||||
--guild-members-min-width: calc(15rem + 7.5rem + 7.5rem + 9.375rem + 10rem + 4.5rem);
|
||||
--guild-members-select-column-width: 2.75rem;
|
||||
--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);
|
||||
--guild-members-min-width-selectable: calc(var(--guild-members-select-column-width) + var(--guild-members-min-width));
|
||||
--floating-surface-ring-color: color-mix(in srgb, var(--background-modifier-accent) 20%, transparent);
|
||||
--floating-surface-ring-color-strong: color-mix(in srgb, var(--background-modifier-accent) 45%, transparent);
|
||||
--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);
|
||||
--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);
|
||||
--skeleton-opacity-strong: 0.55;
|
||||
--skeleton-opacity-default: 0.45;
|
||||
--skeleton-opacity-muted: 0.35;
|
||||
|
||||
--footer-box-padding-y: max(0rem, calc((var(--footer-box-height) - var(--textarea-button-height, 2rem)) / 2));
|
||||
--composer-mobile-padding-y: max(
|
||||
@@ -124,10 +173,14 @@ var {
|
||||
--textarea-min-height: var(--footer-box-height);
|
||||
--textarea-padding-y: var(--footer-box-padding-y);
|
||||
--composer-box-inset: max(0rem, calc((var(--input-container-min-height) - var(--textarea-min-height)) / 2));
|
||||
--composer-box-inset-inline: min(var(--footer-box-inset), var(--chat-horizontal-padding, var(--spacing-4)));
|
||||
--messages-bottom-clearance: var(--composer-status-safe-area);
|
||||
--composer-box-inset-inline: min(
|
||||
var(--footer-box-inset),
|
||||
var(--chat-horizontal-padding, var(--chat-horizontal-padding-default))
|
||||
);
|
||||
--composer-box-padding-inline: max(
|
||||
0rem,
|
||||
calc(var(--chat-horizontal-padding, var(--spacing-4)) - var(--composer-box-inset-inline))
|
||||
calc(var(--chat-horizontal-padding, var(--chat-horizontal-padding-default)) - var(--composer-box-inset-inline))
|
||||
);
|
||||
|
||||
--typing-indicator-height: 1rem;
|
||||
@@ -167,6 +220,7 @@ var {
|
||||
--layout-header-height: 3.5rem;
|
||||
--layout-user-area-height: var(--footer-box-height);
|
||||
--layout-user-area-reserved-height: 0px;
|
||||
--layout-voice-connection-height: 0px;
|
||||
--layout-mobile-bottom-nav-reserved-height: 0px;
|
||||
--user-area-box-inset-block-end: calc(var(--footer-box-inset) + var(--outline-frame-border-width));
|
||||
--user-area-content-height: var(--textarea-button-height, 2rem);
|
||||
@@ -193,6 +247,90 @@ var {
|
||||
|
||||
--mobile-bottom-nav-height: 3.75rem;
|
||||
|
||||
--message-compact-text-indent-from-username: 2rem;
|
||||
--guilds-layout-item-bg: var(--guild-list-foreground);
|
||||
--guild-badge-surface: var(--background-secondary);
|
||||
--layout-member-list-width: 16.5rem;
|
||||
--layout-header-height-mobile: 4rem;
|
||||
|
||||
--channel-header-gap: var(--spacing-4);
|
||||
--channel-header-padding-inline: var(--spacing-4);
|
||||
--channel-header-action-size: 2rem;
|
||||
--channel-header-action-size-mobile: 2.5rem;
|
||||
--channel-header-actions-gap: var(--spacing-2);
|
||||
--channel-header-back-button-size: 2rem;
|
||||
--channel-header-back-button-gap: var(--spacing-3);
|
||||
--channel-header-icon-size: 1.5rem;
|
||||
--channel-header-back-icon-size: 1.5rem;
|
||||
--channel-header-caret-size: 1rem;
|
||||
--channel-header-action-icon-size: 1.5rem;
|
||||
--channel-header-name-gap: var(--spacing-3);
|
||||
--channel-header-name-gap-group-dm: var(--spacing-2);
|
||||
--channel-header-caret-gap: var(--spacing-1);
|
||||
--channel-header-topic-divider-gap: var(--spacing-2);
|
||||
|
||||
--chat-mobile-horizontal-padding: 0.75rem;
|
||||
--dm-sidebar-mobile-header-height: 3.5rem;
|
||||
|
||||
--message-search-bar-width: 15.25rem;
|
||||
--message-search-bar-height: 2.25rem;
|
||||
--message-search-bar-icon-size: 1rem;
|
||||
--message-search-bar-icon-gap: 0.5rem;
|
||||
|
||||
--member-list-item-content-height: 2rem;
|
||||
--member-list-item-padding-block: 0.25rem;
|
||||
--member-list-item-padding-inline: 0.5rem;
|
||||
--member-list-item-content-gap: 0.625rem;
|
||||
--member-list-item-name-line-height: 1.25rem;
|
||||
--member-list-item-status-line-height: 0.875rem;
|
||||
--member-list-row-gap: 0.125rem;
|
||||
--member-list-group-spacer-height: 0.25rem;
|
||||
--member-list-scroller-padding-block-start: 0.25rem;
|
||||
--member-list-scroller-padding-block-end: var(--spacing-4);
|
||||
--member-list-scroller-padding-inline: var(--spacing-2);
|
||||
|
||||
--friend-row-padding-block: 0.75rem;
|
||||
--friend-row-padding-inline: 1rem;
|
||||
--friend-row-separator-inset: 0 0.4rem 0 var(--spacing-2);
|
||||
--friend-row-separator-radius: 0.375rem;
|
||||
--friend-row-content-gap: 0.75rem;
|
||||
--friend-row-name-line-height: 1.25rem;
|
||||
--friend-row-subtext-line-height: 1rem;
|
||||
--friend-row-subtext-offset: -0.0625rem;
|
||||
--friend-row-avatar-size: 2.25rem;
|
||||
--friend-row-action-size: 2.25rem;
|
||||
--friend-row-action-gap: 0.5rem;
|
||||
|
||||
--avatar-stack-extra-height: 0.375rem;
|
||||
|
||||
--active-now-sidebar-width: 22rem;
|
||||
--active-now-sidebar-header-padding-inline: 1rem;
|
||||
--active-now-sidebar-header-padding-block-start: 1rem;
|
||||
--active-now-sidebar-header-padding-block-end: 0.75rem;
|
||||
--active-now-sidebar-content-gap: 0.5rem;
|
||||
--active-now-sidebar-content-padding-inline: 0.75rem;
|
||||
--active-now-sidebar-content-padding-block-end: 0.75rem;
|
||||
--active-now-card-gap: 0.75rem;
|
||||
--active-now-card-padding-block: 0.75rem;
|
||||
--active-now-card-padding-inline: 0.875rem;
|
||||
--active-now-card-radius: var(--radius-md);
|
||||
--active-now-card-shadow: 0 0.375rem 1.125rem rgba(0, 0, 0, 0.08);
|
||||
--active-now-context-gap: 0.375rem;
|
||||
--active-now-action-height: 2rem;
|
||||
--active-now-action-radius: 0.5rem;
|
||||
--active-now-action-gap: 0.375rem;
|
||||
--active-now-action-padding-block: 0.3125rem;
|
||||
--active-now-action-padding-inline: 0.625rem;
|
||||
|
||||
--discovery-card-description-min-height: 5.25rem;
|
||||
--guild-list-indicator-inset: -0.15rem;
|
||||
--guild-list-indicator-track-width: 0.5rem;
|
||||
--guild-list-indicator-bar-width: 0.35rem;
|
||||
--count-indicator-gap: 0.25rem;
|
||||
|
||||
--guild-members-table-row-min-height: 3rem;
|
||||
--guild-members-table-row-border-width: 0.0625rem;
|
||||
|
||||
--form-surface-background: var(--background-tertiary);
|
||||
--surface-interactive-hover-bg: var(--background-modifier-hover);
|
||||
--surface-interactive-selected-bg: var(--background-modifier-selected);
|
||||
@@ -209,6 +347,7 @@ html.platform-native.platform-macos {
|
||||
}
|
||||
|
||||
.theme-light {
|
||||
--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);
|
||||
--form-surface-background: var(--background-primary);
|
||||
--surface-interactive-hover-bg: var(--background-modifier-hover);
|
||||
--surface-interactive-selected-bg: var(--background-modifier-selected);
|
||||
|
||||
@@ -4,15 +4,21 @@ import {Routes} from '@app/app/Routes';
|
||||
import {isAutoRedirectExemptPath} from '@app/app/router/RouterConstants';
|
||||
import {KeyboardModeListener} from '@app/features/app/components/layout/KeyboardModeListener';
|
||||
import {MobileBottomNav} from '@app/features/app/components/layout/MobileBottomNav';
|
||||
import {SplashScreen} from '@app/features/app/components/layout/SplashScreen';
|
||||
import {SelfHostedSetupWizardGate} from '@app/features/app/components/setup/SelfHostedSetupWizardGate';
|
||||
import {AppSkeleton} from '@app/features/app/components/skeleton/AppSkeleton';
|
||||
import {useLoadingSkeletonRetention} from '@app/features/app/hooks/useLoadingSkeletonRetention';
|
||||
import {useSkeletonLayoutMemoryCapture} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import {
|
||||
shouldShowLoadingSkeleton as getShouldShowLoadingSkeleton,
|
||||
shouldShowMobileBottomNav,
|
||||
} from '@app/features/app/state/AppShellPresentationPolicy';
|
||||
import {isClientBooting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import {isHandoffRequest} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {setPathQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import {getDefaultLandingPath} from '@app/features/navigation/utils/DefaultLandingUtils';
|
||||
@@ -40,6 +46,7 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
const requiresSelfHostedSetup = RuntimeConfig.requiresSelfHostedSetup();
|
||||
const mobileLayoutState = MobileLayout;
|
||||
const [hasRestoredLocation, setHasRestoredLocation] = useState(false);
|
||||
const [effectiveSkeletonPathname, setEffectiveSkeletonPathname] = useState<string | null>(null);
|
||||
const currentUser = Users.currentUser;
|
||||
const [hasHandledNotificationNav, setHasHandledNotificationNav] = useState(false);
|
||||
const previousMobileLayoutStateRef = useRef(mobileLayoutState.enabled);
|
||||
@@ -77,13 +84,57 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
const shouldShowSelfHostedSetup = requiresSelfHostedSetup && !isStandaloneRoute;
|
||||
const shouldBypassGateway = requiresSelfHostedSetup || isStandaloneRoute;
|
||||
const authToken = Authentication.authToken;
|
||||
const protectedRouteBlocked = !canNavigateToProtectedRoutes && !shouldBypassGateway;
|
||||
const splashScreenCoversProtectedRoute =
|
||||
const shouldShowLoadingSkeleton = getShouldShowLoadingSkeleton({
|
||||
booting: isClientBooting(),
|
||||
isAuthenticated,
|
||||
isAuthSessionInitialized: SessionManager.isInitialized,
|
||||
shouldBypassGateway,
|
||||
});
|
||||
useSkeletonLayoutMemoryCapture(
|
||||
isAuthenticated && Initialization.isReady && !shouldBypassGateway && !shouldShowLoadingSkeleton,
|
||||
);
|
||||
const shouldDismissTransientOverlays = !isAuthenticated || shouldShowLoadingSkeleton;
|
||||
const {isLoadingSkeletonRetained, handleLoadingSkeletonTransitionEnd} = useLoadingSkeletonRetention({
|
||||
shouldShowLoadingSkeleton,
|
||||
});
|
||||
let pendingRestorableSkeletonPathname: string | null = null;
|
||||
if (
|
||||
isAuthenticated &&
|
||||
!DeveloperOptions.bypassSplashScreen &&
|
||||
!shouldBypassGateway &&
|
||||
(GatewayConnection.isConnectionInterrupted || !canNavigateToProtectedRoutes);
|
||||
const shouldDismissTransientOverlays = !isAuthenticated || protectedRouteBlocked || splashScreenCoversProtectedRoute;
|
||||
isLocationStateHydrated &&
|
||||
!AccountManager.isSwitching &&
|
||||
pathname === Routes.ME &&
|
||||
!hasRestoredLocation
|
||||
) {
|
||||
const lastLocation = Location.getLastLocation();
|
||||
if (lastLocation != null && lastLocation !== pathname) {
|
||||
pendingRestorableSkeletonPathname = lastLocation;
|
||||
}
|
||||
}
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
!isAuthenticated ||
|
||||
!isLocationStateHydrated ||
|
||||
AccountManager.isSwitching ||
|
||||
pathname !== Routes.ME ||
|
||||
hasRestoredLocation
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const lastLocation = Location.getLastLocation();
|
||||
if (lastLocation && lastLocation !== pathname) {
|
||||
setEffectiveSkeletonPathname(lastLocation);
|
||||
}
|
||||
}, [hasRestoredLocation, isAuthenticated, isLocationStateHydrated, pathname]);
|
||||
useLayoutEffect(() => {
|
||||
if (effectiveSkeletonPathname != null && pathname !== Routes.ME) {
|
||||
setEffectiveSkeletonPathname(null);
|
||||
}
|
||||
}, [effectiveSkeletonPathname, pathname]);
|
||||
useLayoutEffect(() => {
|
||||
if (!shouldShowLoadingSkeleton && !isLoadingSkeletonRetained && effectiveSkeletonPathname != null) {
|
||||
setEffectiveSkeletonPathname(null);
|
||||
}
|
||||
}, [effectiveSkeletonPathname, isLoadingSkeletonRetained, shouldShowLoadingSkeleton]);
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
void PushSubscriptionService.cleanupNativeDesktopWebPushSubscriptions('desktop-authenticated');
|
||||
@@ -298,25 +349,32 @@ export const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({
|
||||
navigateWithHistoryStack,
|
||||
normalizeInternalUrl,
|
||||
]);
|
||||
const showBottomNav =
|
||||
mobileLayoutState.enabled &&
|
||||
(location.pathname === Routes.ME ||
|
||||
location.pathname === Routes.FAVORITES ||
|
||||
location.pathname === Routes.NOTIFICATIONS ||
|
||||
location.pathname === Routes.YOU ||
|
||||
(Routes.isGuildChannelRoute(location.pathname) && location.pathname.split('/').length === 3));
|
||||
const showBottomNav = shouldShowMobileBottomNav({
|
||||
mobileLayoutEnabled: mobileLayoutState.enabled,
|
||||
pathname,
|
||||
});
|
||||
if (shouldShowSelfHostedSetup) {
|
||||
return <SelfHostedSetupWizardGate />;
|
||||
}
|
||||
if (isAuthenticated && !canNavigateToProtectedRoutes && !shouldBypassGateway) {
|
||||
return <SplashScreen data-flx="app.router.root-component.splash-screen" />;
|
||||
return <SelfHostedSetupWizardGate data-flx="app.router.root-component.self-hosted-setup-wizard-gate" />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<KeyboardModeListener data-flx="app.router.root-component.keyboard-mode-listener" />
|
||||
{children}
|
||||
{showBottomNav && currentUser && (
|
||||
<MobileBottomNav currentUser={currentUser} data-flx="app.router.root-component.mobile-bottom-nav" />
|
||||
{!shouldShowLoadingSkeleton && (
|
||||
<>
|
||||
<KeyboardModeListener data-flx="app.router.root-component.keyboard-mode-listener" />
|
||||
{children}
|
||||
{showBottomNav && currentUser && (
|
||||
<MobileBottomNav currentUser={currentUser} data-flx="app.router.root-component.mobile-bottom-nav" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(shouldShowLoadingSkeleton || isLoadingSkeletonRetained) && (
|
||||
<AppSkeleton
|
||||
key={`loading-skeleton:${AccountManager.currentUserId ?? 'unauthenticated'}`}
|
||||
effectivePathname={effectiveSkeletonPathname ?? pendingRestorableSkeletonPathname ?? pathname}
|
||||
isExiting={!shouldShowLoadingSkeleton}
|
||||
onTransitionEnd={handleLoadingSkeletonTransitionEnd}
|
||||
data-flx="app.router.root-component.app-skeleton"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getMessageGroupSpacingForDisplayMode,
|
||||
migrateLegacyMessageGroupSpacing,
|
||||
} from '@app/features/accessibility/state/MessageGroupSpacing';
|
||||
import {DEFAULT_MESSAGE_GUTTER_PX} from '@app/features/accessibility/state/MessagePresentationDefaults';
|
||||
import type {AnimatedMediaKind} from '@app/features/accessibility/state/MotionPreferencesMachine';
|
||||
import {
|
||||
AuthSessionStorageKey,
|
||||
@@ -667,7 +668,7 @@ class Accessibility {
|
||||
keepStickerAnimationUnderReducedMotion = false;
|
||||
messageGroupSpacing = COMFY_MESSAGE_GROUP_SPACING_DEFAULT;
|
||||
compactMessageGroupSpacing = COMPACT_MESSAGE_GROUP_SPACING_DEFAULT;
|
||||
messageGutter = 16;
|
||||
messageGutter = DEFAULT_MESSAGE_GUTTER_PX;
|
||||
fontSize = 16;
|
||||
showUserAvatarsInCompactMode = false;
|
||||
mobileStickerAnimationOverridden = false;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const DEFAULT_MESSAGE_GUTTER_PX = 16;
|
||||
@@ -0,0 +1,26 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import SessionManager from '@app/features/platform/state/AuthSession';
|
||||
|
||||
class StalledConnectionAccountSwitchUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('Cannot switch accounts from the current authentication session state');
|
||||
this.name = 'StalledConnectionAccountSwitchUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export function canSwitchAccountFromStalledConnection(): boolean {
|
||||
return SessionManager.canSwitchAccount() || SessionManager.isConnecting;
|
||||
}
|
||||
|
||||
export async function switchAccountFromStalledConnection(userId: string): Promise<void> {
|
||||
if (!canSwitchAccountFromStalledConnection()) {
|
||||
throw new StalledConnectionAccountSwitchUnavailableError();
|
||||
}
|
||||
SessionManager.requireAccountOnCurrentInstance(userId);
|
||||
if (SessionManager.isConnecting) {
|
||||
SessionManager.handleConnectionFailed();
|
||||
}
|
||||
await AccountManager.switchToAccount(userId, null);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--text-link);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
text-decoration: none;
|
||||
text-underline-offset: 0.125rem;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 0.125rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--text-tertiary);
|
||||
user-select: none;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/ConnectionIssuesLinks.module.css';
|
||||
import type {StatusPageIncident} from '@app/features/user/state/StatusPage';
|
||||
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
|
||||
interface ConnectionIssuesLinksProps {
|
||||
incident: StatusPageIncident | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STATUS_HISTORY_URL = `${ExternalUrls.SERVICE_STATUS}/history`;
|
||||
|
||||
export function ConnectionIssuesLinks({incident, className}: ConnectionIssuesLinksProps) {
|
||||
const containerClassName = className != null ? `${styles.container} ${className}` : styles.container;
|
||||
const incidentUrl = incident?.url ?? STATUS_HISTORY_URL;
|
||||
return (
|
||||
<div className={containerClassName} data-flx="app.connection-issues-links.div">
|
||||
<p className={styles.prompt} data-flx="app.connection-issues-links.prompt">
|
||||
<Trans>Connection issues?</Trans>
|
||||
</p>
|
||||
<p className={styles.links} data-flx="app.connection-issues-links.links">
|
||||
<a
|
||||
href={ExternalUrls.SERVICE_STATUS}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.link}
|
||||
data-flx="app.connection-issues-links.link"
|
||||
>
|
||||
<Trans>Status page</Trans>
|
||||
</a>
|
||||
<span aria-hidden="true" className={styles.separator} data-flx="app.connection-issues-links.separator">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.link}
|
||||
data-flx="app.connection-issues-links.link--2"
|
||||
>
|
||||
{incident ? <Trans>Read incident</Trans> : <Trans>Incident history</Trans>}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const LONG_PRESS_MOVEMENT_THRESHOLD = 10;
|
||||
const SWIPE_VELOCITY_THRESHOLD = 0.4;
|
||||
@@ -9,6 +10,7 @@ const MIN_VELOCITY_SAMPLES = 2;
|
||||
const MAX_VELOCITY_SAMPLE_AGE = 100;
|
||||
const PRESS_HIGHLIGHT_DELAY_MS = 100;
|
||||
const LONG_PRESS_DURATION_MS = 500;
|
||||
const LONG_PRESS_CLICK_SUPPRESSION_MS = 750;
|
||||
const HAS_POINTER_EVENTS = typeof window !== 'undefined' && 'PointerEvent' in window;
|
||||
|
||||
interface VelocitySample {
|
||||
@@ -53,8 +55,12 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
const storedEvent = useRef<LongPressEvent | null>(null);
|
||||
const velocitySamples = useRef<Array<VelocitySample>>([]);
|
||||
const isPressIntent = useRef(false);
|
||||
const pressScrollCancelHandler = useRef<(() => void) | null>(null);
|
||||
const clearTimerRef = useRef<() => void>(() => {});
|
||||
const suppressNextClickRef = useRef(false);
|
||||
const suppressClickTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
useImperativeHandle(forwardedRef, () => innerRef.current as HTMLElement);
|
||||
const composedRef = useMergeRefs<HTMLElement>([forwardedRef, innerRef]);
|
||||
const setPressed = useCallback(
|
||||
(pressed: boolean) => {
|
||||
setIsPressed(pressed);
|
||||
@@ -62,6 +68,21 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
},
|
||||
[onPressStateChange],
|
||||
);
|
||||
const clearSuppressedClick = useCallback(() => {
|
||||
suppressNextClickRef.current = false;
|
||||
if (suppressClickTimer.current) {
|
||||
clearTimeout(suppressClickTimer.current);
|
||||
suppressClickTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
const suppressNextClick = useCallback(() => {
|
||||
suppressNextClickRef.current = true;
|
||||
if (suppressClickTimer.current) clearTimeout(suppressClickTimer.current);
|
||||
suppressClickTimer.current = setTimeout(() => {
|
||||
suppressClickTimer.current = null;
|
||||
suppressNextClickRef.current = false;
|
||||
}, LONG_PRESS_CLICK_SUPPRESSION_MS);
|
||||
}, []);
|
||||
const calculateVelocity = useCallback((): number => {
|
||||
const samples = velocitySamples.current;
|
||||
if (samples.length < MIN_VELOCITY_SAMPLES) return 0;
|
||||
@@ -83,7 +104,24 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
return distance / dt;
|
||||
}, []);
|
||||
const detachPressScrollCancel = useCallback(() => {
|
||||
const handler = pressScrollCancelHandler.current;
|
||||
if (handler == null) return;
|
||||
pressScrollCancelHandler.current = null;
|
||||
window.removeEventListener('scroll', handler, {capture: true});
|
||||
}, []);
|
||||
const attachPressScrollCancel = useCallback(() => {
|
||||
if (pressScrollCancelHandler.current != null) return;
|
||||
const handler = () => {
|
||||
if (isPressIntent.current) {
|
||||
clearTimerRef.current();
|
||||
}
|
||||
};
|
||||
pressScrollCancelHandler.current = handler;
|
||||
window.addEventListener('scroll', handler, {capture: true, passive: true});
|
||||
}, []);
|
||||
const clearTimer = useCallback(() => {
|
||||
detachPressScrollCancel();
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
@@ -103,7 +141,8 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
velocitySamples.current = [];
|
||||
isPressIntent.current = false;
|
||||
setPressed(false);
|
||||
}, [setPressed]);
|
||||
}, [detachPressScrollCancel, setPressed]);
|
||||
clearTimerRef.current = clearTimer;
|
||||
const {
|
||||
onPointerDown: userOnPointerDown,
|
||||
onPointerMove: userOnPointerMove,
|
||||
@@ -136,6 +175,7 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
pointerIdRef.current = pointerId ?? null;
|
||||
velocitySamples.current = [{x, y, timestamp: performance.now()}];
|
||||
isPressIntent.current = true;
|
||||
attachPressScrollCancel();
|
||||
if (capturePointer && pointerId != null && innerRef.current?.setPointerCapture) {
|
||||
try {
|
||||
innerRef.current.setPointerCapture(pointerId);
|
||||
@@ -150,13 +190,14 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
}, PRESS_HIGHLIGHT_DELAY_MS);
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
if (!disabled && onLongPress && storedEvent.current && isPressIntent.current) {
|
||||
suppressNextClick();
|
||||
onLongPress(storedEvent.current);
|
||||
setPressed(false);
|
||||
}
|
||||
clearTimer();
|
||||
}, delay);
|
||||
},
|
||||
[clearTimer, delay, disabled, onLongPress, setPressed],
|
||||
[attachPressScrollCancel, clearTimer, delay, disabled, onLongPress, setPressed, suppressNextClick],
|
||||
);
|
||||
const handlePointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
@@ -255,26 +296,35 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
},
|
||||
[clearTimer, userOnTouchCancel],
|
||||
);
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (isPressIntent.current) {
|
||||
clearTimer();
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
if (suppressNextClickRef.current) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearSuppressedClick();
|
||||
return;
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll, {capture: true, passive: true});
|
||||
userOnClick?.(event);
|
||||
},
|
||||
[clearSuppressedClick, userOnClick],
|
||||
);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, {capture: true});
|
||||
detachPressScrollCancel();
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
}
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
}
|
||||
if (suppressClickTimer.current) {
|
||||
clearTimeout(suppressClickTimer.current);
|
||||
}
|
||||
};
|
||||
}, [clearTimer]);
|
||||
}, [detachPressScrollCancel]);
|
||||
const finalClassName = isPressed && pressedClassName ? `${className ?? ''} ${pressedClassName}`.trim() : className;
|
||||
return React.createElement(Component, {
|
||||
ref: innerRef,
|
||||
ref: composedRef,
|
||||
className: finalClassName,
|
||||
onPointerDown: HAS_POINTER_EVENTS ? handlePointerDown : undefined,
|
||||
onPointerMove: HAS_POINTER_EVENTS ? handlePointerMove : undefined,
|
||||
@@ -284,7 +334,7 @@ export const LongPressable = React.forwardRef<HTMLElement, LongPressableProps>(
|
||||
onTouchMove: !HAS_POINTER_EVENTS ? handleTouchMove : undefined,
|
||||
onTouchEnd: !HAS_POINTER_EVENTS ? handleTouchEnd : undefined,
|
||||
onTouchCancel: !HAS_POINTER_EVENTS ? handleTouchCancel : undefined,
|
||||
onClick: userOnClick,
|
||||
onClick: userOnClick ? handleClick : undefined,
|
||||
onKeyDown: handleKeyDown,
|
||||
...restWithoutPointer,
|
||||
'data-long-press-owner': !disabled && onLongPress ? 'true' : undefined,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {OutageSplashScreen} from '@app/features/app/components/layout/SplashScreen';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
const AUTO_RELOAD_BASE_MS = 15_000;
|
||||
const AUTO_RELOAD_JITTER_MS = 5_000;
|
||||
|
||||
export function NetworkErrorScreen() {
|
||||
useEffect(() => {
|
||||
const delay = AUTO_RELOAD_BASE_MS + Math.random() * AUTO_RELOAD_JITTER_MS;
|
||||
const timer = setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
return <OutageSplashScreen data-flx="app.network-error-screen.outage-splash-screen" />;
|
||||
}
|
||||
@@ -15,11 +15,3 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.appLayoutSplashCovered,
|
||||
.appLayoutSplashCovered * {
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
-moz-user-select: none !important;
|
||||
-ms-user-select: none !important;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
import styles from '@app/features/app/components/layout/AppLayout.module.css';
|
||||
import {useAppLayoutState} from '@app/features/app/components/layout/app_layout/AppLayoutHooks';
|
||||
import RequiredActionGate from '@app/features/app/components/layout/RequiredActionGate';
|
||||
import {SplashScreen} from '@app/features/app/components/layout/SplashScreen';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import * as AuthenticationCommands from '@app/features/auth/commands/AuthenticationCommands';
|
||||
import AccountManager from '@app/features/auth/state/AccountManager';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {NewDeviceMonitoringManager} from '@app/features/voice/components/NewDeviceMonitoringManager';
|
||||
import {VoiceReconnectionManager} from '@app/features/voice/components/VoiceReconnectionManager';
|
||||
@@ -21,10 +19,6 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
|
||||
const isAuthenticated = Authentication.isAuthenticated;
|
||||
const socket = GatewayConnection.socket;
|
||||
const appState = useAppLayoutState();
|
||||
const isSplashScreenActive =
|
||||
isAuthenticated &&
|
||||
!DeveloperOptions.bypassSplashScreen &&
|
||||
(GatewayConnection.isConnectionInterrupted || !Initialization.canNavigateToProtectedRoutes);
|
||||
useEffect(() => {
|
||||
if (Initialization.isLoading) {
|
||||
return;
|
||||
@@ -40,16 +34,11 @@ export const AppLayout = observer(({children}: {children: React.ReactNode}) => {
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated && <SplashScreen data-flx="app.app-layout.splash-screen" />}
|
||||
{isAuthenticated && socket && <VoiceReconnectionManager data-flx="app.app-layout.voice-reconnection-manager" />}
|
||||
{isAuthenticated && <NewDeviceMonitoringManager data-flx="app.app-layout.new-device-monitoring-manager" />}
|
||||
{isAuthenticated && <RequiredActionGate data-flx="app.app-layout.required-action-gate" />}
|
||||
<div
|
||||
className={clsx(
|
||||
styles.appLayout,
|
||||
appState.isStandalone && styles.appLayoutStandalone,
|
||||
isSplashScreenActive && styles.appLayoutSplashCovered,
|
||||
)}
|
||||
className={clsx(styles.appLayout, appState.isStandalone && styles.appLayoutStandalone)}
|
||||
data-flx="app.app-layout.app-layout"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -5,6 +5,11 @@ import styles from '@app/features/app/components/layout/ChannelItem.module.css';
|
||||
import {ChannelItemContent} from '@app/features/app/components/layout/ChannelItemContent';
|
||||
import {ChannelItemIcon} from '@app/features/app/components/layout/ChannelItemIcon';
|
||||
import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css';
|
||||
import {
|
||||
DirectSelectionSurface,
|
||||
markDirectSelection,
|
||||
peekDirectSelection,
|
||||
} from '@app/features/app/components/layout/DirectSelectionOrigin';
|
||||
import {
|
||||
type ChannelReorderTarget,
|
||||
canChannelDropOnTarget,
|
||||
@@ -12,7 +17,7 @@ import {
|
||||
} from '@app/features/app/components/layout/dnd/ChannelReorderStateMachine';
|
||||
import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem';
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {isCategory, isTextChannel} from '@app/features/app/components/layout/utils/ChannelOrganization';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {VoiceChannelUserCount} from '@app/features/app/components/layout/VoiceChannelUserCount';
|
||||
@@ -221,7 +226,7 @@ export const ChannelItem = observer(
|
||||
defaultHiddenForChannel: channelIsVoice,
|
||||
enabled: !channelIsCategory,
|
||||
});
|
||||
const draggingChannel = activeDragItem?.type === DND_TYPES.CHANNEL ? activeDragItem : null;
|
||||
const draggingChannel = activeDragItem?.type === DragItemType.CHANNEL ? activeDragItem : null;
|
||||
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
|
||||
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
|
||||
const unreadCount = ReadStates.getUnreadCount(channel.id);
|
||||
@@ -283,7 +288,7 @@ export const ChannelItem = observer(
|
||||
const [dropIndicator, setDropIndicator] = useState<{position: 'top' | 'bottom'; isValid: boolean} | null>(null);
|
||||
const dragItemData = useMemo<DragItem>(
|
||||
() => ({
|
||||
type: channelIsCategory ? DND_TYPES.CATEGORY : DND_TYPES.CHANNEL,
|
||||
type: channelIsCategory ? DragItemType.CATEGORY : DragItemType.CHANNEL,
|
||||
id: channel.id,
|
||||
channelType: channel.type,
|
||||
parentId: channel.parentId,
|
||||
@@ -319,7 +324,7 @@ export const ChannelItem = observer(
|
||||
);
|
||||
const [{isOver}, dropRef] = useDrop(
|
||||
() => ({
|
||||
accept: [DND_TYPES.CHANNEL, DND_TYPES.CATEGORY, DND_TYPES.VOICE_PARTICIPANT],
|
||||
accept: [DragItemType.CHANNEL, DragItemType.CATEGORY, DragItemType.VOICE_PARTICIPANT],
|
||||
canDrop: (item: DragItem) => canChannelDropOnTarget(item, dropTargetData),
|
||||
hover: (item: DragItem, monitor) => {
|
||||
const node = dropTargetRef.current;
|
||||
@@ -335,7 +340,7 @@ export const ChannelItem = observer(
|
||||
setDropIndicator(null);
|
||||
return;
|
||||
}
|
||||
if (item.type === DND_TYPES.VOICE_PARTICIPANT && channelIsVoice) {
|
||||
if (item.type === DragItemType.VOICE_PARTICIPANT && channelIsVoice) {
|
||||
const canMove = Permission.can(Permissions.MOVE_MEMBERS, {guildId: guild.id});
|
||||
if (!canMove || item.currentChannelId === channel.id) {
|
||||
setDropIndicator(null);
|
||||
@@ -405,6 +410,7 @@ export const ChannelItem = observer(
|
||||
CompactVoiceCallHeight.setExpandedForKey(getGuildVoiceCallExpansionKey(channel.id), false);
|
||||
}, [channelIsVoice, channel.id]);
|
||||
const handleSelect = useCallback(() => {
|
||||
markDirectSelection(DirectSelectionSurface.CHANNEL_LIST);
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
onToggle?.();
|
||||
return;
|
||||
@@ -465,7 +471,8 @@ export const ChannelItem = observer(
|
||||
const shouldShowSelectedState = !channelIsCategory && isSelected && (!channelIsVoice || isSelectedByPath);
|
||||
const hasMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (shouldShowSelectedState && hasMountedRef.current) {
|
||||
const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.CHANNEL_LIST);
|
||||
if (shouldShowSelectedState && hasMountedRef.current && !selectedFromThisRow) {
|
||||
elementRef.current?.scrollIntoView({block: 'nearest'});
|
||||
}
|
||||
hasMountedRef.current = true;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {GenericChannelItem} from '@app/features/app/components/layout/GenericCha
|
||||
import {GuildDetachedBanner} from '@app/features/app/components/layout/GuildDetachedBanner';
|
||||
import {NullSpaceDropIndicator} from '@app/features/app/components/layout/NullSpaceDropIndicator';
|
||||
import {ScrollIndicatorOverlay} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {
|
||||
shouldShowChannelInCollapsedCategory,
|
||||
shouldShowChannelWhenHidingMutedChannels,
|
||||
@@ -23,7 +23,17 @@ import {createChannelMoveOperation} from '@app/features/app/components/layout/ut
|
||||
import {organizeChannels} from '@app/features/app/components/layout/utils/ChannelOrganization';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {VoiceParticipantsList} from '@app/features/app/components/layout/VoiceParticipantsList';
|
||||
import {
|
||||
type RememberedSkeletonGuildChannelGroup,
|
||||
type RememberedSkeletonGuildChannelRow,
|
||||
reportSkeletonGuildChannelList,
|
||||
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {useRovingFocusList} from '@app/features/app/hooks/useRovingFocusList';
|
||||
import {
|
||||
measureSkeletonTextWidthPx,
|
||||
useSkeletonLayoutReport,
|
||||
} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import * as GuildCommands from '@app/features/guild/commands/GuildCommands';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
@@ -90,6 +100,66 @@ const MEMBERS_PAGE_PERMISSIONS =
|
||||
Permissions.MODERATE_MEMBERS |
|
||||
Permissions.KICK_MEMBERS;
|
||||
const EMPTY_ARRAY: ReadonlyArray<never> = Object.freeze([]);
|
||||
|
||||
interface ResolvedChannelGroup {
|
||||
readonly key: string;
|
||||
readonly category: Channel | undefined;
|
||||
readonly isCollapsed: boolean;
|
||||
readonly connectedChannelInGroup: boolean;
|
||||
readonly filteredVoiceChannels: Array<Channel>;
|
||||
readonly visibleTextChannels: ReadonlyArray<Channel>;
|
||||
readonly visibleVoiceChannels: ReadonlyArray<Channel>;
|
||||
readonly showTextChannels: boolean;
|
||||
readonly showVoiceChannels: boolean;
|
||||
}
|
||||
|
||||
function createChannelListProjectionKey(
|
||||
groups: ReadonlyArray<ResolvedChannelGroup>,
|
||||
membersRowVisible: boolean,
|
||||
): string {
|
||||
const groupKeys = groups.map((group) => {
|
||||
const channelKeys = [...group.visibleTextChannels, ...group.visibleVoiceChannels].map(
|
||||
(channel) => `${channel.id}~${channel.name}`,
|
||||
);
|
||||
return `${group.category?.id ?? ''}~${group.category?.name ?? ''}~${group.isCollapsed}~${channelKeys.join(',')}`;
|
||||
});
|
||||
return `${membersRowVisible}|${groupKeys.join('|')}`;
|
||||
}
|
||||
|
||||
function createRememberedChannelGroups(
|
||||
groups: ReadonlyArray<ResolvedChannelGroup>,
|
||||
container: HTMLElement | null,
|
||||
): ReadonlyArray<RememberedSkeletonGuildChannelGroup> {
|
||||
const categoryNameElements = container?.querySelectorAll(`.${channelItemStyles.categoryName}`);
|
||||
const channelNameElements = container?.querySelectorAll(`.${channelItemStyles.channelName}`);
|
||||
let categoryNameIndex = 0;
|
||||
let channelNameIndex = 0;
|
||||
const readNextChannelWidth = (): number => {
|
||||
const width = measureSkeletonTextWidthPx(channelNameElements?.item(channelNameIndex) ?? null);
|
||||
channelNameIndex += 1;
|
||||
return width;
|
||||
};
|
||||
return groups.map((group) => {
|
||||
let categoryNameWidthPx = measureSkeletonTextWidthPx(null);
|
||||
if (group.category != null) {
|
||||
categoryNameWidthPx = measureSkeletonTextWidthPx(categoryNameElements?.item(categoryNameIndex) ?? null);
|
||||
categoryNameIndex += 1;
|
||||
}
|
||||
const channels: Array<RememberedSkeletonGuildChannelRow> = [];
|
||||
for (const _textChannel of group.visibleTextChannels) {
|
||||
channels.push({voice: false, nameWidthPx: readNextChannelWidth()});
|
||||
}
|
||||
for (const _voiceChannel of group.visibleVoiceChannels) {
|
||||
channels.push({voice: true, nameWidthPx: readNextChannelWidth()});
|
||||
}
|
||||
return {
|
||||
categoryHeaderVisible: group.category != null,
|
||||
collapsed: group.isCollapsed,
|
||||
categoryNameWidthPx,
|
||||
channels,
|
||||
};
|
||||
});
|
||||
}
|
||||
export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scrollY: MotionValue<number>}) => {
|
||||
const {i18n} = useLingui();
|
||||
const channels = Channels.getGuildChannels(guild.id);
|
||||
@@ -99,11 +169,14 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
if (!monitor.isDragging()) return false;
|
||||
const itemType = monitor.getItemType();
|
||||
return (
|
||||
itemType === DND_TYPES.CHANNEL || itemType === DND_TYPES.CATEGORY || itemType === DND_TYPES.VOICE_PARTICIPANT
|
||||
itemType === DragItemType.CHANNEL ||
|
||||
itemType === DragItemType.CATEGORY ||
|
||||
itemType === DragItemType.VOICE_PARTICIPANT
|
||||
);
|
||||
});
|
||||
const [activeDragItem, setActiveDragItem] = useState<DragItem | null>(null);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const channelGroupsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(false);
|
||||
const pendingScrollTopRef = useRef<number | null>(null);
|
||||
const scrollPersistRafRef = useRef<number | null>(null);
|
||||
@@ -283,6 +356,142 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
});
|
||||
return unreadState.hasVisibleUnread;
|
||||
};
|
||||
const resolvedGroups: Array<ResolvedChannelGroup> = [];
|
||||
for (const group of channelGroups) {
|
||||
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
|
||||
const isNullSpace = !group.category;
|
||||
const isCategoryMuted = group.category ? UserGuildSettings.isChannelMuted(guild.id, group.category.id) : false;
|
||||
let filteredTextChannels: Array<Channel>;
|
||||
let filteredVoiceChannels: Array<Channel>;
|
||||
if (hideMutedChannels) {
|
||||
filteredTextChannels = [];
|
||||
for (const ch of group.textChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
filteredTextChannels.push(ch);
|
||||
}
|
||||
}
|
||||
filteredVoiceChannels = [];
|
||||
for (const ch of group.voiceChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: ch.id === connectedChannelId,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
filteredVoiceChannels.push(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filteredTextChannels = group.textChannels;
|
||||
filteredVoiceChannels = group.voiceChannels;
|
||||
}
|
||||
let visibleTextChannels: ReadonlyArray<Channel> = filteredTextChannels;
|
||||
let visibleVoiceChannels: ReadonlyArray<Channel> = filteredVoiceChannels;
|
||||
let connectedChannelInGroup = false;
|
||||
if (isCollapsed) {
|
||||
const showTextSelected = selectedChannelInGuildId;
|
||||
const showSet = new Set<string>();
|
||||
for (const ch of filteredTextChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === showTextSelected,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
showSet.add(ch.id);
|
||||
}
|
||||
}
|
||||
if (showSet.size === 0) {
|
||||
visibleTextChannels = EMPTY_ARRAY;
|
||||
} else if (showSet.size === filteredTextChannels.length) {
|
||||
visibleTextChannels = filteredTextChannels;
|
||||
} else {
|
||||
const next: Array<Channel> = [];
|
||||
for (const ch of filteredTextChannels) if (showSet.has(ch.id)) next.push(ch);
|
||||
visibleTextChannels = next;
|
||||
}
|
||||
const voiceSet = new Set<string>();
|
||||
if (selectedChannelInGuildId) voiceSet.add(selectedChannelInGuildId);
|
||||
if (connectedChannelId) {
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (ch.id === connectedChannelId) {
|
||||
connectedChannelInGroup = true;
|
||||
voiceSet.add(connectedChannelId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
voiceSet.add(ch.id);
|
||||
}
|
||||
}
|
||||
if (voiceSet.size === 0) {
|
||||
visibleVoiceChannels = EMPTY_ARRAY;
|
||||
} else {
|
||||
const next: Array<Channel> = [];
|
||||
let connectedRow: Channel | null = null;
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (!voiceSet.has(ch.id)) continue;
|
||||
if (ch.id === connectedChannelId) {
|
||||
connectedRow = ch;
|
||||
} else {
|
||||
next.push(ch);
|
||||
}
|
||||
}
|
||||
visibleVoiceChannels = connectedRow ? [connectedRow, ...next] : next;
|
||||
}
|
||||
}
|
||||
if (isNullSpace && filteredTextChannels.length === 0 && filteredVoiceChannels.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
hideMutedChannels &&
|
||||
group.category &&
|
||||
filteredTextChannels.length === 0 &&
|
||||
filteredVoiceChannels.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
resolvedGroups.push({
|
||||
key: group.category?.id || 'null-space',
|
||||
category: group.category,
|
||||
isCollapsed,
|
||||
connectedChannelInGroup,
|
||||
filteredVoiceChannels,
|
||||
visibleTextChannels,
|
||||
visibleVoiceChannels,
|
||||
showTextChannels: !isCollapsed || visibleTextChannels.length > 0,
|
||||
showVoiceChannels: !isCollapsed || visibleVoiceChannels.length > 0,
|
||||
});
|
||||
}
|
||||
useSkeletonLayoutReport(
|
||||
() => {
|
||||
reportSkeletonGuildChannelList(guild.id, {
|
||||
membersRowVisible: canViewMembers,
|
||||
groups: createRememberedChannelGroups(resolvedGroups, channelGroupsContainerRef.current),
|
||||
});
|
||||
},
|
||||
`${guild.id}|${createChannelListProjectionKey(resolvedGroups, canViewMembers)}`,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.channelListScrollerWrapper}
|
||||
@@ -352,156 +561,53 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
<div className={styles.membersSeparator} data-flx="app.channel-list-content.members-separator" />
|
||||
</>
|
||||
)}
|
||||
<div className={styles.channelGroupsContainer} data-flx="app.channel-list-content.channel-groups-container">
|
||||
{channelGroups.map((group) => {
|
||||
const isCollapsed = group.category ? (collapsedCategories?.has(group.category.id) ?? false) : false;
|
||||
const isNullSpace = !group.category;
|
||||
const isCategoryMuted = group.category
|
||||
? UserGuildSettings.isChannelMuted(guild.id, group.category.id)
|
||||
: false;
|
||||
type ChannelRow = (typeof group.textChannels)[number];
|
||||
let filteredTextChannels: Array<ChannelRow>;
|
||||
let filteredVoiceChannels: Array<ChannelRow>;
|
||||
if (hideMutedChannels) {
|
||||
filteredTextChannels = [];
|
||||
for (const ch of group.textChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: false,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
filteredTextChannels.push(ch);
|
||||
}
|
||||
}
|
||||
filteredVoiceChannels = [];
|
||||
for (const ch of group.voiceChannels) {
|
||||
const isMuted = UserGuildSettings.isChannelMuted(guild.id, ch.id);
|
||||
if (
|
||||
shouldShowChannelWhenHidingMutedChannels({
|
||||
isMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
isConnected: ch.id === connectedChannelId,
|
||||
hasVisibleUnread: isMuted && hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
filteredVoiceChannels.push(ch);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filteredTextChannels = group.textChannels;
|
||||
filteredVoiceChannels = group.voiceChannels;
|
||||
}
|
||||
let visibleTextChannels: ReadonlyArray<ChannelRow> = filteredTextChannels;
|
||||
let visibleVoiceChannels: ReadonlyArray<ChannelRow> = filteredVoiceChannels;
|
||||
let connectedChannelInGroup = false;
|
||||
if (isCollapsed) {
|
||||
const showTextSelected = selectedChannelInGuildId;
|
||||
const showSet = new Set<string>();
|
||||
for (const ch of filteredTextChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === showTextSelected,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
showSet.add(ch.id);
|
||||
}
|
||||
}
|
||||
if (showSet.size === 0) {
|
||||
visibleTextChannels = EMPTY_ARRAY;
|
||||
} else if (showSet.size === filteredTextChannels.length) {
|
||||
visibleTextChannels = filteredTextChannels;
|
||||
} else {
|
||||
const next: Array<ChannelRow> = [];
|
||||
for (const ch of filteredTextChannels) if (showSet.has(ch.id)) next.push(ch);
|
||||
visibleTextChannels = next;
|
||||
}
|
||||
const voiceSet = new Set<string>();
|
||||
if (selectedChannelInGuildId) voiceSet.add(selectedChannelInGuildId);
|
||||
if (connectedChannelId) {
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (ch.id === connectedChannelId) {
|
||||
connectedChannelInGroup = true;
|
||||
voiceSet.add(connectedChannelId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (
|
||||
shouldShowChannelInCollapsedCategory({
|
||||
isCategoryMuted,
|
||||
isSelected: ch.id === selectedChannelInGuildId,
|
||||
hasVisibleUnread: hasVisibleUnreadInChannel(ch.id),
|
||||
})
|
||||
) {
|
||||
voiceSet.add(ch.id);
|
||||
}
|
||||
}
|
||||
if (voiceSet.size === 0) {
|
||||
visibleVoiceChannels = EMPTY_ARRAY;
|
||||
} else {
|
||||
const next: Array<ChannelRow> = [];
|
||||
let connectedRow: ChannelRow | null = null;
|
||||
for (const ch of filteredVoiceChannels) {
|
||||
if (!voiceSet.has(ch.id)) continue;
|
||||
if (ch.id === connectedChannelId) {
|
||||
connectedRow = ch;
|
||||
} else {
|
||||
next.push(ch);
|
||||
}
|
||||
}
|
||||
visibleVoiceChannels = connectedRow ? [connectedRow, ...next] : next;
|
||||
}
|
||||
}
|
||||
if (isNullSpace && filteredTextChannels.length === 0 && filteredVoiceChannels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
hideMutedChannels &&
|
||||
group.category &&
|
||||
filteredTextChannels.length === 0 &&
|
||||
filteredVoiceChannels.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const showTextChannels = !isCollapsed || visibleTextChannels.length > 0;
|
||||
const showVoiceChannels = !isCollapsed || visibleVoiceChannels.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={group.category?.id || 'null-space'}
|
||||
className={styles.channelGroup}
|
||||
data-flx="app.channel-list-content.channel-group"
|
||||
>
|
||||
{group.category && (
|
||||
<div
|
||||
ref={channelGroupsContainerRef}
|
||||
className={styles.channelGroupsContainer}
|
||||
data-flx="app.channel-list-content.channel-groups-container"
|
||||
>
|
||||
{resolvedGroups.map((group) => (
|
||||
<div key={group.key} className={styles.channelGroup} data-flx="app.channel-list-content.channel-group">
|
||||
{group.category && (
|
||||
<ChannelItem
|
||||
guild={guild}
|
||||
channel={group.category}
|
||||
isCollapsed={group.isCollapsed}
|
||||
onToggle={() => toggleCategory(group.category!.id)}
|
||||
isDraggingAnything={isDraggingAnything}
|
||||
activeDragItem={activeDragItem}
|
||||
onChannelDrop={handleChannelDrop}
|
||||
onDragStateChange={setActiveDragItem}
|
||||
isSelectedByPath={selectedChannelInGuildId === group.category.id}
|
||||
isOnMembersRoute={isMembersSelected}
|
||||
data-flx="app.channel-list-content.channel-item"
|
||||
/>
|
||||
)}
|
||||
{group.isCollapsed && group.category && !group.connectedChannelInGroup && (
|
||||
<CollapsedCategoryVoiceParticipants
|
||||
guild={guild}
|
||||
voiceChannels={group.filteredVoiceChannels}
|
||||
data-flx="app.channel-list-content.collapsed-category-voice-participants"
|
||||
/>
|
||||
)}
|
||||
{group.showTextChannels &&
|
||||
group.visibleTextChannels.map((ch) => (
|
||||
<ChannelItem
|
||||
key={ch.id}
|
||||
guild={guild}
|
||||
channel={group.category}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleCategory(group.category!.id)}
|
||||
channel={ch}
|
||||
isDraggingAnything={isDraggingAnything}
|
||||
activeDragItem={activeDragItem}
|
||||
onChannelDrop={handleChannelDrop}
|
||||
onDragStateChange={setActiveDragItem}
|
||||
isSelectedByPath={selectedChannelInGuildId === group.category.id}
|
||||
isSelectedByPath={selectedChannelInGuildId === ch.id}
|
||||
isOnMembersRoute={isMembersSelected}
|
||||
data-flx="app.channel-list-content.channel-item"
|
||||
data-flx="app.channel-list-content.channel-item--2"
|
||||
/>
|
||||
)}
|
||||
{isCollapsed && group.category && !connectedChannelInGroup && (
|
||||
<CollapsedCategoryVoiceParticipants
|
||||
guild={guild}
|
||||
voiceChannels={filteredVoiceChannels}
|
||||
data-flx="app.channel-list-content.collapsed-category-voice-participants"
|
||||
/>
|
||||
)}
|
||||
{showTextChannels &&
|
||||
visibleTextChannels.map((ch) => (
|
||||
))}
|
||||
{group.showVoiceChannels &&
|
||||
group.visibleVoiceChannels.map((ch) => {
|
||||
const channelRow = (
|
||||
<ChannelItem
|
||||
key={ch.id}
|
||||
guild={guild}
|
||||
@@ -512,53 +618,36 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
onDragStateChange={setActiveDragItem}
|
||||
isSelectedByPath={selectedChannelInGuildId === ch.id}
|
||||
isOnMembersRoute={isMembersSelected}
|
||||
data-flx="app.channel-list-content.channel-item--2"
|
||||
data-flx="app.channel-list-content.channel-item--3"
|
||||
/>
|
||||
))}
|
||||
{showVoiceChannels &&
|
||||
visibleVoiceChannels.map((ch) => {
|
||||
const channelRow = (
|
||||
<ChannelItem
|
||||
key={ch.id}
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
isDraggingAnything={isDraggingAnything}
|
||||
activeDragItem={activeDragItem}
|
||||
onChannelDrop={handleChannelDrop}
|
||||
onDragStateChange={setActiveDragItem}
|
||||
isSelectedByPath={selectedChannelInGuildId === ch.id}
|
||||
isOnMembersRoute={isMembersSelected}
|
||||
data-flx="app.channel-list-content.channel-item--3"
|
||||
/>
|
||||
);
|
||||
if (isCollapsed && connectedChannelId && ch.id === connectedChannelId) {
|
||||
return (
|
||||
<React.Fragment key={ch.id}>
|
||||
{channelRow}
|
||||
<CollapsedChannelAvatarStack
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
data-flx="app.channel-list-content.collapsed-channel-avatar-stack"
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
);
|
||||
if (group.isCollapsed && connectedChannelId && ch.id === connectedChannelId) {
|
||||
return (
|
||||
<React.Fragment key={ch.id}>
|
||||
{channelRow}
|
||||
{!isCollapsed && (
|
||||
<VoiceParticipantsList
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
data-flx="app.channel-list-content.voice-participants-list"
|
||||
/>
|
||||
)}
|
||||
<CollapsedChannelAvatarStack
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
data-flx="app.channel-list-content.collapsed-channel-avatar-stack"
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}
|
||||
return (
|
||||
<React.Fragment key={ch.id}>
|
||||
{channelRow}
|
||||
{!group.isCollapsed && (
|
||||
<VoiceParticipantsList
|
||||
guild={guild}
|
||||
channel={ch}
|
||||
data-flx="app.channel-list-content.voice-participants-list"
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showTrailingDropZone && (
|
||||
<div className={styles.bottomDropZone} data-flx="app.channel-list-content.bottom-drop-zone">
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/GuildNavbarSkeleton.module.css';
|
||||
import type React from 'react';
|
||||
|
||||
export const ChannelListSkeleton: React.FC = () => {
|
||||
return (
|
||||
<div className={styles.skeletonContent} data-flx="app.channel-list-skeleton.skeleton-content">
|
||||
<div className={styles.skeletonCategory} data-flx="app.channel-list-skeleton.skeleton-category">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.channel-list-skeleton.skeleton-category-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--2">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--2" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--3">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--3" />
|
||||
</div>
|
||||
<div className={styles.skeletonCategory} data-flx="app.channel-list-skeleton.skeleton-category--2">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.channel-list-skeleton.skeleton-category-pill--2" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--4">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--4" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--5">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--5" />
|
||||
</div>
|
||||
<div className={styles.skeletonCategory} data-flx="app.channel-list-skeleton.skeleton-category--3">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.channel-list-skeleton.skeleton-category-pill--3" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--6">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--6" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--7">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--7" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--8">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--8" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.channel-list-skeleton.skeleton-channel--9">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.channel-list-skeleton.skeleton-channel-pill--9" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const DirectSelectionSurface = Object.freeze({
|
||||
GUILD_RAIL: 'guild-rail',
|
||||
CHANNEL_LIST: 'channel-list',
|
||||
FAVORITES_LIST: 'favorites-list',
|
||||
} as const);
|
||||
|
||||
export type DirectSelectionSurface = (typeof DirectSelectionSurface)[keyof typeof DirectSelectionSurface];
|
||||
|
||||
const markedSurfaces = new Set<DirectSelectionSurface>();
|
||||
|
||||
export function markDirectSelection(surface: DirectSelectionSurface): void {
|
||||
markedSurfaces.add(surface);
|
||||
}
|
||||
|
||||
export function peekDirectSelection(surface: DirectSelectionSurface): boolean {
|
||||
return markedSurfaces.has(surface);
|
||||
}
|
||||
|
||||
export function consumeDirectSelection(surface: DirectSelectionSurface): boolean {
|
||||
const marked = markedSurfaces.has(surface);
|
||||
markedSurfaces.delete(surface);
|
||||
return marked;
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.headerIconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.headerIcon {
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import guildHeaderStyles from '@app/features/app/components/layout/GuildHeader.m
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CompassIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
|
||||
const EXPLORE_DESCRIPTOR = msg({
|
||||
message: 'Explore',
|
||||
@@ -17,7 +15,7 @@ export function DiscoveryGuildHeader() {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<div
|
||||
className={clsx(guildHeaderStyles.headerContainer, guildHeaderStyles.headerContainerNoBanner)}
|
||||
className={guildHeaderStyles.headerContainer}
|
||||
style={{height: remFromPx(56)}}
|
||||
data-flx="app.discovery-guild-header.div"
|
||||
>
|
||||
@@ -26,16 +24,9 @@ export function DiscoveryGuildHeader() {
|
||||
style={{cursor: 'default'}}
|
||||
data-flx="app.discovery-guild-header.div--2"
|
||||
>
|
||||
<div className={styles.headerIconContainer} data-flx="app.discovery-guild-header.header-icon-container">
|
||||
<CompassIcon
|
||||
weight="fill"
|
||||
className={clsx(guildHeaderStyles.verifiedIconDefault, styles.headerIcon)}
|
||||
data-flx="app.discovery-guild-header.header-icon"
|
||||
/>
|
||||
<span className={guildHeaderStyles.guildNameDefault} data-flx="app.discovery-guild-header.span">
|
||||
{i18n._(EXPLORE_DESCRIPTOR)}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className={styles.headerTitle} data-flx="app.discovery-guild-header.header-title">
|
||||
{i18n._(EXPLORE_DESCRIPTOR)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,13 +6,19 @@ import channelItemStyles from '@app/features/app/components/layout/ChannelItem.m
|
||||
import {ChannelItemIcon} from '@app/features/app/components/layout/ChannelItemIcon';
|
||||
import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css';
|
||||
import styles from '@app/features/app/components/layout/ChannelListContent.module.css';
|
||||
import {ChannelListSkeleton} from '@app/features/app/components/layout/ChannelListSkeleton';
|
||||
import {
|
||||
DirectSelectionSurface,
|
||||
markDirectSelection,
|
||||
peekDirectSelection,
|
||||
} from '@app/features/app/components/layout/DirectSelectionOrigin';
|
||||
import {computeVerticalDropPosition} from '@app/features/app/components/layout/dnd/DndDropPosition';
|
||||
import {useDragTargetRect} from '@app/features/app/components/layout/dnd/useDragTargetRect';
|
||||
import favoritesChannelListStyles from '@app/features/app/components/layout/FavoritesChannelListContent.module.css';
|
||||
import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem';
|
||||
import {DragItemType} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
|
||||
import {GuildChannelListSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton';
|
||||
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import * as LinkChannelCommands from '@app/features/channel/commands/LinkChannelCommands';
|
||||
@@ -65,24 +71,19 @@ const EMPTY_FAVORITES_DESCRIPTOR = msg({
|
||||
message: 'Empty favorites',
|
||||
comment: 'Short label in the app layout favorites channel list content.',
|
||||
});
|
||||
const DND_TYPES = {
|
||||
FAVORITES_CHANNEL: 'favorites-channel',
|
||||
FAVORITES_CATEGORY: 'favorites-category',
|
||||
} as const;
|
||||
|
||||
type ChannelDragItem = {
|
||||
type: typeof DND_TYPES.FAVORITES_CHANNEL;
|
||||
type: typeof DragItemType.FAVORITES_CHANNEL;
|
||||
channelId: string;
|
||||
parentId: string | null;
|
||||
};
|
||||
type CategoryDragItem = {
|
||||
type: typeof DND_TYPES.FAVORITES_CATEGORY;
|
||||
type: typeof DragItemType.FAVORITES_CATEGORY;
|
||||
categoryId: string;
|
||||
};
|
||||
type DragItem = ChannelDragItem | CategoryDragItem;
|
||||
|
||||
const isCategoryDragItem = (item: DragItem): item is CategoryDragItem => item.type === DND_TYPES.FAVORITES_CATEGORY;
|
||||
const isChannelDragItem = (item: DragItem): item is ChannelDragItem => item.type === DND_TYPES.FAVORITES_CHANNEL;
|
||||
const isCategoryDragItem = (item: DragItem): item is CategoryDragItem => item.type === DragItemType.FAVORITES_CATEGORY;
|
||||
const isChannelDragItem = (item: DragItem): item is ChannelDragItem => item.type === DragItemType.FAVORITES_CHANNEL;
|
||||
|
||||
type FavoriteDropIndicator = {position: 'top' | 'bottom'; isValid: boolean};
|
||||
|
||||
@@ -120,7 +121,8 @@ const FavoriteChannelResolvedItem = observer(
|
||||
const isSelected = location.pathname === Routes.favoritesChannel(favoriteChannel.channelId);
|
||||
const shouldShowSelectedState = isSelected;
|
||||
useEffect(() => {
|
||||
if (isSelected) {
|
||||
const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.FAVORITES_LIST);
|
||||
if (isSelected && !selectedFromThisRow) {
|
||||
elementRef.current?.scrollIntoView({block: 'nearest'});
|
||||
}
|
||||
}, [isSelected]);
|
||||
@@ -128,9 +130,9 @@ const FavoriteChannelResolvedItem = observer(
|
||||
const {keyboardModeEnabled} = KeyboardMode;
|
||||
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
|
||||
const [{isDragging}, dragRef, preview] = useDrag<ChannelDragItem, unknown, {isDragging: boolean}>({
|
||||
type: DND_TYPES.FAVORITES_CHANNEL,
|
||||
type: DragItemType.FAVORITES_CHANNEL,
|
||||
item: {
|
||||
type: DND_TYPES.FAVORITES_CHANNEL,
|
||||
type: DragItemType.FAVORITES_CHANNEL,
|
||||
channelId: favoriteChannel.channelId,
|
||||
parentId: favoriteChannel.parentId,
|
||||
},
|
||||
@@ -140,7 +142,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
end: resetFavoriteDropIndicator,
|
||||
});
|
||||
const [{isOver}, dropRef] = useDrop<ChannelDragItem, unknown, {isOver: boolean}>({
|
||||
accept: DND_TYPES.FAVORITES_CHANNEL,
|
||||
accept: DragItemType.FAVORITES_CHANNEL,
|
||||
hover: (_item, monitor) => {
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
@@ -233,6 +235,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
if (LinkChannelCommands.openLinkChannel(channel)) {
|
||||
return;
|
||||
}
|
||||
markDirectSelection(DirectSelectionSurface.FAVORITES_LIST);
|
||||
preloadChannelNow();
|
||||
NavigationCommands.selectChannel(FAVORITES_GUILD_ID, favoriteChannel.channelId);
|
||||
};
|
||||
@@ -427,9 +430,9 @@ const FavoriteCategoryItem = observer(
|
||||
const {keyboardModeEnabled} = KeyboardMode;
|
||||
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
|
||||
const [{isDragging}, dragRef, preview] = useDrag<CategoryDragItem, unknown, {isDragging: boolean}>({
|
||||
type: DND_TYPES.FAVORITES_CATEGORY,
|
||||
type: DragItemType.FAVORITES_CATEGORY,
|
||||
item: {
|
||||
type: DND_TYPES.FAVORITES_CATEGORY,
|
||||
type: DragItemType.FAVORITES_CATEGORY,
|
||||
categoryId: category.id,
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
@@ -442,7 +445,7 @@ const FavoriteCategoryItem = observer(
|
||||
unknown,
|
||||
{isOver: boolean; draggedItemType: string | symbol | null}
|
||||
>({
|
||||
accept: [DND_TYPES.FAVORITES_CHANNEL, DND_TYPES.FAVORITES_CATEGORY],
|
||||
accept: [DragItemType.FAVORITES_CHANNEL, DragItemType.FAVORITES_CATEGORY],
|
||||
hover: (item, monitor) => {
|
||||
if (!isCategoryDragItem(item)) {
|
||||
resetCategoryDropIndicator();
|
||||
@@ -490,8 +493,8 @@ const FavoriteCategoryItem = observer(
|
||||
draggedItemType: monitor.getItemType(),
|
||||
}),
|
||||
});
|
||||
const isCategoryDragOver = isOver && draggedItemType === DND_TYPES.FAVORITES_CATEGORY;
|
||||
const isChannelDragOver = isOver && draggedItemType === DND_TYPES.FAVORITES_CHANNEL;
|
||||
const isCategoryDragOver = isOver && draggedItemType === DragItemType.FAVORITES_CATEGORY;
|
||||
const isChannelDragOver = isOver && draggedItemType === DragItemType.FAVORITES_CHANNEL;
|
||||
useEffect(() => {
|
||||
if (!isCategoryDragOver) resetCategoryDropIndicator();
|
||||
}, [isCategoryDragOver, resetCategoryDropIndicator]);
|
||||
@@ -605,7 +608,7 @@ const FavoriteCategoryItem = observer(
|
||||
);
|
||||
const UncategorizedGroup = ({children}: {children: React.ReactNode}) => {
|
||||
const [{isOver}, dropRef] = useDrop<ChannelDragItem, unknown, {isOver: boolean}>({
|
||||
accept: DND_TYPES.FAVORITES_CHANNEL,
|
||||
accept: DragItemType.FAVORITES_CHANNEL,
|
||||
drop: (item, monitor) => {
|
||||
if (monitor.didDrop()) return;
|
||||
if (item.parentId === null) return;
|
||||
@@ -640,7 +643,8 @@ export const FavoritesChannelListContent = observer(() => {
|
||||
const isDraggingFavorite = useDragLayer((monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
return (
|
||||
monitor.isDragging() && (itemType === DND_TYPES.FAVORITES_CHANNEL || itemType === DND_TYPES.FAVORITES_CATEGORY)
|
||||
monitor.isDragging() &&
|
||||
(itemType === DragItemType.FAVORITES_CHANNEL || itemType === DragItemType.FAVORITES_CATEGORY)
|
||||
);
|
||||
});
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
@@ -704,7 +708,11 @@ export const FavoritesChannelListContent = observer(() => {
|
||||
aria-label={i18n._(EMPTY_FAVORITES_DESCRIPTOR)}
|
||||
data-flx="app.favorites-channel-list-content.region.context-menu"
|
||||
>
|
||||
<ChannelListSkeleton data-flx="app.favorites-channel-list-content.channel-list-skeleton" />
|
||||
<GuildChannelListSkeleton
|
||||
channelList={null}
|
||||
detachedBannerAspectRatio={null}
|
||||
data-flx="app.favorites-channel-list-content.guild-channel-list-skeleton"
|
||||
/>
|
||||
<div className={styles.bottomSpacer} data-flx="app.favorites-channel-list-content.bottom-spacer.empty" />
|
||||
</div>
|
||||
</Scroller>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorStateMachine';
|
||||
|
||||
export interface FloatingUnreadTarget {
|
||||
id: string;
|
||||
severity: ScrollIndicatorSeverity;
|
||||
}
|
||||
|
||||
export interface MeasuredFloatingUnreadTarget {
|
||||
id: string;
|
||||
severity: ScrollIndicatorSeverity;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface FloatingUnreadEdges {
|
||||
top: MeasuredFloatingUnreadTarget | null;
|
||||
bottom: MeasuredFloatingUnreadTarget | null;
|
||||
}
|
||||
|
||||
export interface FloatingUnreadTargetIndex {
|
||||
byBottom: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
topCandidates: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
byTop: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
bottomCandidates: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
}
|
||||
|
||||
export interface FloatingUnreadTargetBounds {
|
||||
readonly bottom: number;
|
||||
readonly top: number;
|
||||
}
|
||||
|
||||
export interface FloatingUnreadViewport {
|
||||
scrollTop: number;
|
||||
viewportHeight: number;
|
||||
targetIndex: FloatingUnreadTargetIndex;
|
||||
}
|
||||
|
||||
interface CreateFloatingUnreadTargetIndexFromMeasurementsRequest {
|
||||
readonly targets: ReadonlyArray<MeasuredFloatingUnreadTarget>;
|
||||
}
|
||||
|
||||
interface CreateFloatingUnreadTargetIndexFromBoundsRequest {
|
||||
readonly boundsByTargetId: ReadonlyMap<string, FloatingUnreadTargetBounds>;
|
||||
readonly targets: ReadonlyArray<FloatingUnreadTarget>;
|
||||
}
|
||||
|
||||
interface CompareFloatingUnreadEdgesQuery {
|
||||
readonly left: FloatingUnreadEdges;
|
||||
readonly right: FloatingUnreadEdges;
|
||||
}
|
||||
|
||||
interface PreserveStableFloatingUnreadEdgesQuery {
|
||||
readonly current: FloatingUnreadEdges;
|
||||
readonly next: FloatingUnreadEdges;
|
||||
}
|
||||
|
||||
const VISIBILITY_EPSILON = 0.5;
|
||||
const EMPTY_MEASURED_TARGETS: ReadonlyArray<MeasuredFloatingUnreadTarget> = Object.freeze([]);
|
||||
|
||||
function compareTargetIdentifiers(left: string, right: string): number {
|
||||
if (left === right) return 0;
|
||||
if (left < right) return -1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
class FloatingUnreadEdgeLayoutOwner {
|
||||
public readonly EMPTY_EDGES: FloatingUnreadEdges = Object.freeze({top: null, bottom: null});
|
||||
public readonly EMPTY_TARGET_INDEX: FloatingUnreadTargetIndex = Object.freeze({
|
||||
byBottom: EMPTY_MEASURED_TARGETS,
|
||||
topCandidates: EMPTY_MEASURED_TARGETS,
|
||||
byTop: EMPTY_MEASURED_TARGETS,
|
||||
bottomCandidates: EMPTY_MEASURED_TARGETS,
|
||||
});
|
||||
|
||||
public createTargetIndexFromMeasurements({
|
||||
targets,
|
||||
}: CreateFloatingUnreadTargetIndexFromMeasurementsRequest): FloatingUnreadTargetIndex {
|
||||
if (targets.length === 0) return this.EMPTY_TARGET_INDEX;
|
||||
|
||||
const byBottom = targets.slice().sort(this.compareByBottom);
|
||||
const topCandidates = new Array<MeasuredFloatingUnreadTarget>(byBottom.length);
|
||||
let topCandidate: MeasuredFloatingUnreadTarget | null = null;
|
||||
for (let index = 0; index < byBottom.length; index++) {
|
||||
topCandidate = this.chooseTopCandidate(topCandidate, byBottom[index]);
|
||||
topCandidates[index] = topCandidate;
|
||||
}
|
||||
|
||||
const byTop = targets.slice().sort(this.compareByTop);
|
||||
const bottomCandidates = new Array<MeasuredFloatingUnreadTarget>(byTop.length);
|
||||
let bottomCandidate: MeasuredFloatingUnreadTarget | null = null;
|
||||
for (let index = byTop.length - 1; index >= 0; index--) {
|
||||
bottomCandidate = this.chooseBottomCandidate(byTop[index], bottomCandidate);
|
||||
bottomCandidates[index] = bottomCandidate;
|
||||
}
|
||||
|
||||
return {byBottom, topCandidates, byTop, bottomCandidates};
|
||||
}
|
||||
|
||||
public createTargetIndexFromBounds({
|
||||
targets,
|
||||
boundsByTargetId,
|
||||
}: CreateFloatingUnreadTargetIndexFromBoundsRequest): FloatingUnreadTargetIndex {
|
||||
if (targets.length === 0) return this.EMPTY_TARGET_INDEX;
|
||||
const measuredTargets: Array<MeasuredFloatingUnreadTarget> = [];
|
||||
for (const target of targets) {
|
||||
const bounds = boundsByTargetId.get(target.id);
|
||||
if (bounds == null) continue;
|
||||
measuredTargets.push({...target, ...bounds});
|
||||
}
|
||||
return this.createTargetIndexFromMeasurements({targets: measuredTargets});
|
||||
}
|
||||
|
||||
public selectEdges({scrollTop, viewportHeight, targetIndex}: FloatingUnreadViewport): FloatingUnreadEdges {
|
||||
const viewportTop = Math.max(0, scrollTop);
|
||||
const viewportBottom = viewportTop + Math.max(0, viewportHeight);
|
||||
const topIndex = this.findLastTargetEndingBefore(targetIndex.byBottom, viewportTop + VISIBILITY_EPSILON);
|
||||
const bottomIndex = this.findFirstTargetStartingAfter(targetIndex.byTop, viewportBottom - VISIBILITY_EPSILON);
|
||||
let top: MeasuredFloatingUnreadTarget | null;
|
||||
if (topIndex >= 0) {
|
||||
top = targetIndex.topCandidates[topIndex];
|
||||
} else {
|
||||
top = null;
|
||||
}
|
||||
let bottom: MeasuredFloatingUnreadTarget | null;
|
||||
if (bottomIndex < targetIndex.byTop.length) {
|
||||
bottom = targetIndex.bottomCandidates[bottomIndex];
|
||||
} else {
|
||||
bottom = null;
|
||||
}
|
||||
return {top, bottom};
|
||||
}
|
||||
|
||||
public areEdgesEqual({left, right}: CompareFloatingUnreadEdgesQuery): boolean {
|
||||
return this.areEdgesEqualAtPosition(left.top, right.top) && this.areEdgesEqualAtPosition(left.bottom, right.bottom);
|
||||
}
|
||||
|
||||
public preserveStableEdges({current, next}: PreserveStableFloatingUnreadEdgesQuery): FloatingUnreadEdges {
|
||||
if (this.areEdgesEqual({left: current, right: next})) {
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private chooseTopCandidate(
|
||||
current: MeasuredFloatingUnreadTarget | null,
|
||||
next: MeasuredFloatingUnreadTarget,
|
||||
): MeasuredFloatingUnreadTarget {
|
||||
if (current == null || (current.severity !== 'mention' && next.severity === 'mention')) {
|
||||
return next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private chooseBottomCandidate(
|
||||
current: MeasuredFloatingUnreadTarget,
|
||||
next: MeasuredFloatingUnreadTarget | null,
|
||||
): MeasuredFloatingUnreadTarget {
|
||||
if (next == null || (next.severity !== 'mention' && current.severity === 'mention')) {
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private compareByBottom(left: MeasuredFloatingUnreadTarget, right: MeasuredFloatingUnreadTarget): number {
|
||||
const bottomDifference = left.bottom - right.bottom;
|
||||
if (bottomDifference !== 0) return bottomDifference;
|
||||
const topDifference = left.top - right.top;
|
||||
if (topDifference !== 0) return topDifference;
|
||||
return compareTargetIdentifiers(left.id, right.id);
|
||||
}
|
||||
|
||||
private compareByTop(left: MeasuredFloatingUnreadTarget, right: MeasuredFloatingUnreadTarget): number {
|
||||
const topDifference = left.top - right.top;
|
||||
if (topDifference !== 0) return topDifference;
|
||||
const bottomDifference = left.bottom - right.bottom;
|
||||
if (bottomDifference !== 0) return bottomDifference;
|
||||
return compareTargetIdentifiers(left.id, right.id);
|
||||
}
|
||||
|
||||
private findLastTargetEndingBefore(
|
||||
targets: ReadonlyArray<MeasuredFloatingUnreadTarget>,
|
||||
viewportTop: number,
|
||||
): number {
|
||||
let low = 0;
|
||||
let high = targets.length;
|
||||
while (low < high) {
|
||||
const middle = low + Math.floor((high - low) / 2);
|
||||
if (targets[middle].bottom <= viewportTop) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
return low - 1;
|
||||
}
|
||||
|
||||
private findFirstTargetStartingAfter(
|
||||
targets: ReadonlyArray<MeasuredFloatingUnreadTarget>,
|
||||
viewportBottom: number,
|
||||
): number {
|
||||
let low = 0;
|
||||
let high = targets.length;
|
||||
while (low < high) {
|
||||
const middle = low + Math.floor((high - low) / 2);
|
||||
if (targets[middle].top < viewportBottom) {
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
private areEdgesEqualAtPosition(
|
||||
left: MeasuredFloatingUnreadTarget | null,
|
||||
right: MeasuredFloatingUnreadTarget | null,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (left == null || right == null) return false;
|
||||
return left.id === right.id && left.severity === right.severity;
|
||||
}
|
||||
}
|
||||
|
||||
export const FloatingUnreadEdgeLayout = Object.freeze(new FloatingUnreadEdgeLayoutOwner());
|
||||
@@ -0,0 +1,50 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-index-elevated-2);
|
||||
}
|
||||
|
||||
.slot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slotTop {
|
||||
top: 0.5rem;
|
||||
}
|
||||
|
||||
.slotBottom {
|
||||
bottom: var(--floating-unread-indicator-bottom-inset, 0.5rem);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
pointer-events: auto;
|
||||
border: none;
|
||||
border-radius: 62.4375rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1rem;
|
||||
letter-spacing: 0.025em;
|
||||
text-transform: uppercase;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 0.625rem 1.25rem -0.75rem rgba(0, 0, 0, 0.75),
|
||||
0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.indicatorBrand {
|
||||
background-color: hsl(220, 6%, 30%);
|
||||
}
|
||||
|
||||
.indicatorMention {
|
||||
background-color: var(--status-danger);
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {
|
||||
FloatingUnreadEdgeLayout,
|
||||
type FloatingUnreadEdges,
|
||||
type FloatingUnreadTarget,
|
||||
type FloatingUnreadTargetBounds,
|
||||
type MeasuredFloatingUnreadTarget,
|
||||
} from '@app/features/app/components/layout/FloatingUnreadEdges';
|
||||
import styles from '@app/features/app/components/layout/FloatingUnreadIndicators.module.css';
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorStateMachine';
|
||||
import {Edge, type VerticalEdge} from '@app/features/ui/AxisOrientation';
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {AnimeButton, AnimePresence} from '@app/features/ui/motion/AnimeElement';
|
||||
import {resolveVisibleScrollViewportHeight} from '@app/features/ui/utils/ScrollViewportGeometry';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const SCROLL_TARGET_PADDING_PX = 12;
|
||||
const SETTLE_REFRESH_DELAY_MS = 280;
|
||||
|
||||
const FloatingUnreadRefreshScope = Object.freeze({
|
||||
GEOMETRY: 'geometry',
|
||||
SCROLL: 'scroll',
|
||||
TARGETS: 'targets',
|
||||
} as const);
|
||||
|
||||
type FloatingUnreadRefreshScope = (typeof FloatingUnreadRefreshScope)[keyof typeof FloatingUnreadRefreshScope];
|
||||
|
||||
function buildFloatingUnreadTargetsSignature(targets: ReadonlyArray<FloatingUnreadTarget>): string {
|
||||
if (targets.length === 0) return '';
|
||||
const parts = new Array<string>(targets.length);
|
||||
for (let index = 0; index < targets.length; index += 1) {
|
||||
const target = targets[index];
|
||||
parts[index] = `${target.id}:${target.severity}`;
|
||||
}
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
export interface FloatingUnreadTargetRegistry {
|
||||
register: (id: string) => React.RefCallback<HTMLElement>;
|
||||
getTargetNode: (id: string) => HTMLElement | null;
|
||||
}
|
||||
|
||||
export function useFloatingUnreadTargetRegistry(): FloatingUnreadTargetRegistry {
|
||||
const nodesRef = useRef<Map<string, HTMLElement>>(new Map());
|
||||
const callbacksRef = useRef<Map<string, React.RefCallback<HTMLElement>>>(new Map());
|
||||
const register = useCallback((id: string): React.RefCallback<HTMLElement> => {
|
||||
const cached = callbacksRef.current.get(id);
|
||||
if (cached != null) return cached;
|
||||
const callback = (node: HTMLElement | null) => {
|
||||
const registeredNode = nodesRef.current.get(id);
|
||||
if (registeredNode === node) return;
|
||||
if (registeredNode == null && node == null) return;
|
||||
if (node != null) {
|
||||
nodesRef.current.set(id, node);
|
||||
callbacksRef.current.set(id, callback);
|
||||
} else {
|
||||
nodesRef.current.delete(id);
|
||||
callbacksRef.current.delete(id);
|
||||
}
|
||||
};
|
||||
callbacksRef.current.set(id, callback);
|
||||
return callback;
|
||||
}, []);
|
||||
const getTargetNode = useCallback((id: string): HTMLElement | null => {
|
||||
const node = nodesRef.current.get(id);
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
return node;
|
||||
}, []);
|
||||
return useMemo(() => ({register, getTargetNode}), [register, getTargetNode]);
|
||||
}
|
||||
|
||||
interface FloatingUnreadPillProps {
|
||||
readonly label: string;
|
||||
readonly severity: ScrollIndicatorSeverity;
|
||||
readonly direction: VerticalEdge;
|
||||
readonly onClick: () => void;
|
||||
}
|
||||
|
||||
interface VisibleFloatingUnreadEdge {
|
||||
readonly direction: VerticalEdge;
|
||||
readonly edge: MeasuredFloatingUnreadTarget;
|
||||
}
|
||||
|
||||
const FloatingUnreadPill = ({label, severity, direction, onClick}: FloatingUnreadPillProps) => {
|
||||
const prefersReducedMotion = Accessibility.useReducedMotion;
|
||||
let directionOffset: 10 | -10;
|
||||
if (direction === Edge.TOP) {
|
||||
directionOffset = -10;
|
||||
} else {
|
||||
directionOffset = 10;
|
||||
}
|
||||
let severityClassName = styles.indicatorBrand;
|
||||
if (severity === 'mention') {
|
||||
severityClassName = styles.indicatorMention;
|
||||
}
|
||||
if (prefersReducedMotion) {
|
||||
return (
|
||||
<FocusRing offset={-2} data-flx="app.floating-unread-indicators.floating-unread-pill.focus-ring">
|
||||
<AnimeButton
|
||||
type="button"
|
||||
className={clsx(styles.indicator, severityClassName)}
|
||||
onClick={onClick}
|
||||
from={{opacity: 1, translateY: 0, scale: 1}}
|
||||
to={{opacity: 1, translateY: 0, scale: 1}}
|
||||
leave={{opacity: 1, translateY: 0, scale: 1, tween: {duration: 0}}}
|
||||
tween={{duration: 0, ease: 'out(3)'}}
|
||||
aria-label={label}
|
||||
data-flx="app.floating-unread-indicators.floating-unread-pill.indicator.click.button"
|
||||
>
|
||||
{label}
|
||||
</AnimeButton>
|
||||
</FocusRing>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FocusRing offset={-2} data-flx="app.floating-unread-indicators.floating-unread-pill.focus-ring--2">
|
||||
<AnimeButton
|
||||
type="button"
|
||||
className={clsx(styles.indicator, severityClassName)}
|
||||
onClick={onClick}
|
||||
from={{opacity: 0, translateY: directionOffset, scale: 0.96}}
|
||||
to={{opacity: 1, translateY: 0, scale: 1}}
|
||||
leave={{
|
||||
opacity: 0,
|
||||
translateY: directionOffset,
|
||||
scale: 0.96,
|
||||
tween: {duration: 0.16, ease: 'inOut(2)'},
|
||||
}}
|
||||
tween={{duration: 0.18, ease: 'out(3)'}}
|
||||
hover={{scale: 1.05}}
|
||||
press={{translateY: 1}}
|
||||
aria-label={label}
|
||||
data-flx="app.floating-unread-indicators.floating-unread-pill.indicator.click.button--2"
|
||||
>
|
||||
{label}
|
||||
</AnimeButton>
|
||||
</FocusRing>
|
||||
);
|
||||
};
|
||||
|
||||
export interface FloatingUnreadIndicatorsProps {
|
||||
readonly label: string;
|
||||
readonly scrollerRef: React.RefObject<ScrollerHandle | null>;
|
||||
readonly scrollerIdentity: string;
|
||||
readonly targets: ReadonlyArray<FloatingUnreadTarget>;
|
||||
readonly getTargetNode: (id: string) => HTMLElement | null;
|
||||
readonly measurementRevision: number;
|
||||
readonly getTargetBounds: (
|
||||
targets: ReadonlyArray<FloatingUnreadTarget>,
|
||||
) => ReadonlyMap<string, FloatingUnreadTargetBounds>;
|
||||
readonly scrollToTarget: (id: string, direction: VerticalEdge) => boolean;
|
||||
}
|
||||
|
||||
function resolveScrollerNode(scroller: ScrollerHandle | null): HTMLElement | null {
|
||||
if (scroller == null) {
|
||||
return null;
|
||||
}
|
||||
return scroller.getScrollerNode();
|
||||
}
|
||||
|
||||
function resolveOwnerWindow(container: HTMLElement): Window & typeof globalThis {
|
||||
const defaultView = container.ownerDocument.defaultView;
|
||||
if (defaultView == null) {
|
||||
throw new Error('Floating unread indicator container has no owner window');
|
||||
}
|
||||
return defaultView;
|
||||
}
|
||||
|
||||
function observeScrollContentElements(
|
||||
resizeObserver: ResizeObserver,
|
||||
container: HTMLElement,
|
||||
ownerWindow: Window & typeof globalThis,
|
||||
): void {
|
||||
const contentWrapper = container.firstElementChild;
|
||||
if (!(contentWrapper instanceof ownerWindow.HTMLElement)) return;
|
||||
const contentChildren = contentWrapper.children;
|
||||
if (contentChildren.length === 0) {
|
||||
resizeObserver.observe(contentWrapper);
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < contentChildren.length; index += 1) {
|
||||
const child = contentChildren[index];
|
||||
if (child instanceof ownerWindow.HTMLElement) resizeObserver.observe(child);
|
||||
}
|
||||
}
|
||||
|
||||
interface OwnedAnimationFrame {
|
||||
readonly id: number;
|
||||
readonly ownerWindow: Window & typeof globalThis;
|
||||
}
|
||||
|
||||
interface OwnedSettleTimer {
|
||||
readonly id: number;
|
||||
readonly ownerWindow: Window & typeof globalThis;
|
||||
}
|
||||
|
||||
interface AlignMountedTargetRequest {
|
||||
readonly animate: boolean;
|
||||
readonly direction: VerticalEdge;
|
||||
readonly scroller: ScrollerHandle;
|
||||
readonly targetNode: HTMLElement;
|
||||
}
|
||||
|
||||
function cancelOwnedAnimationFrame(frame: OwnedAnimationFrame | null): void {
|
||||
if (frame == null) return;
|
||||
frame.ownerWindow.cancelAnimationFrame(frame.id);
|
||||
}
|
||||
|
||||
function cancelOwnedSettleTimer(timer: OwnedSettleTimer | null): void {
|
||||
if (timer == null) return;
|
||||
timer.ownerWindow.clearTimeout(timer.id);
|
||||
}
|
||||
|
||||
function alignMountedTarget({animate, direction, scroller, targetNode}: AlignMountedTargetRequest): boolean {
|
||||
const container = scroller.getScrollerNode();
|
||||
if (container == null) return false;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const targetRect = targetNode.getBoundingClientRect();
|
||||
const targetTop = container.scrollTop + targetRect.top - containerRect.top;
|
||||
const targetBottom = container.scrollTop + targetRect.bottom - containerRect.top;
|
||||
let to = targetBottom + SCROLL_TARGET_PADDING_PX - resolveVisibleScrollViewportHeight(container);
|
||||
if (direction === Edge.TOP) {
|
||||
to = targetTop - SCROLL_TARGET_PADDING_PX;
|
||||
}
|
||||
scroller.scrollTo({to, animate});
|
||||
return true;
|
||||
}
|
||||
|
||||
export const FloatingUnreadIndicators = observer(
|
||||
({
|
||||
label,
|
||||
scrollerRef,
|
||||
scrollerIdentity,
|
||||
targets,
|
||||
getTargetNode,
|
||||
measurementRevision,
|
||||
getTargetBounds,
|
||||
scrollToTarget,
|
||||
}: FloatingUnreadIndicatorsProps) => {
|
||||
const [edges, setEdges] = useState<FloatingUnreadEdges>(FloatingUnreadEdgeLayout.EMPTY_EDGES);
|
||||
const targetsSignature = useMemo(() => buildFloatingUnreadTargetsSignature(targets), [targets]);
|
||||
const targetsRef = useRef(targets);
|
||||
const getTargetNodeRef = useRef(getTargetNode);
|
||||
const getTargetBoundsRef = useRef(getTargetBounds);
|
||||
const scrollToTargetRef = useRef(scrollToTarget);
|
||||
targetsRef.current = targets;
|
||||
getTargetNodeRef.current = getTargetNode;
|
||||
getTargetBoundsRef.current = getTargetBounds;
|
||||
scrollToTargetRef.current = scrollToTarget;
|
||||
const rafRef = useRef<OwnedAnimationFrame | null>(null);
|
||||
const targetAlignmentFrameRef = useRef<OwnedAnimationFrame | null>(null);
|
||||
const settleTimerRef = useRef<OwnedSettleTimer | null>(null);
|
||||
const geometrySettleTimerRef = useRef<OwnedSettleTimer | null>(null);
|
||||
const geometrySettleArmedRef = useRef(false);
|
||||
const geometrySettleSuppressedRef = useRef(false);
|
||||
const refreshNowRef = useRef<() => void>(() => {});
|
||||
const geometryInvalidatedRef = useRef(true);
|
||||
const targetIndexInvalidatedRef = useRef(true);
|
||||
const visibleViewportHeightRef = useRef(0);
|
||||
const targetBoundsCacheRef = useRef<Map<string, FloatingUnreadTargetBounds>>(new Map());
|
||||
const pendingScrollTopRef = useRef<number | null>(null);
|
||||
const targetIndexRef = useRef(FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX);
|
||||
const refreshTargetIndex = useCallback(() => {
|
||||
const currentTargets = targetsRef.current;
|
||||
const boundsCache = targetBoundsCacheRef.current;
|
||||
const unmeasuredTargets: Array<FloatingUnreadTarget> = [];
|
||||
for (const target of currentTargets) {
|
||||
if (boundsCache.has(target.id)) continue;
|
||||
unmeasuredTargets.push(target);
|
||||
}
|
||||
if (unmeasuredTargets.length > 0) {
|
||||
const measuredBounds = getTargetBoundsRef.current(unmeasuredTargets);
|
||||
for (const [id, bounds] of measuredBounds) {
|
||||
boundsCache.set(id, bounds);
|
||||
}
|
||||
}
|
||||
if (boundsCache.size > currentTargets.length) {
|
||||
const retainedBounds = new Map<string, FloatingUnreadTargetBounds>();
|
||||
for (const target of currentTargets) {
|
||||
const bounds = boundsCache.get(target.id);
|
||||
if (bounds != null) retainedBounds.set(target.id, bounds);
|
||||
}
|
||||
targetBoundsCacheRef.current = retainedBounds;
|
||||
}
|
||||
targetIndexRef.current = FloatingUnreadEdgeLayout.createTargetIndexFromBounds({
|
||||
targets: currentTargets,
|
||||
boundsByTargetId: targetBoundsCacheRef.current,
|
||||
});
|
||||
}, []);
|
||||
const refreshNow = useCallback(() => {
|
||||
const container = resolveScrollerNode(scrollerRef.current);
|
||||
if (container == null) {
|
||||
pendingScrollTopRef.current = null;
|
||||
targetIndexRef.current = FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX;
|
||||
setEdges(FloatingUnreadEdgeLayout.EMPTY_EDGES);
|
||||
return;
|
||||
}
|
||||
if (targetsRef.current.length === 0) {
|
||||
pendingScrollTopRef.current = null;
|
||||
targetBoundsCacheRef.current.clear();
|
||||
targetIndexRef.current = FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX;
|
||||
setEdges(FloatingUnreadEdgeLayout.EMPTY_EDGES);
|
||||
return;
|
||||
}
|
||||
if (geometryInvalidatedRef.current) {
|
||||
geometryInvalidatedRef.current = false;
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
targetBoundsCacheRef.current.clear();
|
||||
visibleViewportHeightRef.current = resolveVisibleScrollViewportHeight(container);
|
||||
if (geometrySettleSuppressedRef.current) {
|
||||
geometrySettleSuppressedRef.current = false;
|
||||
geometrySettleArmedRef.current = false;
|
||||
} else if (!geometrySettleArmedRef.current) {
|
||||
geometrySettleArmedRef.current = true;
|
||||
const settleOwnerWindow = resolveOwnerWindow(container);
|
||||
const settleId = settleOwnerWindow.setTimeout(() => {
|
||||
geometrySettleTimerRef.current = null;
|
||||
geometrySettleArmedRef.current = false;
|
||||
geometrySettleSuppressedRef.current = true;
|
||||
geometryInvalidatedRef.current = true;
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
refreshNowRef.current();
|
||||
}, SETTLE_REFRESH_DELAY_MS);
|
||||
geometrySettleTimerRef.current = {id: settleId, ownerWindow: settleOwnerWindow};
|
||||
}
|
||||
}
|
||||
if (targetIndexInvalidatedRef.current) {
|
||||
targetIndexInvalidatedRef.current = false;
|
||||
refreshTargetIndex();
|
||||
}
|
||||
const pendingScrollTop = pendingScrollTopRef.current;
|
||||
pendingScrollTopRef.current = null;
|
||||
let scrollTop: number;
|
||||
if (pendingScrollTop == null) {
|
||||
scrollTop = container.scrollTop;
|
||||
} else {
|
||||
scrollTop = pendingScrollTop;
|
||||
}
|
||||
const next = FloatingUnreadEdgeLayout.selectEdges({
|
||||
scrollTop,
|
||||
viewportHeight: visibleViewportHeightRef.current,
|
||||
targetIndex: targetIndexRef.current,
|
||||
});
|
||||
setEdges((current) => FloatingUnreadEdgeLayout.preserveStableEdges({current, next}));
|
||||
}, [refreshTargetIndex, scrollerRef]);
|
||||
refreshNowRef.current = refreshNow;
|
||||
const scheduleRefresh = useCallback(
|
||||
(scope: FloatingUnreadRefreshScope) => {
|
||||
if (scope === FloatingUnreadRefreshScope.GEOMETRY) {
|
||||
geometryInvalidatedRef.current = true;
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
geometrySettleSuppressedRef.current = false;
|
||||
geometrySettleArmedRef.current = false;
|
||||
cancelOwnedSettleTimer(geometrySettleTimerRef.current);
|
||||
geometrySettleTimerRef.current = null;
|
||||
} else if (scope === FloatingUnreadRefreshScope.TARGETS) {
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
if (geometrySettleArmedRef.current) geometryInvalidatedRef.current = true;
|
||||
}
|
||||
const container = resolveScrollerNode(scrollerRef.current);
|
||||
if (container == null) {
|
||||
refreshNow();
|
||||
return;
|
||||
}
|
||||
const ownerWindow = resolveOwnerWindow(container);
|
||||
const pendingFrame = rafRef.current;
|
||||
if (pendingFrame != null && pendingFrame.ownerWindow === ownerWindow) return;
|
||||
cancelOwnedAnimationFrame(pendingFrame);
|
||||
const id = ownerWindow.requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
refreshNow();
|
||||
});
|
||||
rafRef.current = {id, ownerWindow};
|
||||
},
|
||||
[refreshNow, scrollerRef],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const container = resolveScrollerNode(scrollerRef.current);
|
||||
if (container == null) {
|
||||
targetIndexRef.current = FloatingUnreadEdgeLayout.EMPTY_TARGET_INDEX;
|
||||
setEdges(FloatingUnreadEdgeLayout.EMPTY_EDGES);
|
||||
return;
|
||||
}
|
||||
geometryInvalidatedRef.current = true;
|
||||
targetIndexInvalidatedRef.current = true;
|
||||
refreshNow();
|
||||
const ownerWindow = resolveOwnerWindow(container);
|
||||
const handleScroll = () => {
|
||||
pendingScrollTopRef.current = container.scrollTop;
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.SCROLL);
|
||||
};
|
||||
const handleLayoutChange = () => scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
container.addEventListener('scroll', handleScroll, {passive: true});
|
||||
ownerWindow.addEventListener('resize', handleLayoutChange);
|
||||
let resizeObserver: ResizeObserver | null;
|
||||
if (ownerWindow.ResizeObserver != null) {
|
||||
resizeObserver = new ownerWindow.ResizeObserver(handleLayoutChange);
|
||||
} else {
|
||||
resizeObserver = null;
|
||||
}
|
||||
if (resizeObserver != null) {
|
||||
resizeObserver.observe(container);
|
||||
observeScrollContentElements(resizeObserver, container, ownerWindow);
|
||||
}
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
ownerWindow.removeEventListener('resize', handleLayoutChange);
|
||||
if (resizeObserver != null) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
cancelOwnedAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
cancelOwnedAnimationFrame(targetAlignmentFrameRef.current);
|
||||
targetAlignmentFrameRef.current = null;
|
||||
cancelOwnedSettleTimer(settleTimerRef.current);
|
||||
settleTimerRef.current = null;
|
||||
cancelOwnedSettleTimer(geometrySettleTimerRef.current);
|
||||
geometrySettleTimerRef.current = null;
|
||||
geometrySettleArmedRef.current = false;
|
||||
geometrySettleSuppressedRef.current = false;
|
||||
};
|
||||
}, [refreshNow, scheduleRefresh, scrollerIdentity, scrollerRef]);
|
||||
useLayoutEffect(() => {
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
}, [measurementRevision, scheduleRefresh]);
|
||||
useLayoutEffect(() => {
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.TARGETS);
|
||||
}, [scheduleRefresh, targetsSignature]);
|
||||
const alignTarget = useCallback(
|
||||
(id: string, direction: VerticalEdge, animate: boolean): boolean => {
|
||||
const scroller = scrollerRef.current;
|
||||
const targetNode = getTargetNodeRef.current(id);
|
||||
if (scroller == null || targetNode == null) return false;
|
||||
return alignMountedTarget({animate, direction, scroller, targetNode});
|
||||
},
|
||||
[scrollerRef],
|
||||
);
|
||||
const scrollToEdge = useCallback(
|
||||
(edge: MeasuredFloatingUnreadTarget, direction: VerticalEdge) => {
|
||||
const scroller = scrollerRef.current;
|
||||
const scrollTarget = scrollToTargetRef.current;
|
||||
if (scrollTarget(edge.id, direction)) {
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
if (scroller == null) return;
|
||||
const scrollNode = scroller.getScrollerNode();
|
||||
if (scrollNode == null) return;
|
||||
const ownerWindow = resolveOwnerWindow(scrollNode);
|
||||
cancelOwnedAnimationFrame(targetAlignmentFrameRef.current);
|
||||
const id = ownerWindow.requestAnimationFrame(() => {
|
||||
targetAlignmentFrameRef.current = null;
|
||||
alignTarget(edge.id, direction, Accessibility.useSmoothScrolling);
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
});
|
||||
targetAlignmentFrameRef.current = {id, ownerWindow};
|
||||
} else if (!alignTarget(edge.id, direction, Accessibility.useSmoothScrolling)) {
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
return;
|
||||
}
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.SCROLL);
|
||||
if (scroller == null) return;
|
||||
const scrollNode = scroller.getScrollerNode();
|
||||
if (scrollNode == null) return;
|
||||
const ownerWindow = resolveOwnerWindow(scrollNode);
|
||||
cancelOwnedSettleTimer(settleTimerRef.current);
|
||||
const id = ownerWindow.setTimeout(() => {
|
||||
settleTimerRef.current = null;
|
||||
alignTarget(edge.id, direction, false);
|
||||
scheduleRefresh(FloatingUnreadRefreshScope.GEOMETRY);
|
||||
}, SETTLE_REFRESH_DELAY_MS);
|
||||
settleTimerRef.current = {id, ownerWindow};
|
||||
},
|
||||
[alignTarget, scheduleRefresh, scrollerRef],
|
||||
);
|
||||
const visibleEdges: Array<VisibleFloatingUnreadEdge> = [];
|
||||
if (edges.top != null) visibleEdges.push({direction: Edge.TOP, edge: edges.top});
|
||||
if (edges.bottom != null) visibleEdges.push({direction: Edge.BOTTOM, edge: edges.bottom});
|
||||
function renderVisibleEdge({direction, edge}: VisibleFloatingUnreadEdge): React.ReactNode {
|
||||
let slotPositionClassName = styles.slotBottom;
|
||||
if (direction === Edge.TOP) {
|
||||
slotPositionClassName = styles.slotTop;
|
||||
}
|
||||
return (
|
||||
<flx-app-floating-unread-indicators-slot
|
||||
key={`${direction}:${edge.id}:${edge.severity}`}
|
||||
className={flxElementClassName(styles.slot, slotPositionClassName)}
|
||||
data-flx="app.floating-unread-indicators.render-visible-edge.slot"
|
||||
>
|
||||
<FloatingUnreadPill
|
||||
label={label}
|
||||
severity={edge.severity}
|
||||
direction={direction}
|
||||
onClick={() => scrollToEdge(edge, direction)}
|
||||
data-flx="app.floating-unread-indicators.render-visible-edge.floating-unread-pill.scroll-to-edge"
|
||||
/>
|
||||
</flx-app-floating-unread-indicators-slot>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<flx-app-floating-unread-indicators-layer
|
||||
className={flxElementClassName(styles.layer)}
|
||||
data-flx="app.floating-unread-indicators.layer"
|
||||
>
|
||||
<AnimePresence enterOnMount={false} data-flx="app.floating-unread-indicators.anime-presence">
|
||||
{visibleEdges.map(renderVisibleEdge)}
|
||||
</AnimePresence>
|
||||
</flx-app-floating-unread-indicators-layer>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -3,8 +3,16 @@
|
||||
import styles from '@app/features/app/components/layout/GuildHeader.module.css';
|
||||
import {GuildHeaderShell} from '@app/features/app/components/layout/GuildHeaderShell';
|
||||
import {NativeDragRegion} from '@app/features/app/components/layout/NativeDragRegion';
|
||||
import {
|
||||
reportSkeletonGuildPresentation,
|
||||
SkeletonGuildBannerPlacement,
|
||||
} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {useAnimatedImageUrl} from '@app/features/app/hooks/useAnimatedImageUrl';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import {
|
||||
measureSkeletonTextWidthPx,
|
||||
useSkeletonLayoutReport,
|
||||
} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import {clampWideAssetAspectRatio} from '@app/features/expressions/utils/AssetImageGeometry';
|
||||
import {GuildHeaderBottomSheet} from '@app/features/guild/components/bottomsheets/GuildHeaderBottomSheet';
|
||||
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
|
||||
@@ -34,6 +42,25 @@ const OPEN_COMMUNITY_MENU_FOR_DESCRIPTOR = msg({
|
||||
const HEADER_MIN_HEIGHT = 56;
|
||||
const DEFAULT_BANNER_ASPECT_RATIO = 16 / 9;
|
||||
const MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
|
||||
const GUILD_BADGE_FEATURES: ReadonlyArray<string> = [
|
||||
GuildFeatures.VERIFIED,
|
||||
GuildFeatures.PARTNERED,
|
||||
GuildFeatures.DISCOVERABLE,
|
||||
];
|
||||
|
||||
function resolveGuildBadgeVisible(features: ReadonlySet<string>): boolean {
|
||||
return GUILD_BADGE_FEATURES.some((feature) => features.has(feature));
|
||||
}
|
||||
|
||||
function resolveSkeletonBannerPlacement(hasBanner: boolean, isDetachedBanner: boolean): SkeletonGuildBannerPlacement {
|
||||
if (!hasBanner) {
|
||||
return SkeletonGuildBannerPlacement.NONE;
|
||||
}
|
||||
if (isDetachedBanner) {
|
||||
return SkeletonGuildBannerPlacement.DETACHED;
|
||||
}
|
||||
return SkeletonGuildBannerPlacement.INTEGRATED;
|
||||
}
|
||||
export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {popouts} = Popout;
|
||||
@@ -54,6 +81,10 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
});
|
||||
const isDetachedBanner = guild.features.has(GuildFeatures.DETACHED_BANNER);
|
||||
const showIntegratedBanner = Boolean(bannerURL && !isDetachedBanner);
|
||||
const bannerAspectRatio =
|
||||
guild.bannerWidth && guild.bannerHeight
|
||||
? (clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? DEFAULT_BANNER_ASPECT_RATIO)
|
||||
: DEFAULT_BANNER_ASPECT_RATIO;
|
||||
const headerContainerRef = useRef<HTMLElement | null>(null);
|
||||
const mergedHeaderContainerRef = useMergeRefs([headerContainerRef, bannerHoverRef]);
|
||||
const [containerWidth, setContainerWidth] = useState<number>(() =>
|
||||
@@ -87,11 +118,7 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
if (!showIntegratedBanner || !bannerURL || !containerWidth) {
|
||||
return {bannerMaxHeight: HEADER_MIN_HEIGHT, centerCrop: false};
|
||||
}
|
||||
const aspectRatio =
|
||||
guild.bannerWidth && guild.bannerHeight
|
||||
? (clampWideAssetAspectRatio(guild.bannerWidth / guild.bannerHeight) ?? DEFAULT_BANNER_ASPECT_RATIO)
|
||||
: DEFAULT_BANNER_ASPECT_RATIO;
|
||||
const idealHeight = containerWidth / aspectRatio;
|
||||
const idealHeight = containerWidth / bannerAspectRatio;
|
||||
const viewportCap = viewportHeight * MAX_VIEWPORT_HEIGHT_FRACTION;
|
||||
const isCapped = idealHeight > viewportCap;
|
||||
return {
|
||||
@@ -112,6 +139,17 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
[guild],
|
||||
);
|
||||
const headerButtonRef = useRef<HTMLDivElement | null>(null);
|
||||
const guildNameRef = useRef<HTMLSpanElement | null>(null);
|
||||
const badgeVisible = resolveGuildBadgeVisible(guild.features);
|
||||
const bannerPlacement = resolveSkeletonBannerPlacement(Boolean(bannerURL), isDetachedBanner);
|
||||
useSkeletonLayoutReport(() => {
|
||||
reportSkeletonGuildPresentation(guild.id, {
|
||||
headerNameWidthPx: measureSkeletonTextWidthPx(guildNameRef.current),
|
||||
badgeVisible,
|
||||
bannerPlacement,
|
||||
bannerAspectRatio,
|
||||
});
|
||||
}, `${guild.id}:${guild.name}:${badgeVisible}:${bannerPlacement}:${bannerAspectRatio}:${isMobile}`);
|
||||
return (
|
||||
<div className={styles.headerWrapper} data-flx="app.guild-header.header-wrapper">
|
||||
<NativeDragRegion
|
||||
@@ -162,6 +200,7 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
data-flx="app.guild-header.guild-badge"
|
||||
/>
|
||||
<span
|
||||
ref={guildNameRef}
|
||||
className={showIntegratedBanner ? styles.guildNameWithBanner : styles.guildNameDefault}
|
||||
data-flx="app.guild-header.guild-name"
|
||||
>
|
||||
|
||||
@@ -5,9 +5,9 @@ import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
|
||||
import {TopNagbarContext} from '@app/features/app/components/layout/app_layout/TopNagbarContext';
|
||||
import styles from '@app/features/app/components/layout/GuildLayout.module.css';
|
||||
import {GuildNavbar} from '@app/features/app/components/layout/GuildNavbar';
|
||||
import {GuildNavbarSkeleton} from '@app/features/app/components/layout/GuildNavbarSkeleton';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {GuildSidebarSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {MatureContentChannelGate} from '@app/features/channel/components/MatureContentChannelGate';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
@@ -497,7 +497,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
return (
|
||||
<TopNagbarContext.Provider value={nagbarContextValue}>
|
||||
<div className={styles.guildLayoutContent} data-flx="app.guild-layout.guild-layout-content">
|
||||
<GuildNavbarSkeleton data-flx="app.guild-layout.guild-navbar-skeleton" />
|
||||
<GuildSidebarSkeleton guildId={guildId} data-flx="app.guild-layout.guild-sidebar-skeleton" />
|
||||
<div className={styles.guildMainContent} data-flx="app.guild-layout.guild-main-content">
|
||||
{guildUnavailable ? (
|
||||
<GuildUnavailable
|
||||
@@ -572,7 +572,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
>
|
||||
{guildNagbars}
|
||||
<div className={styles.guildLayoutContent} data-flx="app.guild-layout.guild-layout-content--2">
|
||||
<GuildNavbarSkeleton data-flx="app.guild-layout.guild-navbar-skeleton--2" />
|
||||
<GuildSidebarSkeleton guildId={guildId} data-flx="app.guild-layout.guild-sidebar-skeleton--2" />
|
||||
<div className={styles.guildMainContent} data-flx="app.guild-layout.guild-main-content--4">
|
||||
<GuildUnavailable
|
||||
icon={NetworkSlashIcon}
|
||||
@@ -595,7 +595,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
>
|
||||
{guildNagbars}
|
||||
<div className={styles.guildLayoutContent} data-flx="app.guild-layout.guild-layout-content--3">
|
||||
<GuildNavbarSkeleton data-flx="app.guild-layout.guild-navbar-skeleton--3" />
|
||||
<GuildSidebarSkeleton guildId={guildId} data-flx="app.guild-layout.guild-sidebar-skeleton--3" />
|
||||
<div className={styles.guildMainContent} data-flx="app.guild-layout.guild-main-content--5">
|
||||
<GuildUnavailable
|
||||
icon={SmileySadIcon}
|
||||
@@ -641,7 +641,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
>
|
||||
{guildNagbars}
|
||||
<div className={styles.guildLayoutContent} data-flx="app.guild-layout.guild-layout-content--5">
|
||||
<GuildNavbarSkeleton data-flx="app.guild-layout.guild-navbar-skeleton--4" />
|
||||
<GuildSidebarSkeleton guildId={guildId} data-flx="app.guild-layout.guild-sidebar-skeleton--4" />
|
||||
<div className={styles.guildMainContent} data-flx="app.guild-layout.guild-main-content--7">
|
||||
<MatureContentChannelGate
|
||||
guildId={guild.id}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.skeletonContainer {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
height: calc(100% - var(--layout-user-area-reserved-height, 0px));
|
||||
min-height: 0;
|
||||
width: var(--layout-sidebar-width);
|
||||
min-width: 0;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
overflow: hidden;
|
||||
background-color: var(--background-secondary);
|
||||
padding-bottom: var(--spacing-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skeletonContainerMobile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(html.platform-native) .skeletonContainer {
|
||||
border-top-left-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
background-clip: padding-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeletonHeader {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: start;
|
||||
overflow: hidden;
|
||||
height: var(--layout-header-height);
|
||||
min-height: var(--layout-header-height);
|
||||
border-bottom: 0.0625rem solid var(--user-area-divider-color);
|
||||
background-color: var(--background-secondary);
|
||||
}
|
||||
|
||||
.skeletonHeaderPill {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: var(--layout-header-height);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 var(--spacing-4);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.skeletonHeaderPill::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 80%;
|
||||
height: 1.25rem;
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-radius: var(--radius-md);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.skeletonContent {
|
||||
padding: var(--spacing-3);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.skeletonCategory {
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
margin-top: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.skeletonCategoryPill {
|
||||
width: 60%;
|
||||
height: 1rem;
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-radius: var(--radius-md);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.skeletonChannel {
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
margin-bottom: var(--spacing-1-5);
|
||||
}
|
||||
|
||||
.skeletonChannelPill {
|
||||
width: 75%;
|
||||
height: 1.25rem;
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-radius: var(--radius-md);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(2) .skeletonChannelPill {
|
||||
width: 62%;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(3) .skeletonChannelPill {
|
||||
width: 84%;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(4) .skeletonChannelPill {
|
||||
width: 58%;
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(6) .skeletonChannelPill {
|
||||
width: 78%;
|
||||
opacity: 0.31;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(7) .skeletonChannelPill {
|
||||
width: 55%;
|
||||
opacity: 0.36;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(10) .skeletonChannelPill {
|
||||
width: 68%;
|
||||
opacity: 0.33;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(11) .skeletonChannelPill {
|
||||
width: 82%;
|
||||
opacity: 0.29;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(12) .skeletonChannelPill {
|
||||
width: 71%;
|
||||
opacity: 0.37;
|
||||
}
|
||||
|
||||
.skeletonChannel:nth-child(13) .skeletonChannelPill {
|
||||
width: 64%;
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.skeletonCategory:nth-child(1) .skeletonCategoryPill {
|
||||
width: 48%;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.skeletonCategory:nth-child(5) .skeletonCategoryPill {
|
||||
width: 68%;
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.skeletonCategory:nth-child(9) .skeletonCategoryPill {
|
||||
width: 53%;
|
||||
opacity: 0.28;
|
||||
}
|
||||
|
||||
.skeletonHeaderPill {
|
||||
width: 78%;
|
||||
opacity: 0.36;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/GuildNavbarSkeleton.module.css';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
|
||||
export const GuildNavbarSkeleton = observer(() => {
|
||||
const mobileLayout = MobileLayout;
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.skeletonContainer, mobileLayout.enabled && styles.skeletonContainerMobile)}
|
||||
data-flx="app.guild-navbar-skeleton.skeleton-container"
|
||||
>
|
||||
<div className={styles.skeletonHeader} data-flx="app.guild-navbar-skeleton.skeleton-header">
|
||||
<div className={styles.skeletonHeaderPill} data-flx="app.guild-navbar-skeleton.skeleton-header-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonContent} data-flx="app.guild-navbar-skeleton.skeleton-content">
|
||||
<div className={styles.skeletonCategory} data-flx="app.guild-navbar-skeleton.skeleton-category">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.guild-navbar-skeleton.skeleton-category-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--2">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--2" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--3">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--3" />
|
||||
</div>
|
||||
<div className={styles.skeletonCategory} data-flx="app.guild-navbar-skeleton.skeleton-category--2">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.guild-navbar-skeleton.skeleton-category-pill--2" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--4">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--4" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--5">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--5" />
|
||||
</div>
|
||||
<div className={styles.skeletonCategory} data-flx="app.guild-navbar-skeleton.skeleton-category--3">
|
||||
<div className={styles.skeletonCategoryPill} data-flx="app.guild-navbar-skeleton.skeleton-category-pill--3" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--6">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--6" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--7">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--7" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--8">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--8" />
|
||||
</div>
|
||||
<div className={styles.skeletonChannel} data-flx="app.guild-navbar-skeleton.skeleton-channel--9">
|
||||
<div className={styles.skeletonChannelPill} data-flx="app.guild-navbar-skeleton.skeleton-channel-pill--9" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -22,7 +22,7 @@
|
||||
--layout-user-area-overlay-height: calc(
|
||||
var(--layout-user-area-height) +
|
||||
var(--user-area-box-inset-block-end) +
|
||||
var(--layout-voice-connection-height, 0px)
|
||||
var(--layout-voice-connection-height)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,8 +70,6 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background-color: var(--background-secondary);
|
||||
overflow-anchor: none;
|
||||
--guild-badge-surface: var(--background-secondary);
|
||||
padding-top: var(--spacing-1);
|
||||
padding-bottom: var(--spacing-2);
|
||||
scroll-padding-bottom: calc(
|
||||
@@ -80,15 +78,15 @@
|
||||
);
|
||||
}
|
||||
|
||||
.guildListScrollContainer.guildListScrollContainer {
|
||||
overflow-anchor: auto;
|
||||
}
|
||||
|
||||
.guildListContent {
|
||||
--guild-list-item-size: var(--guild-icon-size);
|
||||
--guild-list-item-box-size: 3rem;
|
||||
--guild-list-item-gap: 0.375rem;
|
||||
--guild-list-item-inset: calc((var(--guild-list-item-box-size) - var(--guild-list-item-size)) / 2);
|
||||
--guild-folder-expanded-surface-size: var(--guild-list-item-box-size);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-bottom: calc(
|
||||
@@ -97,34 +95,9 @@
|
||||
);
|
||||
}
|
||||
|
||||
.guildListTopSection,
|
||||
.guildListGuildsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.guildListTopSection {
|
||||
gap: var(--guild-list-item-gap);
|
||||
}
|
||||
|
||||
.guildListGuildsSection {
|
||||
position: relative;
|
||||
gap: var(--guild-list-item-gap);
|
||||
}
|
||||
|
||||
.guildListItems {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.guildListItemSlot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -208,7 +181,7 @@
|
||||
}
|
||||
|
||||
.guildListReorderTarget {
|
||||
height: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
|
||||
height: var(--guild-list-item-target-size);
|
||||
}
|
||||
|
||||
.guildIcon {
|
||||
@@ -256,7 +229,7 @@
|
||||
transition-property: background-color, color, border-radius;
|
||||
transition-duration: 70ms;
|
||||
transition-timing-function: ease-out;
|
||||
background-color: var(--guilds-layout-item-bg, var(--guild-list-foreground));
|
||||
background-color: var(--guilds-layout-item-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -306,12 +279,12 @@
|
||||
|
||||
.guildIndicator {
|
||||
position: absolute;
|
||||
left: -0.15rem;
|
||||
left: var(--guild-list-indicator-inset);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--guild-icon-size);
|
||||
width: 0.5rem;
|
||||
width: var(--guild-list-indicator-track-width);
|
||||
pointer-events: none;
|
||||
container-type: layout size;
|
||||
z-index: 2;
|
||||
@@ -319,7 +292,7 @@
|
||||
|
||||
.guildIndicatorBar {
|
||||
display: block;
|
||||
width: 0.35rem;
|
||||
width: var(--guild-list-indicator-bar-width);
|
||||
border-radius: 0 var(--radius-full) var(--radius-full) 0;
|
||||
background-color: var(--text-primary);
|
||||
}
|
||||
@@ -330,20 +303,17 @@
|
||||
bottom: -0.25rem;
|
||||
pointer-events: none;
|
||||
border-radius: var(--radius-md);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.guildBadgeActive {
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary));
|
||||
}
|
||||
|
||||
.dmListSection {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
.guildBadgeActive::before,
|
||||
.guildBadge:not(:empty)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.1875rem;
|
||||
z-index: -1;
|
||||
border-radius: calc(var(--radius-md) + 0.1875rem);
|
||||
background-color: var(--guild-badge-surface);
|
||||
}
|
||||
|
||||
.guildVoiceBadge {
|
||||
@@ -363,7 +333,7 @@
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--status-online);
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary));
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface);
|
||||
pointer-events: none;
|
||||
color: white;
|
||||
}
|
||||
@@ -385,7 +355,7 @@
|
||||
border-radius: var(--radius-full);
|
||||
background-color: white;
|
||||
color: var(--status-danger);
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary));
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface);
|
||||
}
|
||||
|
||||
.dmListItem {
|
||||
@@ -431,7 +401,7 @@
|
||||
|
||||
.fluxerButtonIcon {
|
||||
composes: guildIcon;
|
||||
background-color: var(--guilds-layout-item-bg, var(--guild-list-foreground));
|
||||
background-color: var(--guilds-layout-item-bg);
|
||||
color: var(--text-primary);
|
||||
transition-property: background-color, color, border-radius;
|
||||
transition-duration: 70ms;
|
||||
@@ -487,12 +457,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
.guildDividerSlot {
|
||||
--guild-list-divider-previous-extra: 0px;
|
||||
--guild-list-divider-next-extra: 0px;
|
||||
display: grid;
|
||||
grid-template-rows:
|
||||
calc(var(--guild-list-divider-base-gap) + var(--guild-list-divider-next-extra))
|
||||
var(--guild-list-divider-line-size)
|
||||
calc(var(--guild-list-divider-base-gap) + var(--guild-list-divider-previous-extra));
|
||||
justify-items: center;
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.guildDividerSlot[data-next-row-extended='true'] {
|
||||
--guild-list-divider-next-extra: var(--guild-list-item-target-half-extra);
|
||||
}
|
||||
|
||||
.guildDivider {
|
||||
height: 0.125rem;
|
||||
grid-row: 2;
|
||||
height: var(--guild-list-divider-line-size);
|
||||
width: 2rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--guild-list-item-gap);
|
||||
margin-bottom: var(--guild-list-item-gap);
|
||||
border-radius: 0.0625rem;
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
@@ -514,9 +500,9 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fluxerSymbolIcon {
|
||||
height: 2.75rem;
|
||||
width: 2.75rem;
|
||||
.messageBubbleIcon {
|
||||
height: 1.75rem;
|
||||
width: 1.75rem;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
@@ -631,7 +617,7 @@
|
||||
|
||||
:global(html.platform-native) .outlineFrame {
|
||||
border-top: 0.0625rem solid var(--user-area-divider-color);
|
||||
--outline-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
--outline-radius: var(--outline-frame-radius-native);
|
||||
}
|
||||
|
||||
:global(html.platform-native) .outlineFrame.outlineFrameFullscreen {
|
||||
@@ -669,7 +655,6 @@
|
||||
}
|
||||
|
||||
.guildTooltipStat {
|
||||
--guild-tooltip-count-skeleton-width: 2.125rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
@@ -677,14 +662,6 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.guildTooltipStatOnline {
|
||||
--guild-tooltip-count-skeleton-width: 2.125rem;
|
||||
}
|
||||
|
||||
.guildTooltipStatMembers {
|
||||
--guild-tooltip-count-skeleton-width: 2.5rem;
|
||||
}
|
||||
|
||||
.guildTooltipStatDot {
|
||||
margin-right: 0.3rem;
|
||||
height: 0.5rem;
|
||||
@@ -713,31 +690,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.guildTooltipStatSkeleton {
|
||||
height: 0.6875rem;
|
||||
width: var(--guild-tooltip-count-skeleton-width);
|
||||
min-width: var(--guild-tooltip-count-skeleton-width);
|
||||
border-radius: 62.4375rem;
|
||||
background: color-mix(in srgb, var(--text-primary-muted) 22%, transparent);
|
||||
animation: guild-tooltip-stat-skeleton-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
:global(html.reduced-motion) .guildTooltipStatSkeleton {
|
||||
animation: none;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
@keyframes guild-tooltip-stat-skeleton-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.44;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.72;
|
||||
}
|
||||
}
|
||||
|
||||
.guildVoiceInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -774,7 +726,7 @@
|
||||
right: calc(-0.25rem - 0.1875rem);
|
||||
bottom: calc(-0.25rem - 0.1875rem);
|
||||
pointer-events: none;
|
||||
background-color: var(--guild-badge-surface, var(--background-secondary));
|
||||
background-color: var(--guild-badge-surface);
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0.1875rem;
|
||||
}
|
||||
@@ -796,10 +748,6 @@
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
:global(.theme-light) .guildsLayoutContainer {
|
||||
--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);
|
||||
}
|
||||
|
||||
.roundedFull {
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import styles from '@app/features/app/components/layout/MobileBottomNav.module.css';
|
||||
import {reportSkeletonVoiceConnected} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import {PRIMARY_NAVIGATION_LANDMARK_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
@@ -49,6 +51,9 @@ export const MobileBottomNav = observer(({currentUser}: MobileBottomNavProps) =>
|
||||
const isDirectCallChannel = Boolean(
|
||||
voiceChannel && (voiceChannel.type === ChannelTypes.DM || voiceChannel.type === ChannelTypes.GROUP_DM),
|
||||
);
|
||||
useSkeletonLayoutReport(() => {
|
||||
reportSkeletonVoiceConnected(isConnectedToVoice);
|
||||
}, `${isConnectedToVoice}`);
|
||||
useEffect(() => {
|
||||
if (Routes.isChannelRoute(location.pathname)) {
|
||||
const channel = Navigation.channelId ? Channels.getChannel(Navigation.channelId) : undefined;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/NagbarButton.module.css';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Button, type ButtonVariant} from '@app/features/ui/button/Button';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
|
||||
@@ -12,6 +12,7 @@ interface NagbarButtonProps {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
submitting?: boolean;
|
||||
variant?: ButtonVariant;
|
||||
}
|
||||
|
||||
export const NagbarButton = ({
|
||||
@@ -21,10 +22,11 @@ export const NagbarButton = ({
|
||||
className,
|
||||
disabled = false,
|
||||
submitting,
|
||||
variant = 'inverted',
|
||||
}: NagbarButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
variant="inverted"
|
||||
variant={variant}
|
||||
superCompact={!isMobile}
|
||||
compact={isMobile}
|
||||
fitContent
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export interface NagbarTone {
|
||||
readonly backgroundColor: string;
|
||||
readonly textColor: string;
|
||||
}
|
||||
|
||||
export const NagbarToneKind = Object.freeze({
|
||||
NEUTRAL: 'neutral',
|
||||
MAINTENANCE: 'maintenance',
|
||||
MAINTENANCE_SCHEDULED: 'maintenance_scheduled',
|
||||
MAINTENANCE_ACTIVE: 'maintenance_active',
|
||||
MAINTENANCE_COMPLETED: 'maintenance_completed',
|
||||
BRAND: 'brand',
|
||||
DANGER: 'danger',
|
||||
ALERT: 'alert',
|
||||
PREMIUM: 'premium',
|
||||
LEGAL: 'legal',
|
||||
VOICE: 'voice',
|
||||
CRITICAL: 'critical',
|
||||
DEVELOPMENT: 'development',
|
||||
STREAMER: 'streamer',
|
||||
ENCODER: 'encoder',
|
||||
} as const);
|
||||
|
||||
export type NagbarToneKind = (typeof NagbarToneKind)[keyof typeof NagbarToneKind];
|
||||
|
||||
export const NAGBAR_TONES: Readonly<Record<NagbarToneKind, NagbarTone>> = Object.freeze({
|
||||
[NagbarToneKind.NEUTRAL]: Object.freeze({backgroundColor: '#4e5058', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.MAINTENANCE]: Object.freeze({backgroundColor: '#4285f4', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.MAINTENANCE_SCHEDULED]: Object.freeze({backgroundColor: '#1d4ed8', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.MAINTENANCE_ACTIVE]: Object.freeze({backgroundColor: '#9a3412', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.MAINTENANCE_COMPLETED]: Object.freeze({backgroundColor: '#166534', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.BRAND]: Object.freeze({
|
||||
backgroundColor: 'var(--brand-primary)',
|
||||
textColor: 'var(--text-on-brand-primary)',
|
||||
}),
|
||||
[NagbarToneKind.DANGER]: Object.freeze({
|
||||
backgroundColor: 'var(--status-danger)',
|
||||
textColor: 'var(--text-on-brand-primary)',
|
||||
}),
|
||||
[NagbarToneKind.ALERT]: Object.freeze({backgroundColor: '#ea580c', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.PREMIUM]: Object.freeze({backgroundColor: '#f97316', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.LEGAL]: Object.freeze({backgroundColor: '#4338ca', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.VOICE]: Object.freeze({backgroundColor: '#15803d', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.CRITICAL]: Object.freeze({backgroundColor: '#b00000', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.DEVELOPMENT]: Object.freeze({backgroundColor: '#17231d', textColor: '#72e6a2'}),
|
||||
[NagbarToneKind.STREAMER]: Object.freeze({backgroundColor: '#5865f2', textColor: '#ffffff'}),
|
||||
[NagbarToneKind.ENCODER]: Object.freeze({backgroundColor: '#b45309', textColor: '#ffffff'}),
|
||||
});
|
||||
|
||||
export const NAGBAR_TONE_NEUTRAL: NagbarTone = NAGBAR_TONES[NagbarToneKind.NEUTRAL];
|
||||
export const NAGBAR_TONE_MAINTENANCE: NagbarTone = NAGBAR_TONES[NagbarToneKind.MAINTENANCE];
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/NullSpaceDropIndicator.module.css';
|
||||
import {DND_TYPES, type DragItem, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
@@ -18,7 +18,7 @@ export const NullSpaceDropIndicator = observer(
|
||||
({isDraggingAnything, onChannelDrop, variant = 'top'}: NullSpaceDropIndicatorProps) => {
|
||||
const [{isOver, canDrop}, dropRef] = useDrop(
|
||||
() => ({
|
||||
accept: [DND_TYPES.CHANNEL, DND_TYPES.CATEGORY],
|
||||
accept: [DragItemType.CHANNEL, DragItemType.CATEGORY],
|
||||
drop: (item: DragItem): DropResult => {
|
||||
const result: DropResult =
|
||||
variant === 'top'
|
||||
|
||||
@@ -61,7 +61,7 @@ export const OutlineFrame: React.FC<OutlineFrameProps> = ({
|
||||
{nagbar}
|
||||
<div className={styles.contentWrapper} data-flx="app.outline-frame.content-wrapper">
|
||||
{sidebarDivider && <div className={styles.divider} aria-hidden data-flx="app.outline-frame.divider" />}
|
||||
{sidebarResizeHandle && <SidebarResizeHandle />}
|
||||
{sidebarResizeHandle && <SidebarResizeHandle data-flx="app.outline-frame.sidebar-resize-handle" />}
|
||||
<div className={styles.body} data-flx="app.outline-frame.body">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import SidebarWidth, {
|
||||
SIDEBAR_WIDTH_MAX,
|
||||
SIDEBAR_WIDTH_MIN,
|
||||
} from '@app/features/ui/state/SidebarWidth';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {getAppRemScale} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -33,7 +33,7 @@ interface DragState {
|
||||
}
|
||||
|
||||
function resolveZoomFactor(ownerDocument: Document): number {
|
||||
const zoomFactor = getAppZoomFactor(ownerDocument);
|
||||
const zoomFactor = getAppRemScale(ownerDocument);
|
||||
if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return 1;
|
||||
return zoomFactor;
|
||||
}
|
||||
@@ -229,7 +229,7 @@ export const SidebarResizeHandle = observer(() => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FocusRing offset={-2}>
|
||||
<FocusRing offset={-2} data-flx="app.sidebar-resize-handle.focus-ring">
|
||||
<div
|
||||
ref={handleRef}
|
||||
role="separator"
|
||||
@@ -249,7 +249,7 @@ export const SidebarResizeHandle = observer(() => {
|
||||
onKeyDown={onKeyDown}
|
||||
data-flx="app.sidebar-resize-handle"
|
||||
>
|
||||
<span className={styles.indicator} aria-hidden />
|
||||
<span className={styles.indicator} aria-hidden data-flx="app.sidebar-resize-handle.indicator" />
|
||||
</div>
|
||||
</FocusRing>
|
||||
);
|
||||
|
||||
@@ -1,264 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.splashOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-index-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--background-secondary);
|
||||
padding-left: var(--spacing-6);
|
||||
padding-right: var(--spacing-6);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
.splashOverlay * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) .splashOverlay {
|
||||
top: var(--native-titlebar-height);
|
||||
}
|
||||
|
||||
.quote,
|
||||
.quoteSource,
|
||||
.quoteLink,
|
||||
.incidentCta,
|
||||
.connectionIssuesOverlay,
|
||||
.connectionIssuesOverlay * {
|
||||
user-select: text !important;
|
||||
-webkit-user-select: text !important;
|
||||
-moz-user-select: text !important;
|
||||
-ms-user-select: text !important;
|
||||
}
|
||||
|
||||
.topDragRegion {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--layout-header-height);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos) .topDragRegion {
|
||||
left: var(--traffic-lights-backdrop-width, var(--layout-guild-list-width, 4.5rem));
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.splashOverlay {
|
||||
padding-left: var(--spacing-8);
|
||||
padding-right: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.splashOverlay {
|
||||
padding-left: var(--spacing-12);
|
||||
padding-right: var(--spacing-12);
|
||||
}
|
||||
}
|
||||
|
||||
.splashContent {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
z-index: var(--z-index-elevated-1);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.splashContent {
|
||||
max-width: 32rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.splashContent {
|
||||
max-width: 36rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.splashContent {
|
||||
max-width: 42rem;
|
||||
}
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.iconWrapper {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.iconWrapper {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.iconPulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-primary);
|
||||
opacity: 0.75;
|
||||
animation: splashPulse 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.iconPulseStatic {
|
||||
animation: none;
|
||||
opacity: 0.35;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
z-index: var(--z-index-elevated-1);
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.icon {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.icon {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes splashPulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.75;
|
||||
}
|
||||
75% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.quote {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.quote {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quoteSource {
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 600;
|
||||
margin: var(--spacing-1) 0 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.quoteSource {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quoteContainer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 4rem;
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
.quoteLink {
|
||||
text-align: center;
|
||||
color: var(--text-link);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quoteLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.quoteLink {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.incidentCta {
|
||||
margin-top: var(--spacing-3);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.incidentCta:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.connectionIssuesOverlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: var(--spacing-6);
|
||||
transform: translateX(-50%);
|
||||
width: min(100%, 32rem);
|
||||
padding: 0 var(--spacing-4);
|
||||
z-index: var(--z-index-elevated-2);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.connectionIssuesOverlay {
|
||||
bottom: var(--spacing-8);
|
||||
}
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {ConnectionIssuesLinks} from '@app/features/app/components/ConnectionIssuesLinks';
|
||||
import {NativeDragRegion} from '@app/features/app/components/layout/NativeDragRegion';
|
||||
import styles from '@app/features/app/components/layout/SplashScreen.module.css';
|
||||
import {useSplashScreenGuard} from '@app/features/app/hooks/useSplashScreenGuard';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import {FluxerIcon} from '@app/features/ui/components/icons/FluxerIcon';
|
||||
import {getReducedMotionProps} from '@app/features/ui/utils/ReducedMotionAnimation';
|
||||
import StatusPage from '@app/features/user/state/StatusPage';
|
||||
import {type SplashQuote, useSplashQuotes} from '@app/media/data/SplashQuotes';
|
||||
import {ExternalUrls} from '@fluxer/constants/src/ExternalUrls';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const PROBLEMS_DELAY = 10000;
|
||||
const STATUS_PAGE_DISPLAY_DELAY = 5000;
|
||||
const FORCE_DISMISS_TIMEOUT_MS = 3000;
|
||||
|
||||
type SplashScreenMode = 'live' | 'outage';
|
||||
|
||||
export const SplashScreen = observer(() => {
|
||||
const shouldBypass = DeveloperOptions.bypassSplashScreen;
|
||||
const interrupted = GatewayConnection.isConnectionInterrupted;
|
||||
const isInitialized = Initialization.canNavigateToProtectedRoutes;
|
||||
const isReady = !interrupted && isInitialized;
|
||||
const [hasTimedOut, setHasTimedOut] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady) {
|
||||
setHasTimedOut(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setHasTimedOut(true), FORCE_DISMISS_TIMEOUT_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isReady]);
|
||||
|
||||
if (shouldBypass || hasTimedOut) return null;
|
||||
return (
|
||||
<AnimatePresence initial={false} data-flx="app.splash-screen.animate-presence">
|
||||
{!isReady && <SplashScreenContent mode="live" data-flx="app.splash-screen.splash-screen-content" />}
|
||||
</AnimatePresence>
|
||||
);
|
||||
});
|
||||
export const OutageSplashScreen = observer(() => (
|
||||
<SplashScreenContent mode="outage" data-flx="app.splash-screen.outage-splash-screen.splash-screen-content" />
|
||||
));
|
||||
const SPLASH_MOTION = {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
transition: {duration: 0.35},
|
||||
};
|
||||
const CONNECTION_ISSUES_MOTION = {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
transition: {duration: 0.4},
|
||||
};
|
||||
const INCIDENT_CTA_MOTION = {
|
||||
initial: {opacity: 0},
|
||||
animate: {opacity: 1},
|
||||
exit: {opacity: 0},
|
||||
transition: {duration: 0.2},
|
||||
};
|
||||
|
||||
interface SplashScreenContentProps {
|
||||
mode: SplashScreenMode;
|
||||
}
|
||||
|
||||
function pickRandomQuote(quotes: ReadonlyArray<SplashQuote>): SplashQuote {
|
||||
return quotes[Math.floor(Math.random() * quotes.length)];
|
||||
}
|
||||
|
||||
const SplashScreenContent = observer(({mode}: SplashScreenContentProps) => {
|
||||
const splashOverlayRef = useRef<HTMLDivElement | null>(null);
|
||||
useSplashScreenGuard(splashOverlayRef);
|
||||
const isOutageMode = mode === 'outage';
|
||||
const splashMotion = getReducedMotionProps(SPLASH_MOTION, Accessibility.useReducedMotion);
|
||||
const connectionIssuesMotion = getReducedMotionProps(CONNECTION_ISSUES_MOTION, Accessibility.useReducedMotion);
|
||||
const incidentCtaMotion = getReducedMotionProps(INCIDENT_CTA_MOTION, Accessibility.useReducedMotion);
|
||||
const interrupted = isOutageMode ? true : GatewayConnection.isConnectionInterrupted;
|
||||
const isInitialized = isOutageMode ? false : Initialization.canNavigateToProtectedRoutes;
|
||||
const isReady = !interrupted && isInitialized;
|
||||
const splashQuotes = useSplashQuotes();
|
||||
const incident = StatusPage.incident;
|
||||
const quoteRef = useRef<SplashQuote | null>(null);
|
||||
if (quoteRef.current == null) {
|
||||
quoteRef.current = pickRandomQuote(splashQuotes);
|
||||
}
|
||||
const quote = quoteRef.current;
|
||||
const [showStatusData, setShowStatusData] = useState(isOutageMode);
|
||||
const [showProblems, setShowProblems] = useState(isOutageMode);
|
||||
const problemsTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const clearProblemsTimer = useCallback(() => {
|
||||
if (problemsTimerRef.current != null) {
|
||||
clearTimeout(problemsTimerRef.current);
|
||||
problemsTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (isOutageMode) {
|
||||
setShowStatusData(true);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setShowStatusData(true), STATUS_PAGE_DISPLAY_DELAY);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOutageMode]);
|
||||
useEffect(() => {
|
||||
if (isOutageMode) {
|
||||
clearProblemsTimer();
|
||||
setShowProblems(true);
|
||||
void StatusPage.checkIncidents();
|
||||
return;
|
||||
}
|
||||
if (isReady) {
|
||||
clearProblemsTimer();
|
||||
setShowProblems(false);
|
||||
return;
|
||||
}
|
||||
problemsTimerRef.current = setTimeout(() => {
|
||||
setShowProblems(true);
|
||||
void StatusPage.checkIncidents();
|
||||
}, PROBLEMS_DELAY);
|
||||
return clearProblemsTimer;
|
||||
}, [clearProblemsTimer, isOutageMode, isReady]);
|
||||
const liveIncident = showStatusData ? incident : null;
|
||||
const frozenDisplayRef = useRef<{incident: typeof liveIncident; text: string} | null>(null);
|
||||
if (!isReady) {
|
||||
frozenDisplayRef.current = {
|
||||
incident: liveIncident,
|
||||
text: liveIncident ? liveIncident.name : quote.text,
|
||||
};
|
||||
}
|
||||
const visibleIncident = frozenDisplayRef.current?.incident ?? liveIncident;
|
||||
const displayText = frozenDisplayRef.current?.text ?? quote.text;
|
||||
const incidentUrl = visibleIncident?.url ?? ExternalUrls.SERVICE_STATUS;
|
||||
if (isOutageMode) {
|
||||
return (
|
||||
<div
|
||||
ref={splashOverlayRef}
|
||||
className={styles.splashOverlay}
|
||||
data-flx="app.splash-screen.splash-screen-content.splash-overlay"
|
||||
>
|
||||
<NativeDragRegion
|
||||
className={styles.topDragRegion}
|
||||
data-flx="app.splash-screen.splash-screen-content.top-drag-region"
|
||||
/>
|
||||
<div className={styles.splashContent} data-flx="app.splash-screen.splash-screen-content.splash-content">
|
||||
<div className={styles.iconWrapper} data-flx="app.splash-screen.splash-screen-content.icon-wrapper">
|
||||
<div
|
||||
className={`${styles.iconPulse} ${styles.iconPulseStatic}`}
|
||||
data-flx="app.splash-screen.splash-screen-content.icon-pulse"
|
||||
/>
|
||||
<FluxerIcon className={styles.icon} data-flx="app.splash-screen.splash-screen-content.icon" />
|
||||
</div>
|
||||
<div className={styles.quoteContainer} data-flx="app.splash-screen.splash-screen-content.quote-container">
|
||||
{visibleIncident != null ? (
|
||||
<a
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.quoteLink}
|
||||
data-flx="app.splash-screen.splash-screen-content.quote-link"
|
||||
>
|
||||
{displayText}
|
||||
</a>
|
||||
) : (
|
||||
<p className={styles.quote} data-flx="app.splash-screen.splash-screen-content.quote">
|
||||
<Trans>Connection lost</Trans>
|
||||
</p>
|
||||
)}
|
||||
{visibleIncident != null && (
|
||||
<a
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.incidentCta}
|
||||
data-flx="app.splash-screen.splash-screen-content.incident-cta"
|
||||
>
|
||||
<Trans>View on status page</Trans>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showProblems && !isReady && visibleIncident == null && (
|
||||
<div
|
||||
className={styles.connectionIssuesOverlay}
|
||||
data-flx="app.splash-screen.splash-screen-content.connection-issues-overlay"
|
||||
>
|
||||
<ConnectionIssuesLinks
|
||||
incident={incident}
|
||||
data-flx="app.splash-screen.splash-screen-content.connection-issues-links"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
ref={splashOverlayRef}
|
||||
data-flx="app.splash-screen.splash-screen-content.splash-overlay--2"
|
||||
{...splashMotion}
|
||||
className={styles.splashOverlay}
|
||||
style={{pointerEvents: isReady ? 'none' : 'auto'}}
|
||||
>
|
||||
<NativeDragRegion
|
||||
className={styles.topDragRegion}
|
||||
data-flx="app.splash-screen.splash-screen-content.top-drag-region--2"
|
||||
/>
|
||||
<div className={styles.splashContent} data-flx="app.splash-screen.splash-screen-content.splash-content--2">
|
||||
<div className={styles.iconWrapper} data-flx="app.splash-screen.splash-screen-content.icon-wrapper--2">
|
||||
<div className={styles.iconPulse} data-flx="app.splash-screen.splash-screen-content.icon-pulse--2" />
|
||||
<FluxerIcon className={styles.icon} data-flx="app.splash-screen.splash-screen-content.icon--2" />
|
||||
</div>
|
||||
<div className={styles.quoteContainer} data-flx="app.splash-screen.splash-screen-content.quote-container--2">
|
||||
{visibleIncident != null ? (
|
||||
<a
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.quoteLink}
|
||||
data-flx="app.splash-screen.splash-screen-content.quote-link--2"
|
||||
>
|
||||
{displayText}
|
||||
</a>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.quote} data-flx="app.splash-screen.splash-screen-content.quote--2">
|
||||
{displayText}
|
||||
</p>
|
||||
<p className={styles.quoteSource} data-flx="app.splash-screen.splash-screen-content.quote-source">
|
||||
{quote.source}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<AnimatePresence data-flx="app.splash-screen.splash-screen-content.animate-presence">
|
||||
{visibleIncident != null && (
|
||||
<motion.a
|
||||
data-flx="app.splash-screen.splash-screen-content.incident-cta--2"
|
||||
{...incidentCtaMotion}
|
||||
href={incidentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.incidentCta}
|
||||
>
|
||||
<Trans>View on status page</Trans>
|
||||
</motion.a>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence data-flx="app.splash-screen.splash-screen-content.animate-presence--2">
|
||||
{showProblems && !isReady && visibleIncident == null && (
|
||||
<motion.div
|
||||
data-flx="app.splash-screen.splash-screen-content.connection-issues-overlay--2"
|
||||
{...connectionIssuesMotion}
|
||||
className={styles.connectionIssuesOverlay}
|
||||
>
|
||||
<ConnectionIssuesLinks
|
||||
incident={incident}
|
||||
data-flx="app.splash-screen.splash-screen-content.connection-issues-links--2"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type UserAreaMuteReason,
|
||||
} from '@app/features/app/components/layout/UserAreaState';
|
||||
import {CustomStatusDisplay} from '@app/features/app/components/shared/custom_status_display/CustomStatusDisplay';
|
||||
import {reportSkeletonVoicePresence} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {getStatusTypeLabel} from '@app/features/app/constants/AppConstants';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import * as VoiceStateCommands from '@app/features/devtools/commands/VoiceStateCommands';
|
||||
@@ -155,11 +156,13 @@ const UserAreaInner = observer(
|
||||
};
|
||||
if (!hasVoiceConnection) {
|
||||
clearHeight();
|
||||
reportSkeletonVoicePresence(false, 0);
|
||||
return;
|
||||
}
|
||||
const element = voiceConnectionRef.current;
|
||||
if (!element || typeof ResizeObserver === 'undefined') {
|
||||
clearHeight();
|
||||
reportSkeletonVoicePresence(false, 0);
|
||||
return;
|
||||
}
|
||||
let pendingHeight: number | null = null;
|
||||
@@ -169,6 +172,7 @@ const UserAreaInner = observer(
|
||||
if (voiceConnectionHeightRef.current !== roundedHeight) {
|
||||
voiceConnectionHeightRef.current = roundedHeight;
|
||||
root.style.setProperty(VOICE_CONNECTION_HEIGHT_VARIABLE, `${roundedHeight}px`);
|
||||
reportSkeletonVoicePresence(true, roundedHeight);
|
||||
}
|
||||
} else {
|
||||
clearHeight();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {LongPressable} from '@app/features/app/components/LongPressable';
|
||||
import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css';
|
||||
import {DND_TYPES} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {DragItemType} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import styles from '@app/features/app/components/layout/VoiceParticipantItem.module.css';
|
||||
import {VoiceStateIcons} from '@app/features/app/components/layout/VoiceStateIcons';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
@@ -83,9 +83,9 @@ export const VoiceParticipantItem = observer(function VoiceParticipantItem({
|
||||
const isContextMenuOpen = useContextMenuHoverState(rowRef);
|
||||
const [{isDragging}, dragRef] = useDrag(
|
||||
() => ({
|
||||
type: DND_TYPES.VOICE_PARTICIPANT,
|
||||
type: DragItemType.VOICE_PARTICIPANT,
|
||||
item: {
|
||||
type: DND_TYPES.VOICE_PARTICIPANT,
|
||||
type: DragItemType.VOICE_PARTICIPANT,
|
||||
id: user.id,
|
||||
userId: user.id,
|
||||
guildId,
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
NagbarType,
|
||||
} from '@app/features/app/components/layout/app_layout/AppLayoutTypes';
|
||||
import {isScheduledMaintenanceNagbarDismissed} from '@app/features/app/components/layout/app_layout/ScheduledMaintenanceDismissal';
|
||||
import Config from '@app/features/app/config/Config';
|
||||
import {isClientReconnecting} from '@app/features/app/state/ClientReadiness';
|
||||
import Initialization from '@app/features/app/state/Initialization';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
@@ -42,13 +44,17 @@ function sortNagbarsByPriority(a: NagbarState, b: NagbarState): number {
|
||||
|
||||
export function selectVisibleNagbars(nagbars: Array<NagbarState>): Array<NagbarState> {
|
||||
const visibleNagbars = nagbars.filter((nagbar) => nagbar.visible).sort(sortNagbarsByPriority);
|
||||
const nonDismissible = visibleNagbars.filter((nagbar) => !nagbar.dismissible);
|
||||
const dismissible = visibleNagbars.filter((nagbar) => nagbar.dismissible);
|
||||
const pinned = visibleNagbars.filter((nagbar) => nagbar.type === NagbarType.BUILD_ENVIRONMENT);
|
||||
const selectable = visibleNagbars.filter((nagbar) => nagbar.type !== NagbarType.BUILD_ENVIRONMENT);
|
||||
const nonDismissible = selectable.filter((nagbar) => !nagbar.dismissible);
|
||||
const dismissible = selectable.filter((nagbar) => nagbar.dismissible);
|
||||
const selectedNonDismissible = nonDismissible.slice(0, 1);
|
||||
const selectedDismissible = dismissible.slice(0, 1);
|
||||
return [...selectedNonDismissible, ...selectedDismissible].sort(sortNagbarsByPriority);
|
||||
return [...pinned, ...selectedNonDismissible, ...selectedDismissible].sort(sortNagbarsByPriority);
|
||||
}
|
||||
|
||||
const BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS = new Set<string>(['stable', 'canary']);
|
||||
|
||||
export const useAppLayoutState = (): AppLayoutState => {
|
||||
const [isStandalone, setIsStandalone] = useState(isStandalonePwa());
|
||||
useEffect(() => {
|
||||
@@ -220,6 +226,14 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
const canShowLinuxInputAccess = NativePermission.shouldShowLinuxInputAccessNagbar;
|
||||
const canShowSoftwareEncoder = SoftwareEncoderWarning.showWarning;
|
||||
const canShowStreamerMode = StreamerMode.shouldShowNagbar;
|
||||
const canShowBuildEnvironment =
|
||||
!BUILD_ENVIRONMENT_HIDDEN_RELEASE_CHANNELS.has(Config.PUBLIC_RELEASE_CHANNEL) &&
|
||||
!nagbarState.buildEnvironmentDismissedThisSession;
|
||||
const canShowConnection = nagbarState.forceHideConnectionNotice
|
||||
? false
|
||||
: nagbarState.forceConnectionNotice
|
||||
? true
|
||||
: isClientReconnecting();
|
||||
const needsTermsAcceptance = (() => {
|
||||
if (!user) return false;
|
||||
if (isSelfHosted) return false;
|
||||
@@ -235,6 +249,8 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
return termsOutdated || privacyOutdated;
|
||||
})();
|
||||
return {
|
||||
canShowBuildEnvironment,
|
||||
canShowConnection,
|
||||
canShowCorruptedInstallation: nagbarState.forceHideCorruptedInstallation
|
||||
? false
|
||||
: nagbarState.forceCorruptedInstallation
|
||||
@@ -273,6 +289,18 @@ export const useNagbarConditions = (): NagbarConditions => {
|
||||
export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarState> => {
|
||||
return useMemo(() => {
|
||||
const nagbars: Array<NagbarState> = [
|
||||
{
|
||||
type: NagbarType.BUILD_ENVIRONMENT,
|
||||
priority: -1000,
|
||||
visible: conditions.canShowBuildEnvironment,
|
||||
dismissible: true,
|
||||
},
|
||||
{
|
||||
type: NagbarType.CONNECTION,
|
||||
priority: -100,
|
||||
visible: conditions.canShowConnection,
|
||||
dismissible: false,
|
||||
},
|
||||
{
|
||||
type: NagbarType.CORRUPTED_INSTALLATION,
|
||||
priority: -10,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
|
||||
export const NagbarType = {
|
||||
BUILD_ENVIRONMENT: 'build-environment',
|
||||
CONNECTION: 'connection',
|
||||
CORRUPTED_INSTALLATION: 'corrupted-installation',
|
||||
SCHEDULED_MAINTENANCE: 'scheduled-maintenance',
|
||||
UNCLAIMED_ACCOUNT: 'unclaimed-account',
|
||||
@@ -36,6 +38,8 @@ export interface AppLayoutState {
|
||||
}
|
||||
|
||||
export interface NagbarConditions {
|
||||
canShowBuildEnvironment: boolean;
|
||||
canShowConnection: boolean;
|
||||
canShowCorruptedInstallation: boolean;
|
||||
canShowScheduledMaintenance: boolean;
|
||||
userIsUnclaimed: boolean;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import {type NagbarState, NagbarType} from '@app/features/app/components/layout/app_layout/AppLayoutTypes';
|
||||
import styles from '@app/features/app/components/layout/app_layout/NagbarContainer.module.css';
|
||||
import {BuildEnvironmentNagbar} from '@app/features/app/components/layout/app_layout/nagbars/BuildEnvironmentNagbar';
|
||||
import {ConnectionNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ConnectionNagbar';
|
||||
import {CorruptedInstallationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/CorruptedInstallationNagbar';
|
||||
import {DesktopDownloadNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopDownloadNagbar';
|
||||
import {DesktopNotificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/DesktopNotificationNagbar';
|
||||
@@ -28,6 +30,13 @@ interface NagbarContainerProps {
|
||||
nagbars: Array<NagbarState>;
|
||||
}
|
||||
|
||||
class UnexpectedNagbarTypeError extends Error {
|
||||
constructor(nagbarType: never) {
|
||||
super(`Unexpected nagbar type: ${String(nagbarType)}`);
|
||||
this.name = 'UnexpectedNagbarTypeError';
|
||||
}
|
||||
}
|
||||
|
||||
export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbars}) => {
|
||||
const mobileLayout = MobileLayout;
|
||||
const showPremiumFeatures = shouldShowPremiumFeatures();
|
||||
@@ -36,6 +45,22 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
<div className={styles.container} data-flx="app.app-layout.nagbar-container.container">
|
||||
{nagbars.map((nagbar) => {
|
||||
switch (nagbar.type) {
|
||||
case NagbarType.BUILD_ENVIRONMENT:
|
||||
return (
|
||||
<BuildEnvironmentNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.build-environment-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.CONNECTION:
|
||||
return (
|
||||
<ConnectionNagbar
|
||||
key={nagbar.type}
|
||||
isMobile={mobileLayout.enabled}
|
||||
data-flx="app.app-layout.nagbar-container.connection-nagbar"
|
||||
/>
|
||||
);
|
||||
case NagbarType.CORRUPTED_INSTALLATION:
|
||||
return (
|
||||
<CorruptedInstallationNagbar
|
||||
@@ -177,7 +202,7 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
throw new UnexpectedNagbarTypeError(nagbar.type);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import Config from '@app/features/app/config/Config';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import NagbarController from '@app/features/ui/state/Nagbar';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
|
||||
const BUILD_ENVIRONMENT_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'You are using a {releaseChannel} build of {productName}.',
|
||||
comment:
|
||||
'Session-dismissible nagbar identifying a non-stable official desktop app build. {releaseChannel} is development, staging, or canary. {productName} is the fixed official desktop product name, not the active instance name.',
|
||||
});
|
||||
|
||||
export function BuildEnvironmentNagbar({isMobile}: {isMobile: boolean}) {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.DEVELOPMENT].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.DEVELOPMENT].textColor}
|
||||
dismissible
|
||||
onDismiss={NagbarController.dismissBuildEnvironmentNagbar}
|
||||
data-flx="app.app-layout.nagbars.build-environment-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
onDismiss={NagbarController.dismissBuildEnvironmentNagbar}
|
||||
message={i18n._(BUILD_ENVIRONMENT_MESSAGE_DESCRIPTOR, {
|
||||
releaseChannel: Config.PUBLIC_RELEASE_CHANNEL,
|
||||
productName: PRODUCT_NAME,
|
||||
})}
|
||||
data-flx="app.app-layout.nagbars.build-environment-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.indicator {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
width: var(--typing-indicator-animation-size);
|
||||
height: var(--typing-indicator-animation-size);
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {switchAccountFromStalledConnection} from '@app/features/app/ConnectionRecovery';
|
||||
import styles from '@app/features/app/components/layout/app_layout/nagbars/ConnectionNagbar.module.css';
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {
|
||||
NAGBAR_TONE_MAINTENANCE,
|
||||
NAGBAR_TONE_NEUTRAL,
|
||||
type NagbarTone,
|
||||
} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {NagbarSkeleton} from '@app/features/app/components/skeleton/NagbarSkeleton';
|
||||
import type {RememberedSkeletonNagbarRow} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
||||
import {
|
||||
type ConnectionNotice,
|
||||
ConnectionNoticeTone,
|
||||
useConnectionNotice,
|
||||
} from '@app/features/app/hooks/useConnectionNotice';
|
||||
import AccountSwitcherModal from '@app/features/auth/components/accounts/AccountSwitcherModal';
|
||||
import {Typing} from '@app/features/channel/components/ChannelTyping';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const CONNECTION_NAGBAR_TONES: Record<ConnectionNoticeTone, NagbarTone> = {
|
||||
[ConnectionNoticeTone.NEUTRAL]: NAGBAR_TONE_NEUTRAL,
|
||||
[ConnectionNoticeTone.MAINTENANCE]: NAGBAR_TONE_MAINTENANCE,
|
||||
};
|
||||
const EMPTY_SKELETON_NAGBAR_ROWS: ReadonlyArray<RememberedSkeletonNagbarRow> = Object.freeze([]);
|
||||
const CONNECTION_ACCOUNT_SWITCHER_MODAL_KEY = 'connection-account-switcher';
|
||||
const TYPING_INDICATOR_VIEWBOX_SCALE = 20;
|
||||
const SWITCH_ACCOUNT_DESCRIPTOR = msg({
|
||||
message: 'Switch account',
|
||||
comment: 'Button on the connection banner that opens the account switcher.',
|
||||
});
|
||||
|
||||
function resolveConnectionActionUrl(notice: ConnectionNotice | null): string | null {
|
||||
if (notice == null || notice.action == null) {
|
||||
return null;
|
||||
}
|
||||
return notice.action.url;
|
||||
}
|
||||
|
||||
interface ConnectionNagbarProps {
|
||||
readonly isMobile: boolean;
|
||||
readonly rememberedRows?: ReadonlyArray<RememberedSkeletonNagbarRow> | null;
|
||||
}
|
||||
|
||||
export const ConnectionNagbar = observer(({isMobile, rememberedRows}: ConnectionNagbarProps) => {
|
||||
const skeletonRows = rememberedRows ?? EMPTY_SKELETON_NAGBAR_ROWS;
|
||||
const {i18n} = useLingui();
|
||||
const notice = useConnectionNotice();
|
||||
const actionUrl = resolveConnectionActionUrl(notice);
|
||||
const handleOpenActionUrl = useCallback(() => {
|
||||
if (actionUrl == null || actionUrl === '') {
|
||||
return;
|
||||
}
|
||||
openExternalUrlWithWarning(actionUrl);
|
||||
}, [actionUrl]);
|
||||
const handleSwitchAccount = useCallback(() => {
|
||||
ModalCommands.pushWithKey(
|
||||
modal(() => (
|
||||
<AccountSwitcherModal
|
||||
redirectAfterLogin={null}
|
||||
redirectAfterSwitch={null}
|
||||
switchAccount={switchAccountFromStalledConnection}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.handle-switch-account.account-switcher-modal"
|
||||
/>
|
||||
)),
|
||||
CONNECTION_ACCOUNT_SWITCHER_MODAL_KEY,
|
||||
);
|
||||
}, []);
|
||||
if (notice == null) {
|
||||
return (
|
||||
<NagbarSkeleton
|
||||
isMobile={isMobile}
|
||||
rows={skeletonRows}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.nagbar-skeleton"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const activeNotice = notice;
|
||||
const tone = CONNECTION_NAGBAR_TONES[activeNotice.tone];
|
||||
function renderNoticeAction() {
|
||||
if (activeNotice.action == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
variant="ghost"
|
||||
onClick={handleOpenActionUrl}
|
||||
disabled={false}
|
||||
submitting={false}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.render-notice-action.nagbar-button.open-action-url"
|
||||
>
|
||||
{activeNotice.action.label}
|
||||
</NagbarButton>
|
||||
);
|
||||
}
|
||||
function renderSwitchAccountAction() {
|
||||
if (!activeNotice.showSwitchAccount) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<NagbarButton
|
||||
isMobile={isMobile}
|
||||
variant="ghost"
|
||||
onClick={handleSwitchAccount}
|
||||
disabled={false}
|
||||
submitting={false}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.render-switch-account-action.nagbar-button.switch-account"
|
||||
>
|
||||
{i18n._(SWITCH_ACCOUNT_DESCRIPTOR)}
|
||||
</NagbarButton>
|
||||
);
|
||||
}
|
||||
function renderActions() {
|
||||
if (activeNotice.action == null && !activeNotice.showSwitchAccount) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{renderNoticeAction()}
|
||||
{renderSwitchAccountAction()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor={tone.backgroundColor}
|
||||
textColor={tone.textColor}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
isMobile={isMobile}
|
||||
message={
|
||||
<div className={styles.message} data-flx="app.app-layout.nagbars.connection-nagbar.message">
|
||||
<Typing
|
||||
className={styles.indicator}
|
||||
size={TYPING_INDICATOR_VIEWBOX_SCALE}
|
||||
color={tone.textColor}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.indicator"
|
||||
/>
|
||||
<span data-flx="app.app-layout.nagbars.connection-nagbar.span">{activeNotice.message}</span>
|
||||
</div>
|
||||
}
|
||||
actions={renderActions()}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.nagbar-content"
|
||||
/>
|
||||
</Nagbar>
|
||||
<NagbarSkeleton
|
||||
isMobile={isMobile}
|
||||
rows={skeletonRows.slice(1)}
|
||||
data-flx="app.app-layout.nagbars.connection-nagbar.nagbar-skeleton--2"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {DESKTOP_DOWNLOAD_URL, PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -25,8 +26,8 @@ export const CorruptedInstallationNagbar = observer(({isMobile}: {isMobile: bool
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#b00000"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.CRITICAL].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.CRITICAL].textColor}
|
||||
data-flx="app.app-layout.nagbars.corrupted-installation-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import styles from '@app/features/app/components/layout/app_layout/nagbars/Deskt
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {DESKTOP_DOWNLOAD_URL, PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {DOWNLOAD_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as NagbarCommands from '@app/features/ui/commands/NagbarCommands';
|
||||
@@ -28,8 +29,8 @@ export const DesktopDownloadNagbar = observer(({isMobile}: {isMobile: boolean})
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.desktop-download-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -5,6 +5,7 @@ import styles from '@app/features/app/components/layout/app_layout/nagbars/Deskt
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {ENABLE_NOTIFICATIONS_DESCRIPTOR, OKAY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {usePushSubscriptions} from '@app/features/notification/hooks/usePushSubscriptions';
|
||||
@@ -130,8 +131,8 @@ export const DesktopNotificationNagbar = observer(({isMobile}: {isMobile: boolea
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.desktop-notification-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {OPEN_SETTINGS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -37,8 +38,8 @@ export const EmailVerificationNagbar = observer(({isMobile}: {isMobile: boolean}
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#ea580c"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.ALERT].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.ALERT].textColor}
|
||||
data-flx="app.app-layout.nagbars.email-verification-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -44,8 +45,8 @@ const GiftInventoryNagbarContent = observer(function GiftInventoryNagbarContent(
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.gift-inventory-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_HQ_COMMUNITY_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
@@ -78,8 +79,8 @@ export const GuildMembershipCtaNagbar = observer(({isMobile}: {isMobile: boolean
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
onDismiss={handleDismiss}
|
||||
dismissible={true}
|
||||
data-flx="app.app-layout.nagbars.guild-membership-cta-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import KeybindManager from '@app/features/app/keybindings/KeybindManager';
|
||||
import NativePermission from '@app/features/permissions/system/state/NativePermission';
|
||||
@@ -71,8 +72,8 @@ export const LinuxInputAccessNagbar = observer(({isMobile}: {isMobile: boolean})
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={NativePermission.dismissLinuxInputAccessNagbar}
|
||||
data-flx="app.app-layout.nagbars.linux-input-access-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import {showPremiumActionErrorModal} from '@app/features/app/components/dialogs/
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
@@ -66,8 +67,8 @@ export const PremiumExpiredNagbar = observer(({isMobile}: {isMobile: boolean}) =
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--status-danger)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.DANGER].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.DANGER].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.premium-expired-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -4,6 +4,7 @@ import {showPremiumActionErrorModal} from '@app/features/app/components/dialogs/
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
|
||||
@@ -70,8 +71,8 @@ export const PremiumGracePeriodNagbar = observer(({isMobile}: {isMobile: boolean
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#f97316"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.PREMIUM].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.PREMIUM].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.premium-grace-period-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -131,8 +132,8 @@ export const PremiumOnboardingNagbar = observer(function PremiumOnboardingNagbar
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.premium-onboarding-nagbar.nagbar"
|
||||
|
||||
+6
-19
@@ -7,6 +7,7 @@ import {
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import NagbarState from '@app/features/ui/state/Nagbar';
|
||||
@@ -26,24 +27,10 @@ interface MaintenanceScheduleLabels {
|
||||
durationLabel: string;
|
||||
}
|
||||
|
||||
interface MaintenanceNagbarTone {
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
}
|
||||
|
||||
const MAINTENANCE_NAGBAR_TONES: Record<MaintenanceStatus, MaintenanceNagbarTone> = {
|
||||
scheduled: {
|
||||
backgroundColor: '#1d4ed8',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
in_progress: {
|
||||
backgroundColor: '#9a3412',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
completed: {
|
||||
backgroundColor: '#166534',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
export const MAINTENANCE_NAGBAR_TONE_KINDS: Record<MaintenanceStatus, NagbarToneKind> = {
|
||||
scheduled: NagbarToneKind.MAINTENANCE_SCHEDULED,
|
||||
in_progress: NagbarToneKind.MAINTENANCE_ACTIVE,
|
||||
completed: NagbarToneKind.MAINTENANCE_COMPLETED,
|
||||
};
|
||||
const MAINTENANCE_IN_PROGRESS_MESSAGE_DESCRIPTOR = msg({
|
||||
message: 'Maintenance is in progress. Expected duration: {duration}.',
|
||||
@@ -156,7 +143,7 @@ export const ScheduledMaintenanceNagbar = observer(({isMobile}: {isMobile: boole
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const tone = MAINTENANCE_NAGBAR_TONES[scheduledMaintenance.status];
|
||||
const tone = NAGBAR_TONES[MAINTENANCE_NAGBAR_TONE_KINDS[scheduledMaintenance.status]];
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -22,8 +23,8 @@ export const StreamerModeNagbar = observer(({isMobile}: {isMobile: boolean}) =>
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#5865f2"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.STREAMER].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.STREAMER].textColor}
|
||||
dismissible
|
||||
onDismiss={StreamerMode.dismissNagbar}
|
||||
data-flx="app.app-layout.nagbars.streamer-mode-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {TermsAcceptanceModal} from '@app/features/auth/components/modals/TermsAcceptanceModal';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -31,8 +32,8 @@ export const TermsAcceptanceNagbar = observer(({isMobile}: {isMobile: boolean})
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#4338ca"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.LEGAL].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.LEGAL].textColor}
|
||||
data-flx="app.app-layout.nagbars.terms-acceptance-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {openClaimAccountModal} from '@app/features/auth/components/modals/ClaimAccountModal';
|
||||
import {CLAIM_ACCOUNT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
@@ -27,8 +28,8 @@ export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean})
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#ea580c"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.ALERT].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.ALERT].textColor}
|
||||
data-flx="app.app-layout.nagbars.unclaimed-account-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {ENABLE_TWO_FACTOR_AUTH_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -38,8 +39,8 @@ export const VisionaryMfaNagbar = observer(({isMobile}: {isMobile: boolean}) =>
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="var(--brand-primary)"
|
||||
textColor="var(--text-on-brand-primary)"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
|
||||
dismissible
|
||||
onDismiss={handleDismiss}
|
||||
data-flx="app.app-layout.nagbars.visionary-mfa-nagbar.nagbar"
|
||||
|
||||
+3
-2
@@ -3,6 +3,7 @@
|
||||
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
|
||||
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
|
||||
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
@@ -99,8 +100,8 @@ export const VoiceSessionRestoreNagbar = observer(({isMobile}: {isMobile: boolea
|
||||
return (
|
||||
<Nagbar
|
||||
isMobile={isMobile}
|
||||
backgroundColor="#15803d"
|
||||
textColor="#ffffff"
|
||||
backgroundColor={NAGBAR_TONES[NagbarToneKind.VOICE].backgroundColor}
|
||||
textColor={NAGBAR_TONES[NagbarToneKind.VOICE].textColor}
|
||||
data-flx="app.app-layout.nagbars.voice-session-restore-nagbar.nagbar"
|
||||
>
|
||||
<NagbarContent
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 {
|
||||
type ChannelMoveOperation,
|
||||
createChannelMoveOperation,
|
||||
@@ -62,7 +62,7 @@ function requireChannel(channels: ReadonlyArray<Channel>, id: string): Channel {
|
||||
|
||||
function dragItemFrom(channel: Channel): DragItem {
|
||||
return {
|
||||
type: channel.type === ChannelTypes.GUILD_CATEGORY ? DND_TYPES.CATEGORY : DND_TYPES.CHANNEL,
|
||||
type: channel.type === ChannelTypes.GUILD_CATEGORY ? DragItemType.CATEGORY : DragItemType.CHANNEL,
|
||||
id: channel.id,
|
||||
channelType: channel.type,
|
||||
parentId: channel.parentId,
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DND_TYPES, type DragItem} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {type DragItem, DragItemType} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
@@ -35,7 +35,7 @@ function channelItem(
|
||||
parentId: string | null = null,
|
||||
): DragItem {
|
||||
return {
|
||||
type: DND_TYPES.CHANNEL,
|
||||
type: DragItemType.CHANNEL,
|
||||
id,
|
||||
channelType,
|
||||
parentId,
|
||||
@@ -45,7 +45,7 @@ function channelItem(
|
||||
|
||||
function categoryItem(id = 'category-source'): DragItem {
|
||||
return {
|
||||
type: DND_TYPES.CATEGORY,
|
||||
type: DragItemType.CATEGORY,
|
||||
id,
|
||||
channelType: ChannelTypes.GUILD_CATEGORY,
|
||||
parentId: null,
|
||||
@@ -55,7 +55,7 @@ function categoryItem(id = 'category-source'): DragItem {
|
||||
|
||||
function voiceParticipantItem(id = 'user-1', currentChannelId = 'voice-source'): DragItem {
|
||||
return {
|
||||
type: DND_TYPES.VOICE_PARTICIPANT,
|
||||
type: DragItemType.VOICE_PARTICIPANT,
|
||||
id,
|
||||
channelType: ChannelTypes.GUILD_VOICE,
|
||||
parentId: null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 {getVerticalDropTargetHeight, resolveVerticalDropEdge} from '@app/features/ui/dnd/DropGeometry';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate';
|
||||
|
||||
@@ -60,8 +61,6 @@ export type ChannelReorderEvent =
|
||||
| {type: 'drag.leave'}
|
||||
| {type: 'drag.drop'};
|
||||
|
||||
type VerticalZone = 'before' | 'after';
|
||||
|
||||
const initialChannelReorderMachineContext: ChannelReorderMachineContext = {
|
||||
intent: null,
|
||||
indicator: null,
|
||||
@@ -81,29 +80,19 @@ function isTextType(channelType: number): boolean {
|
||||
return channelType === ChannelTypes.GUILD_TEXT || channelType === ChannelTypes.GUILD_LINK;
|
||||
}
|
||||
|
||||
function getTargetHeight(rect: ChannelReorderRect): number {
|
||||
return rect.bottom - rect.top;
|
||||
}
|
||||
|
||||
function getVerticalZone(clientOffset: ChannelReorderPoint, rect: ChannelReorderRect): VerticalZone {
|
||||
const height = getTargetHeight(rect);
|
||||
const offsetY = Math.min(height, Math.max(0, clientOffset.y - rect.top));
|
||||
return offsetY < height / 2 ? 'before' : 'after';
|
||||
}
|
||||
|
||||
function createIndicator(
|
||||
clientOffset: ChannelReorderPoint,
|
||||
rect: ChannelReorderRect,
|
||||
isValid: boolean,
|
||||
): ChannelReorderIndicator {
|
||||
return {
|
||||
position: getVerticalZone(clientOffset, rect) === 'before' ? 'top' : 'bottom',
|
||||
position: resolveVerticalDropEdge(clientOffset, rect) === 'before' ? 'top' : 'bottom',
|
||||
isValid,
|
||||
};
|
||||
}
|
||||
|
||||
function isReorderDragItem(item: DragItem): boolean {
|
||||
return item.type === DND_TYPES.CHANNEL || item.type === DND_TYPES.CATEGORY;
|
||||
return item.type === DragItemType.CHANNEL || item.type === DragItemType.CATEGORY;
|
||||
}
|
||||
|
||||
export function getChannelDropBlockedReason(
|
||||
@@ -111,21 +100,21 @@ export function getChannelDropBlockedReason(
|
||||
target: ChannelReorderTarget,
|
||||
): ChannelReorderBlockedReason | null {
|
||||
if (item.id === target.id) return 'same-source-and-target';
|
||||
if (item.type === DND_TYPES.VOICE_PARTICIPANT) {
|
||||
if (item.type === DragItemType.VOICE_PARTICIPANT) {
|
||||
return isVoiceType(target.channelType) ? null : 'incompatible-channel-kind';
|
||||
}
|
||||
if (!isReorderDragItem(item)) return 'unsupported-drag-item';
|
||||
const targetIsCategory = isCategoryType(target.channelType);
|
||||
const targetIsVoice = isVoiceType(target.channelType);
|
||||
const targetIsText = isTextType(target.channelType);
|
||||
if (item.type === DND_TYPES.CHANNEL) {
|
||||
if (item.type === DragItemType.CHANNEL) {
|
||||
if (item.channelType === ChannelTypes.GUILD_VOICE) {
|
||||
if (!targetIsCategory && !targetIsVoice && !targetIsText) return 'incompatible-channel-kind';
|
||||
} else if (targetIsVoice) {
|
||||
return 'incompatible-channel-kind';
|
||||
}
|
||||
}
|
||||
if (item.type === DND_TYPES.CATEGORY && target.parentId !== null && !targetIsCategory) {
|
||||
if (item.type === DragItemType.CATEGORY && target.parentId !== null && !targetIsCategory) {
|
||||
return 'category-into-child-channel';
|
||||
}
|
||||
return null;
|
||||
@@ -141,7 +130,7 @@ export function resolveChannelReorderHover(
|
||||
clientOffset: ChannelReorderPoint,
|
||||
targetRect: ChannelReorderRect,
|
||||
): ChannelReorderResolution {
|
||||
if (getTargetHeight(targetRect) <= 0) {
|
||||
if (getVerticalDropTargetHeight(targetRect) <= 0) {
|
||||
return {
|
||||
intent: null,
|
||||
indicator: null,
|
||||
@@ -158,7 +147,7 @@ export function resolveChannelReorderHover(
|
||||
isVoiceParticipantTransfer: false,
|
||||
};
|
||||
}
|
||||
if (item.type === DND_TYPES.VOICE_PARTICIPANT) {
|
||||
if (item.type === DragItemType.VOICE_PARTICIPANT) {
|
||||
return {
|
||||
intent: null,
|
||||
indicator: null,
|
||||
@@ -166,11 +155,11 @@ export function resolveChannelReorderHover(
|
||||
isVoiceParticipantTransfer: true,
|
||||
};
|
||||
}
|
||||
const zone = getVerticalZone(clientOffset, targetRect);
|
||||
const zone = resolveVerticalDropEdge(clientOffset, targetRect);
|
||||
const indicator = createIndicator(clientOffset, targetRect, true);
|
||||
const targetIsCategory = isCategoryType(target.channelType);
|
||||
const result: DropResult =
|
||||
targetIsCategory && item.type !== DND_TYPES.CATEGORY
|
||||
targetIsCategory && item.type !== DragItemType.CATEGORY
|
||||
? {
|
||||
targetId: target.id,
|
||||
position: zone === 'before' ? 'before' : 'inside',
|
||||
|
||||
+158
-100
@@ -1,28 +1,33 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DND_TYPES, type GuildDragItem} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
canGuildDropOnTarget,
|
||||
createGuildReorderSnapshot,
|
||||
type GuildReorderRect,
|
||||
GuildReorderBlockedReason,
|
||||
GuildReorderStateMachine,
|
||||
type GuildReorderTarget,
|
||||
getGuildReorderStateValue,
|
||||
GuildReorderTargetKind,
|
||||
resolveGuildReorderHover,
|
||||
selectGuildReorderIntent,
|
||||
transitionGuildReorderSnapshot,
|
||||
} from './GuildReorderStateMachine';
|
||||
} from '@app/features/app/components/layout/dnd/GuildReorderStateMachine';
|
||||
import {
|
||||
DNDReorderState,
|
||||
DragItemType,
|
||||
DropPlacement,
|
||||
type GuildDragItem,
|
||||
} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {Edge} from '@app/features/ui/AxisOrientation';
|
||||
import type {VerticalDropRect} from '@app/features/ui/dnd/DropGeometry';
|
||||
import {RelativePosition} from '@app/features/ui/RelativePosition';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const rect: GuildReorderRect = {
|
||||
const rect: VerticalDropRect = {
|
||||
top: 100,
|
||||
bottom: 154,
|
||||
left: 0,
|
||||
right: 72,
|
||||
};
|
||||
|
||||
function guildItem(id: string, folderId: number | null = null): GuildDragItem {
|
||||
return {
|
||||
type: DND_TYPES.GUILD_ITEM,
|
||||
type: DragItemType.GUILD_ITEM,
|
||||
id,
|
||||
isFolder: false,
|
||||
folderId,
|
||||
@@ -31,7 +36,7 @@ function guildItem(id: string, folderId: number | null = null): GuildDragItem {
|
||||
|
||||
function folderItem(id = 'folder-1', folderId = 1): GuildDragItem {
|
||||
return {
|
||||
type: DND_TYPES.GUILD_FOLDER,
|
||||
type: DragItemType.GUILD_FOLDER,
|
||||
id,
|
||||
isFolder: true,
|
||||
folderId,
|
||||
@@ -41,14 +46,14 @@ function folderItem(id = 'folder-1', folderId = 1): GuildDragItem {
|
||||
function rootGuildTarget(id = 'guild-target'): GuildReorderTarget {
|
||||
return {
|
||||
id,
|
||||
kind: 'guild',
|
||||
kind: GuildReorderTargetKind.TOP_LEVEL_GUILD,
|
||||
};
|
||||
}
|
||||
|
||||
function folderGuildTarget(id = 'guild-target', isTerminal = false): GuildReorderTarget {
|
||||
return {
|
||||
id,
|
||||
kind: 'guild',
|
||||
kind: GuildReorderTargetKind.FOLDER_GUILD,
|
||||
folderId: 1,
|
||||
isTerminal,
|
||||
};
|
||||
@@ -57,7 +62,7 @@ function folderGuildTarget(id = 'guild-target', isTerminal = false): GuildReorde
|
||||
function folderTarget(id = 'folder-1'): GuildReorderTarget {
|
||||
return {
|
||||
id,
|
||||
kind: 'folder',
|
||||
kind: GuildReorderTargetKind.FOLDER,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,104 +76,136 @@ function point(y: number) {
|
||||
describe('GuildReorderStateMachine', () => {
|
||||
it('starts idle and targets a valid top-level reorder hover', () => {
|
||||
let snapshot = createGuildReorderSnapshot();
|
||||
expect(getGuildReorderStateValue(snapshot)).toBe('idle');
|
||||
snapshot = transitionGuildReorderSnapshot(snapshot, {
|
||||
type: 'drag.hover',
|
||||
item: guildItem('guild-source'),
|
||||
target: rootGuildTarget(),
|
||||
clientOffset: point(104),
|
||||
targetRect: rect,
|
||||
expect(snapshot.value).toBe(DNDReorderState.IDLE);
|
||||
snapshot = transitionGuildReorderSnapshot({
|
||||
snapshot,
|
||||
event: {
|
||||
type: 'drag.hover',
|
||||
item: guildItem('guild-source'),
|
||||
target: rootGuildTarget(),
|
||||
clientOffset: point(104),
|
||||
targetRect: rect,
|
||||
},
|
||||
});
|
||||
expect(getGuildReorderStateValue(snapshot)).toBe('targeting');
|
||||
expect(snapshot.value).toBe(DNDReorderState.TARGETING);
|
||||
expect(snapshot.context.intent?.result).toMatchObject({
|
||||
targetId: 'guild-target',
|
||||
position: 'before',
|
||||
position: RelativePosition.BEFORE,
|
||||
targetIsFolder: false,
|
||||
});
|
||||
expect(snapshot.context.intent?.indicator).toBe('top');
|
||||
expect(snapshot.context.intent?.indicator).toBe(Edge.TOP);
|
||||
});
|
||||
|
||||
it('moves to blocked state for invalid hovers and clears on leave', () => {
|
||||
let snapshot = createGuildReorderSnapshot();
|
||||
snapshot = transitionGuildReorderSnapshot(snapshot, {
|
||||
type: 'drag.hover',
|
||||
item: guildItem('guild-target'),
|
||||
target: rootGuildTarget('guild-target'),
|
||||
clientOffset: point(104),
|
||||
targetRect: rect,
|
||||
snapshot = transitionGuildReorderSnapshot({
|
||||
snapshot,
|
||||
event: {
|
||||
type: 'drag.hover',
|
||||
item: guildItem('guild-target'),
|
||||
target: rootGuildTarget('guild-target'),
|
||||
clientOffset: point(104),
|
||||
targetRect: rect,
|
||||
},
|
||||
});
|
||||
expect(getGuildReorderStateValue(snapshot)).toBe('blocked');
|
||||
expect(snapshot.context.blockedReason).toBe('same-source-and-target');
|
||||
snapshot = transitionGuildReorderSnapshot(snapshot, {type: 'drag.leave'});
|
||||
expect(getGuildReorderStateValue(snapshot)).toBe('idle');
|
||||
expect(snapshot.value).toBe(DNDReorderState.BLOCKED);
|
||||
expect(snapshot.context.blockedReason).toBe(GuildReorderBlockedReason.SAME_SOURCE_AND_TARGET);
|
||||
snapshot = transitionGuildReorderSnapshot({snapshot, event: {type: 'drag.leave'}});
|
||||
expect(snapshot.value).toBe(DNDReorderState.IDLE);
|
||||
expect(snapshot.context.intent).toBeNull();
|
||||
expect(snapshot.context.blockedReason).toBeNull();
|
||||
});
|
||||
|
||||
it('clears targeting state after drop', () => {
|
||||
let snapshot = createGuildReorderSnapshot();
|
||||
snapshot = transitionGuildReorderSnapshot(snapshot, {
|
||||
type: 'drag.hover',
|
||||
item: guildItem('guild-source'),
|
||||
target: rootGuildTarget(),
|
||||
clientOffset: point(104),
|
||||
targetRect: rect,
|
||||
snapshot = transitionGuildReorderSnapshot({
|
||||
snapshot,
|
||||
event: {
|
||||
type: 'drag.hover',
|
||||
item: guildItem('guild-source'),
|
||||
target: rootGuildTarget(),
|
||||
clientOffset: point(104),
|
||||
targetRect: rect,
|
||||
},
|
||||
});
|
||||
snapshot = transitionGuildReorderSnapshot(snapshot, {type: 'drag.drop'});
|
||||
expect(getGuildReorderStateValue(snapshot)).toBe('idle');
|
||||
snapshot = transitionGuildReorderSnapshot({snapshot, event: {type: 'drag.drop'}});
|
||||
expect(snapshot.value).toBe(DNDReorderState.IDLE);
|
||||
expect(snapshot.context.intent).toBeNull();
|
||||
});
|
||||
|
||||
it('covers every pixel of a top-level guild target with either reorder-before or combine', () => {
|
||||
const source = guildItem('guild-source');
|
||||
const item = guildItem('guild-source');
|
||||
const target = rootGuildTarget();
|
||||
for (let y = rect.top; y <= rect.bottom; y++) {
|
||||
const intent = selectGuildReorderIntent(source, target, point(y), rect);
|
||||
const intent = GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(y), targetRect: rect});
|
||||
expect(intent, `expected intent for y=${y}`).not.toBeNull();
|
||||
expect(['before', 'combine']).toContain(intent?.result.position);
|
||||
expect([RelativePosition.BEFORE, DropPlacement.COMBINE]).toContain(intent?.result.position);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses one top-level insertion target between adjacent guilds by never returning after from an item hover', () => {
|
||||
const source = guildItem('guild-source');
|
||||
const item = guildItem('guild-source');
|
||||
const target = rootGuildTarget('guild-next');
|
||||
const upperEdgeIntent = selectGuildReorderIntent(source, target, point(101), rect);
|
||||
const middleIntent = selectGuildReorderIntent(source, target, point(130), rect);
|
||||
const lowerEdgeIntent = selectGuildReorderIntent(source, target, point(153), rect);
|
||||
expect(upperEdgeIntent?.result.position).toBe('before');
|
||||
expect(middleIntent?.result.position).toBe('combine');
|
||||
expect(lowerEdgeIntent?.result.position).toBe('combine');
|
||||
expect(middleIntent?.result.position).not.toBe('after');
|
||||
expect(lowerEdgeIntent?.result.position).not.toBe('after');
|
||||
const upperEdgeIntent = GuildReorderStateMachine.selectIntent({
|
||||
item,
|
||||
target,
|
||||
clientOffset: point(101),
|
||||
targetRect: rect,
|
||||
});
|
||||
const middleIntent = GuildReorderStateMachine.selectIntent({
|
||||
item,
|
||||
target,
|
||||
clientOffset: point(130),
|
||||
targetRect: rect,
|
||||
});
|
||||
const lowerEdgeIntent = GuildReorderStateMachine.selectIntent({
|
||||
item,
|
||||
target,
|
||||
clientOffset: point(153),
|
||||
targetRect: rect,
|
||||
});
|
||||
expect(upperEdgeIntent?.result.position).toBe(RelativePosition.BEFORE);
|
||||
expect(middleIntent?.result.position).toBe(DropPlacement.COMBINE);
|
||||
expect(lowerEdgeIntent?.result.position).toBe(DropPlacement.COMBINE);
|
||||
expect(middleIntent?.result.position).not.toBe(RelativePosition.AFTER);
|
||||
expect(lowerEdgeIntent?.result.position).not.toBe(RelativePosition.AFTER);
|
||||
});
|
||||
|
||||
it('maps root guild top edge to reorder and center/bottom to folder combine for guild sources', () => {
|
||||
const source = guildItem('guild-source');
|
||||
const item = guildItem('guild-source');
|
||||
const target = rootGuildTarget();
|
||||
expect(selectGuildReorderIntent(source, target, point(100), rect)).toMatchObject({
|
||||
indicator: 'top',
|
||||
result: {position: 'before'},
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(100), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: Edge.TOP,
|
||||
result: {position: RelativePosition.BEFORE},
|
||||
});
|
||||
expect(selectGuildReorderIntent(source, target, point(114), rect)).toMatchObject({
|
||||
indicator: 'combine',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(114), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: DropPlacement.COMBINE,
|
||||
combineSourceGuildId: 'guild-source',
|
||||
result: {position: 'combine'},
|
||||
result: {position: DropPlacement.COMBINE},
|
||||
});
|
||||
expect(selectGuildReorderIntent(source, target, point(154), rect)).toMatchObject({
|
||||
indicator: 'combine',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(154), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: DropPlacement.COMBINE,
|
||||
combineSourceGuildId: 'guild-source',
|
||||
result: {position: 'combine'},
|
||||
result: {position: DropPlacement.COMBINE},
|
||||
});
|
||||
});
|
||||
|
||||
it('does not combine folders with root guilds while dragging a folder', () => {
|
||||
const source = folderItem();
|
||||
const item = folderItem();
|
||||
const target = rootGuildTarget();
|
||||
for (const y of [rect.top, 127, rect.bottom]) {
|
||||
expect(selectGuildReorderIntent(source, target, point(y), rect)).toMatchObject({
|
||||
indicator: 'top',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(y), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: Edge.TOP,
|
||||
result: {
|
||||
position: 'before',
|
||||
position: RelativePosition.BEFORE,
|
||||
targetIsFolder: false,
|
||||
},
|
||||
});
|
||||
@@ -176,14 +213,16 @@ describe('GuildReorderStateMachine', () => {
|
||||
});
|
||||
|
||||
it('uses a single before-target slot for non-terminal guilds inside a folder', () => {
|
||||
const source = guildItem('guild-source', 1);
|
||||
const item = guildItem('guild-source', 1);
|
||||
const target = folderGuildTarget('guild-middle', false);
|
||||
for (const y of [rect.top, 127, rect.bottom]) {
|
||||
expect(selectGuildReorderIntent(source, target, point(y), rect)).toMatchObject({
|
||||
indicator: 'top',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(y), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: Edge.TOP,
|
||||
result: {
|
||||
targetId: 'guild-middle',
|
||||
position: 'before',
|
||||
position: RelativePosition.BEFORE,
|
||||
targetIsFolder: false,
|
||||
targetFolderId: 1,
|
||||
},
|
||||
@@ -192,74 +231,86 @@ describe('GuildReorderStateMachine', () => {
|
||||
});
|
||||
|
||||
it('allows the terminal guild inside a folder to own the final after slot', () => {
|
||||
const source = guildItem('guild-source', 1);
|
||||
const item = guildItem('guild-source', 1);
|
||||
const target = folderGuildTarget('guild-last', true);
|
||||
expect(selectGuildReorderIntent(source, target, point(101), rect)).toMatchObject({
|
||||
indicator: 'top',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(101), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: Edge.TOP,
|
||||
result: {
|
||||
targetId: 'guild-last',
|
||||
position: 'before',
|
||||
position: RelativePosition.BEFORE,
|
||||
targetFolderId: 1,
|
||||
},
|
||||
});
|
||||
expect(selectGuildReorderIntent(source, target, point(153), rect)).toMatchObject({
|
||||
indicator: 'bottom',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(153), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: Edge.BOTTOM,
|
||||
result: {
|
||||
targetId: 'guild-last',
|
||||
position: 'after',
|
||||
position: RelativePosition.AFTER,
|
||||
targetFolderId: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks dragging a folder onto a guild inside another folder', () => {
|
||||
const source = folderItem('folder-2', 2);
|
||||
const item = folderItem('folder-2', 2);
|
||||
const target = folderGuildTarget('guild-inside-folder', true);
|
||||
expect(canGuildDropOnTarget(source, target)).toBe(false);
|
||||
expect(resolveGuildReorderHover(source, target, point(127), rect)).toEqual({
|
||||
expect(GuildReorderStateMachine.canDrop({item, target})).toBe(false);
|
||||
expect(resolveGuildReorderHover({item, target, clientOffset: point(127), targetRect: rect})).toEqual({
|
||||
intent: null,
|
||||
blockedReason: 'folder-into-folder-guild',
|
||||
blockedReason: GuildReorderBlockedReason.FOLDER_INTO_FOLDER_GUILD,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses top edge for reordering before a folder and the rest of a folder target for adding a guild inside', () => {
|
||||
const source = guildItem('guild-source');
|
||||
const item = guildItem('guild-source');
|
||||
const target = folderTarget('folder-1');
|
||||
expect(selectGuildReorderIntent(source, target, point(101), rect)).toMatchObject({
|
||||
indicator: 'top',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(101), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: Edge.TOP,
|
||||
result: {
|
||||
targetId: 'folder-1',
|
||||
position: 'before',
|
||||
position: RelativePosition.BEFORE,
|
||||
targetIsFolder: true,
|
||||
},
|
||||
});
|
||||
expect(selectGuildReorderIntent(source, target, point(127), rect)).toMatchObject({
|
||||
indicator: 'inside',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(127), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: DropPlacement.INSIDE,
|
||||
result: {
|
||||
targetId: 'folder-1',
|
||||
position: 'inside',
|
||||
position: DropPlacement.INSIDE,
|
||||
targetIsFolder: true,
|
||||
},
|
||||
});
|
||||
expect(selectGuildReorderIntent(source, target, point(153), rect)).toMatchObject({
|
||||
indicator: 'inside',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(153), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: DropPlacement.INSIDE,
|
||||
result: {
|
||||
targetId: 'folder-1',
|
||||
position: 'inside',
|
||||
position: DropPlacement.INSIDE,
|
||||
targetIsFolder: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('only reorders before a folder when dragging another folder', () => {
|
||||
const source = folderItem('folder-2', 2);
|
||||
const item = folderItem('folder-2', 2);
|
||||
const target = folderTarget('folder-1');
|
||||
for (const y of [rect.top, 127, rect.bottom]) {
|
||||
expect(selectGuildReorderIntent(source, target, point(y), rect)).toMatchObject({
|
||||
indicator: 'top',
|
||||
expect(
|
||||
GuildReorderStateMachine.selectIntent({item, target, clientOffset: point(y), targetRect: rect}),
|
||||
).toMatchObject({
|
||||
indicator: Edge.TOP,
|
||||
result: {
|
||||
targetId: 'folder-1',
|
||||
position: 'before',
|
||||
position: RelativePosition.BEFORE,
|
||||
targetIsFolder: true,
|
||||
},
|
||||
});
|
||||
@@ -268,9 +319,16 @@ describe('GuildReorderStateMachine', () => {
|
||||
|
||||
it('blocks zero-height target rects instead of producing a void result', () => {
|
||||
const emptyRect = {...rect, bottom: rect.top};
|
||||
expect(resolveGuildReorderHover(guildItem('guild-source'), rootGuildTarget(), point(rect.top), emptyRect)).toEqual({
|
||||
expect(
|
||||
resolveGuildReorderHover({
|
||||
item: guildItem('guild-source'),
|
||||
target: rootGuildTarget(),
|
||||
clientOffset: point(rect.top),
|
||||
targetRect: emptyRect,
|
||||
}),
|
||||
).toEqual({
|
||||
intent: null,
|
||||
blockedReason: 'empty-target-rect',
|
||||
blockedReason: GuildReorderBlockedReason.EMPTY_TARGET_RECT,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,65 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GuildDragItem, GuildDropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {assign, getInitialSnapshot, type SnapshotFrom, setup, transition} from 'xstate';
|
||||
import type {
|
||||
DNDReorderState,
|
||||
GuildDragItem,
|
||||
GuildDropPosition,
|
||||
GuildDropResult,
|
||||
} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {DropPlacement} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {Edge, type VerticalEdge} from '@app/features/ui/AxisOrientation';
|
||||
import {
|
||||
getVerticalDropTargetHeight,
|
||||
resolveVerticalDropEdge,
|
||||
resolveVerticalDropZone,
|
||||
type VerticalDropPoint,
|
||||
type VerticalDropRect,
|
||||
} from '@app/features/ui/dnd/DropGeometry';
|
||||
import {RelativePosition} from '@app/features/ui/RelativePosition';
|
||||
import {
|
||||
assign,
|
||||
getInitialSnapshot,
|
||||
type MachineSnapshot,
|
||||
type MetaObject,
|
||||
type NonReducibleUnknown,
|
||||
type StateSchema,
|
||||
setup,
|
||||
transition,
|
||||
} from 'xstate';
|
||||
|
||||
export interface GuildReorderPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
export const GuildReorderTargetKind = Object.freeze({
|
||||
FOLDER: 'folder',
|
||||
TOP_LEVEL_GUILD: 'top-level-guild',
|
||||
FOLDER_GUILD: 'folder-guild',
|
||||
} as const);
|
||||
|
||||
export type GuildReorderTargetKind = (typeof GuildReorderTargetKind)[keyof typeof GuildReorderTargetKind];
|
||||
|
||||
interface GuildReorderFolderTarget {
|
||||
readonly kind: typeof GuildReorderTargetKind.FOLDER;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface GuildReorderRect {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
interface GuildReorderTopLevelGuildTarget {
|
||||
readonly kind: typeof GuildReorderTargetKind.TOP_LEVEL_GUILD;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export type GuildReorderTargetKind = 'guild' | 'folder';
|
||||
|
||||
export interface GuildReorderTarget {
|
||||
id: string;
|
||||
kind: GuildReorderTargetKind;
|
||||
folderId?: number | null;
|
||||
isTerminal?: boolean;
|
||||
interface GuildReorderFolderGuildTarget {
|
||||
readonly kind: typeof GuildReorderTargetKind.FOLDER_GUILD;
|
||||
readonly id: string;
|
||||
readonly folderId: number;
|
||||
readonly isTerminal: boolean;
|
||||
}
|
||||
|
||||
export type GuildReorderIndicator = 'top' | 'bottom' | 'inside' | 'combine';
|
||||
export type GuildReorderTarget =
|
||||
| GuildReorderFolderTarget
|
||||
| GuildReorderTopLevelGuildTarget
|
||||
| GuildReorderFolderGuildTarget;
|
||||
|
||||
export type GuildFolderReorderIndicator = VerticalEdge | typeof DropPlacement.INSIDE;
|
||||
export type GuildItemReorderIndicator = VerticalEdge | typeof DropPlacement.COMBINE;
|
||||
export type GuildReorderIndicator = GuildFolderReorderIndicator | GuildItemReorderIndicator;
|
||||
|
||||
export interface GuildReorderIntent {
|
||||
indicator: GuildReorderIndicator;
|
||||
@@ -32,7 +67,13 @@ export interface GuildReorderIntent {
|
||||
result: GuildDropResult;
|
||||
}
|
||||
|
||||
export type GuildReorderBlockedReason = 'same-source-and-target' | 'folder-into-folder-guild' | 'empty-target-rect';
|
||||
export const GuildReorderBlockedReason = Object.freeze({
|
||||
SAME_SOURCE_AND_TARGET: 'same-source-and-target',
|
||||
FOLDER_INTO_FOLDER_GUILD: 'folder-into-folder-guild',
|
||||
EMPTY_TARGET_RECT: 'empty-target-rect',
|
||||
} as const);
|
||||
|
||||
export type GuildReorderBlockedReason = (typeof GuildReorderBlockedReason)[keyof typeof GuildReorderBlockedReason];
|
||||
|
||||
export interface GuildReorderMachineContext {
|
||||
intent: GuildReorderIntent | null;
|
||||
@@ -44,8 +85,8 @@ export type GuildReorderEvent =
|
||||
type: 'drag.hover';
|
||||
item: GuildDragItem;
|
||||
target: GuildReorderTarget;
|
||||
clientOffset: GuildReorderPoint;
|
||||
targetRect: GuildReorderRect;
|
||||
clientOffset: VerticalDropPoint;
|
||||
targetRect: VerticalDropRect;
|
||||
}
|
||||
| {type: 'drag.leave'}
|
||||
| {type: 'drag.drop'};
|
||||
@@ -55,118 +96,243 @@ export interface GuildReorderHoverResolution {
|
||||
blockedReason: GuildReorderBlockedReason | null;
|
||||
}
|
||||
|
||||
type VerticalZone = 'before' | 'center' | 'after';
|
||||
|
||||
const initialGuildReorderMachineContext: GuildReorderMachineContext = {
|
||||
intent: null,
|
||||
blockedReason: null,
|
||||
};
|
||||
|
||||
function getTargetHeight(rect: GuildReorderRect): number {
|
||||
return rect.bottom - rect.top;
|
||||
interface CreateGuildDropResultRequest {
|
||||
readonly target: GuildReorderTarget;
|
||||
readonly position: GuildDropPosition;
|
||||
}
|
||||
|
||||
function getVerticalZone(clientOffset: GuildReorderPoint, rect: GuildReorderRect, edgeThreshold: number): VerticalZone {
|
||||
const height = getTargetHeight(rect);
|
||||
const offsetY = Math.min(height, Math.max(0, clientOffset.y - rect.top));
|
||||
if (edgeThreshold >= 0.5) {
|
||||
return offsetY < height / 2 ? 'before' : 'after';
|
||||
interface CreateGuildReorderIntentRequest extends CreateGuildDropResultRequest {
|
||||
readonly indicator: GuildReorderIndicator;
|
||||
readonly combineSourceGuildId: string | null;
|
||||
}
|
||||
|
||||
interface GuildReorderDropQuery {
|
||||
readonly item: GuildDragItem;
|
||||
readonly target: GuildReorderTarget;
|
||||
}
|
||||
|
||||
interface GuildReorderBlockQuery extends GuildReorderDropQuery {
|
||||
readonly targetRect: VerticalDropRect | null;
|
||||
}
|
||||
|
||||
interface ResolveFolderTargetIntentRequest {
|
||||
readonly item: GuildDragItem;
|
||||
readonly target: GuildReorderFolderTarget;
|
||||
readonly clientOffset: VerticalDropPoint;
|
||||
readonly targetRect: VerticalDropRect;
|
||||
}
|
||||
|
||||
interface ResolveFolderGuildTargetIntentRequest {
|
||||
readonly target: GuildReorderFolderGuildTarget;
|
||||
readonly clientOffset: VerticalDropPoint;
|
||||
readonly targetRect: VerticalDropRect;
|
||||
}
|
||||
|
||||
interface ResolveTopLevelGuildTargetIntentRequest {
|
||||
readonly item: GuildDragItem;
|
||||
readonly target: GuildReorderTopLevelGuildTarget;
|
||||
readonly clientOffset: VerticalDropPoint;
|
||||
readonly targetRect: VerticalDropRect;
|
||||
}
|
||||
|
||||
export interface GuildReorderHoverRequest extends GuildReorderDropQuery {
|
||||
readonly clientOffset: VerticalDropPoint;
|
||||
readonly targetRect: VerticalDropRect;
|
||||
}
|
||||
|
||||
interface TransitionGuildReorderSnapshotRequest {
|
||||
readonly snapshot: GuildReorderSnapshot;
|
||||
readonly event: GuildReorderEvent;
|
||||
}
|
||||
|
||||
function createDropResult({target, position}: CreateGuildDropResultRequest): GuildDropResult {
|
||||
switch (target.kind) {
|
||||
case GuildReorderTargetKind.FOLDER:
|
||||
return {
|
||||
targetId: target.id,
|
||||
position,
|
||||
targetIsFolder: true,
|
||||
targetFolderId: null,
|
||||
};
|
||||
case GuildReorderTargetKind.TOP_LEVEL_GUILD:
|
||||
return {
|
||||
targetId: target.id,
|
||||
position,
|
||||
targetIsFolder: false,
|
||||
targetFolderId: null,
|
||||
};
|
||||
case GuildReorderTargetKind.FOLDER_GUILD:
|
||||
return {
|
||||
targetId: target.id,
|
||||
position,
|
||||
targetIsFolder: false,
|
||||
targetFolderId: target.folderId,
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported guild reorder target: ${target satisfies never}`);
|
||||
}
|
||||
const threshold = height * edgeThreshold;
|
||||
if (offsetY < threshold) return 'before';
|
||||
if (offsetY > height - threshold) return 'after';
|
||||
return 'center';
|
||||
}
|
||||
|
||||
function createDropResult(target: GuildReorderTarget, position: GuildDropResult['position']): GuildDropResult {
|
||||
const result: GuildDropResult = {
|
||||
targetId: target.id,
|
||||
position,
|
||||
targetIsFolder: target.kind === 'folder',
|
||||
};
|
||||
if (target.kind === 'guild' && target.folderId != null) {
|
||||
result.targetFolderId = target.folderId;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function createIntent(
|
||||
target: GuildReorderTarget,
|
||||
indicator: GuildReorderIndicator,
|
||||
position: GuildDropResult['position'],
|
||||
combineSourceGuildId: string | null = null,
|
||||
): GuildReorderIntent {
|
||||
function createIntent({
|
||||
target,
|
||||
indicator,
|
||||
position,
|
||||
combineSourceGuildId,
|
||||
}: CreateGuildReorderIntentRequest): GuildReorderIntent {
|
||||
return {
|
||||
indicator,
|
||||
combineSourceGuildId,
|
||||
result: createDropResult(target, position),
|
||||
result: createDropResult({target, position}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getGuildReorderBlockedReason(
|
||||
item: GuildDragItem,
|
||||
target: GuildReorderTarget,
|
||||
targetRect?: GuildReorderRect,
|
||||
): GuildReorderBlockedReason | null {
|
||||
if (item.id === target.id) return 'same-source-and-target';
|
||||
if (target.kind === 'guild' && target.folderId != null && item.isFolder) return 'folder-into-folder-guild';
|
||||
if (targetRect && getTargetHeight(targetRect) <= 0) return 'empty-target-rect';
|
||||
function getGuildReorderBlockedReason({
|
||||
item,
|
||||
target,
|
||||
targetRect,
|
||||
}: GuildReorderBlockQuery): GuildReorderBlockedReason | null {
|
||||
if (item.id === target.id) return GuildReorderBlockedReason.SAME_SOURCE_AND_TARGET;
|
||||
if (target.kind === GuildReorderTargetKind.FOLDER_GUILD && item.isFolder) {
|
||||
return GuildReorderBlockedReason.FOLDER_INTO_FOLDER_GUILD;
|
||||
}
|
||||
if (targetRect != null && getVerticalDropTargetHeight(targetRect) <= 0) {
|
||||
return GuildReorderBlockedReason.EMPTY_TARGET_RECT;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canGuildDropOnTarget(item: GuildDragItem, target: GuildReorderTarget): boolean {
|
||||
return getGuildReorderBlockedReason(item, target) === null;
|
||||
function canGuildDropOnTarget({item, target}: GuildReorderDropQuery): boolean {
|
||||
return getGuildReorderBlockedReason({item, target, targetRect: null}) == null;
|
||||
}
|
||||
|
||||
export function resolveGuildReorderHover(
|
||||
item: GuildDragItem,
|
||||
target: GuildReorderTarget,
|
||||
clientOffset: GuildReorderPoint,
|
||||
targetRect: GuildReorderRect,
|
||||
): GuildReorderHoverResolution {
|
||||
const blockedReason = getGuildReorderBlockedReason(item, target, targetRect);
|
||||
if (blockedReason) {
|
||||
function resolveFolderTargetIntent({
|
||||
item,
|
||||
target,
|
||||
clientOffset,
|
||||
targetRect,
|
||||
}: ResolveFolderTargetIntentRequest): GuildReorderIntent {
|
||||
if (item.isFolder) {
|
||||
return createIntent({
|
||||
target,
|
||||
indicator: Edge.TOP,
|
||||
position: RelativePosition.BEFORE,
|
||||
combineSourceGuildId: null,
|
||||
});
|
||||
}
|
||||
const zone = resolveVerticalDropZone(clientOffset, targetRect, 0.25);
|
||||
if (zone === RelativePosition.BEFORE) {
|
||||
return createIntent({
|
||||
target,
|
||||
indicator: Edge.TOP,
|
||||
position: RelativePosition.BEFORE,
|
||||
combineSourceGuildId: null,
|
||||
});
|
||||
}
|
||||
return createIntent({
|
||||
target,
|
||||
indicator: DropPlacement.INSIDE,
|
||||
position: DropPlacement.INSIDE,
|
||||
combineSourceGuildId: null,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFolderGuildTargetIntent({
|
||||
target,
|
||||
clientOffset,
|
||||
targetRect,
|
||||
}: ResolveFolderGuildTargetIntentRequest): GuildReorderIntent {
|
||||
const zone = resolveVerticalDropEdge(clientOffset, targetRect);
|
||||
if (target.isTerminal && zone === RelativePosition.AFTER) {
|
||||
return createIntent({
|
||||
target,
|
||||
indicator: Edge.BOTTOM,
|
||||
position: RelativePosition.AFTER,
|
||||
combineSourceGuildId: null,
|
||||
});
|
||||
}
|
||||
return createIntent({
|
||||
target,
|
||||
indicator: Edge.TOP,
|
||||
position: RelativePosition.BEFORE,
|
||||
combineSourceGuildId: null,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTopLevelGuildTargetIntent({
|
||||
item,
|
||||
target,
|
||||
clientOffset,
|
||||
targetRect,
|
||||
}: ResolveTopLevelGuildTargetIntentRequest): GuildReorderIntent {
|
||||
if (item.isFolder) {
|
||||
return createIntent({
|
||||
target,
|
||||
indicator: Edge.TOP,
|
||||
position: RelativePosition.BEFORE,
|
||||
combineSourceGuildId: null,
|
||||
});
|
||||
}
|
||||
const zone = resolveVerticalDropZone(clientOffset, targetRect, 0.25);
|
||||
if (zone === RelativePosition.BEFORE) {
|
||||
return createIntent({
|
||||
target,
|
||||
indicator: Edge.TOP,
|
||||
position: RelativePosition.BEFORE,
|
||||
combineSourceGuildId: null,
|
||||
});
|
||||
}
|
||||
return createIntent({
|
||||
target,
|
||||
indicator: DropPlacement.COMBINE,
|
||||
position: DropPlacement.COMBINE,
|
||||
combineSourceGuildId: item.id,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGuildReorderHover({
|
||||
item,
|
||||
target,
|
||||
clientOffset,
|
||||
targetRect,
|
||||
}: GuildReorderHoverRequest): GuildReorderHoverResolution {
|
||||
const blockedReason = getGuildReorderBlockedReason({item, target, targetRect});
|
||||
if (blockedReason != null) {
|
||||
return {intent: null, blockedReason};
|
||||
}
|
||||
if (target.kind === 'folder') {
|
||||
if (item.isFolder) {
|
||||
return {intent: createIntent(target, 'top', 'before'), blockedReason: null};
|
||||
}
|
||||
const zone = getVerticalZone(clientOffset, targetRect, 0.25);
|
||||
if (zone === 'before') {
|
||||
return {intent: createIntent(target, 'top', 'before'), blockedReason: null};
|
||||
}
|
||||
return {intent: createIntent(target, 'inside', 'inside'), blockedReason: null};
|
||||
switch (target.kind) {
|
||||
case GuildReorderTargetKind.FOLDER:
|
||||
return {intent: resolveFolderTargetIntent({item, target, clientOffset, targetRect}), blockedReason: null};
|
||||
case GuildReorderTargetKind.FOLDER_GUILD:
|
||||
return {intent: resolveFolderGuildTargetIntent({target, clientOffset, targetRect}), blockedReason: null};
|
||||
case GuildReorderTargetKind.TOP_LEVEL_GUILD:
|
||||
return {intent: resolveTopLevelGuildTargetIntent({item, target, clientOffset, targetRect}), blockedReason: null};
|
||||
default:
|
||||
throw new Error(`Unsupported guild reorder target: ${target satisfies never}`);
|
||||
}
|
||||
if (target.folderId != null) {
|
||||
const zone = getVerticalZone(clientOffset, targetRect, 0.5);
|
||||
if (target.isTerminal && zone === 'after') {
|
||||
return {intent: createIntent(target, 'bottom', 'after'), blockedReason: null};
|
||||
}
|
||||
return {intent: createIntent(target, 'top', 'before'), blockedReason: null};
|
||||
}
|
||||
if (item.isFolder) {
|
||||
return {intent: createIntent(target, 'top', 'before'), blockedReason: null};
|
||||
}
|
||||
const zone = getVerticalZone(clientOffset, targetRect, 0.25);
|
||||
if (zone === 'before') {
|
||||
return {intent: createIntent(target, 'top', 'before'), blockedReason: null};
|
||||
}
|
||||
return {intent: createIntent(target, 'combine', 'combine', item.id), blockedReason: null};
|
||||
}
|
||||
|
||||
export const guildReorderStateMachine = setup({
|
||||
const guildReorderStateMachine = setup({
|
||||
types: {} as {
|
||||
context: GuildReorderMachineContext;
|
||||
events: GuildReorderEvent;
|
||||
},
|
||||
guards: {
|
||||
hasIntent: ({context}) => context.intent !== null,
|
||||
hasIntent: ({context}) => context.intent != null,
|
||||
},
|
||||
actions: {
|
||||
resolveHover: assign(({event}) => {
|
||||
if (event.type !== 'drag.hover') return initialGuildReorderMachineContext;
|
||||
return resolveGuildReorderHover(event.item, event.target, event.clientOffset, event.targetRect);
|
||||
return resolveGuildReorderHover({
|
||||
item: event.item,
|
||||
target: event.target,
|
||||
clientOffset: event.clientOffset,
|
||||
targetRect: event.targetRect,
|
||||
});
|
||||
}),
|
||||
clear: assign(() => initialGuildReorderMachineContext),
|
||||
},
|
||||
@@ -202,36 +368,43 @@ export const guildReorderStateMachine = setup({
|
||||
},
|
||||
});
|
||||
|
||||
export type GuildReorderSnapshot = SnapshotFrom<typeof guildReorderStateMachine>;
|
||||
export type GuildReorderStateValue = 'idle' | 'resolving' | 'targeting' | 'blocked';
|
||||
export type GuildReorderSnapshot = MachineSnapshot<
|
||||
GuildReorderMachineContext,
|
||||
GuildReorderEvent,
|
||||
Record<string, never>,
|
||||
DNDReorderState,
|
||||
string,
|
||||
NonReducibleUnknown,
|
||||
MetaObject,
|
||||
StateSchema
|
||||
>;
|
||||
|
||||
export function createGuildReorderSnapshot(): GuildReorderSnapshot {
|
||||
return getInitialSnapshot(guildReorderStateMachine);
|
||||
}
|
||||
|
||||
export function transitionGuildReorderSnapshot(
|
||||
snapshot: GuildReorderSnapshot,
|
||||
event: GuildReorderEvent,
|
||||
): GuildReorderSnapshot {
|
||||
return transition(guildReorderStateMachine, snapshot, event)[0] as GuildReorderSnapshot;
|
||||
export function transitionGuildReorderSnapshot({
|
||||
snapshot,
|
||||
event,
|
||||
}: TransitionGuildReorderSnapshotRequest): GuildReorderSnapshot {
|
||||
return transition(guildReorderStateMachine, snapshot, event)[0];
|
||||
}
|
||||
|
||||
export function getGuildReorderStateValue(snapshot: GuildReorderSnapshot): GuildReorderStateValue {
|
||||
return snapshot.value as GuildReorderStateValue;
|
||||
}
|
||||
|
||||
export function selectGuildReorderIntent(
|
||||
item: GuildDragItem,
|
||||
target: GuildReorderTarget,
|
||||
clientOffset: GuildReorderPoint,
|
||||
targetRect: GuildReorderRect,
|
||||
): GuildReorderIntent | null {
|
||||
const snapshot = transitionGuildReorderSnapshot(createGuildReorderSnapshot(), {
|
||||
type: 'drag.hover',
|
||||
item,
|
||||
target,
|
||||
clientOffset,
|
||||
targetRect,
|
||||
function selectGuildReorderIntent(request: GuildReorderHoverRequest): GuildReorderIntent | null {
|
||||
const snapshot = transitionGuildReorderSnapshot({
|
||||
snapshot: createGuildReorderSnapshot(),
|
||||
event: {
|
||||
type: 'drag.hover',
|
||||
item: request.item,
|
||||
target: request.target,
|
||||
clientOffset: request.clientOffset,
|
||||
targetRect: request.targetRect,
|
||||
},
|
||||
});
|
||||
return snapshot.context.intent;
|
||||
}
|
||||
|
||||
export const GuildReorderStateMachine = Object.freeze({
|
||||
canDrop: canGuildDropOnTarget,
|
||||
selectIntent: selectGuildReorderIntent,
|
||||
});
|
||||
|
||||
+4
-3
@@ -30,7 +30,7 @@ interface DMListAnimatedRowProps {
|
||||
readonly onHoverStart: (channelId: string) => void;
|
||||
readonly onHoverEnd: (channelId: string) => void;
|
||||
readonly onRemovalAnimationComplete: (channelId: string) => void;
|
||||
readonly scrollAnchorRef: React.RefCallback<HTMLElement>;
|
||||
readonly scrollTargetRef: React.RefCallback<HTMLElement>;
|
||||
readonly voiceCallActive: boolean;
|
||||
}
|
||||
|
||||
@@ -43,9 +43,10 @@ export const DMListAnimatedRow: React.FC<DMListAnimatedRowProps> = ({
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
onRemovalAnimationComplete,
|
||||
scrollAnchorRef,
|
||||
scrollTargetRef,
|
||||
voiceCallActive,
|
||||
}) => {
|
||||
const setRowRef = scrollTargetRef;
|
||||
const previousPendingRemovalRef = useRef(pendingRemoval);
|
||||
const removalCycleRef = useRef(0);
|
||||
if (previousPendingRemovalRef.current !== pendingRemoval) {
|
||||
@@ -64,7 +65,7 @@ export const DMListAnimatedRow: React.FC<DMListAnimatedRowProps> = ({
|
||||
if (reducedMotion) durationMs = 0;
|
||||
return (
|
||||
<motion.div
|
||||
ref={scrollAnchorRef}
|
||||
ref={setRowRef}
|
||||
className={styles.dmListRow}
|
||||
initial={reducedMotion ? false : {height: 0}}
|
||||
animate={{height: pendingRemoval ? 0 : rowHeight}}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import styles from '@app/features/app/components/layout/GuildsLayout.module.css';
|
||||
import {resolveGuildListIndicatorBarTarget} from '@app/features/app/components/layout/sidebar_nav/GuildListIndicator';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import {useHover} from '@app/features/app/hooks/useHover';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
@@ -15,7 +16,6 @@ import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuComma
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {StarIcon} from '@phosphor-icons/react';
|
||||
@@ -63,12 +63,7 @@ export const FavoritesButton = observer(({className}: FavoritesButtonProps = {})
|
||||
));
|
||||
};
|
||||
const shouldShowHoverState = isHovering || contextMenuOpen;
|
||||
const indicatorHeight =
|
||||
(() => {
|
||||
if (isSelected) return 40;
|
||||
if (shouldShowHoverState) return 20;
|
||||
return 8;
|
||||
})() * getAppZoomFactor();
|
||||
const indicatorTarget = resolveGuildListIndicatorBarTarget({isSelected, showHoverState: shouldShowHoverState});
|
||||
const isActive = shouldShowHoverState || isSelected;
|
||||
if (!Accessibility.showFavorites) {
|
||||
return null;
|
||||
@@ -103,8 +98,8 @@ export const FavoritesButton = observer(({className}: FavoritesButtonProps = {})
|
||||
<motion.span
|
||||
className={styles.guildIndicatorBar}
|
||||
initial={false}
|
||||
animate={{opacity: 1, scale: 1, height: indicatorHeight}}
|
||||
exit={{opacity: 0, scale: 0}}
|
||||
animate={indicatorTarget}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2, ease: [0.25, 0.1, 0.25, 1]}}
|
||||
data-flx="app.sidebar-nav.favorites-button.guild-indicator-bar"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import styles from '@app/features/app/components/layout/GuildsLayout.module.css';
|
||||
import {getFluxerButtonBadgeCount} from '@app/features/app/components/layout/sidebar_nav/FluxerButtonBadgeUtils';
|
||||
import {resolveGuildListIndicatorBarTarget} from '@app/features/app/components/layout/sidebar_nav/GuildListIndicator';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import {useHover} from '@app/features/app/hooks/useHover';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
@@ -15,17 +17,16 @@ import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
import Relationships from '@app/features/relationship/state/Relationships';
|
||||
import {FluxerButtonContextMenu} from '@app/features/ui/action_menu/FluxerButtonContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import {FluxerSymbol} from '@app/features/ui/components/icons/FluxerSymbol';
|
||||
import {MentionBadgeAnimated} from '@app/features/ui/components/MentionBadge';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import SidebarPreferences from '@app/features/ui/state/SidebarPreferences';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {ChatCircleIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -56,6 +57,7 @@ export const FluxerButton = observer(() => {
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconRef = useRef<HTMLDivElement | null>(null);
|
||||
const mergedButtonRef = useMergeRefs([hoverRef, buttonRef]);
|
||||
const contextMenuOpen = useContextMenuHoverState(buttonRef, !MobileLayout.enabled);
|
||||
const location = useLocation();
|
||||
const isSelected = location.pathname.startsWith(Routes.ME) || Routes.isSpecialPage(location.pathname);
|
||||
const selectedChannel = SelectedChannel.selectedChannelIds.get(ME);
|
||||
@@ -117,13 +119,9 @@ export const FluxerButton = observer(() => {
|
||||
/>
|
||||
));
|
||||
}, []);
|
||||
const indicatorHeight =
|
||||
(() => {
|
||||
if (isSelected) return 40;
|
||||
if (isHovering) return 20;
|
||||
return 8;
|
||||
})() * getAppZoomFactor();
|
||||
const isActive = isHovering || isSelected;
|
||||
const shouldShowHoverState = isHovering || contextMenuOpen;
|
||||
const indicatorTarget = resolveGuildListIndicatorBarTarget({isSelected, showHoverState: shouldShowHoverState});
|
||||
const isActive = shouldShowHoverState || isSelected;
|
||||
if (RuntimeConfig.directMessagesDisabled) {
|
||||
return null;
|
||||
}
|
||||
@@ -142,7 +140,7 @@ export const FluxerButton = observer(() => {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.fluxerButton}
|
||||
className={clsx(styles.fluxerButton, contextMenuOpen && styles.contextMenuHover)}
|
||||
aria-label={directMessagesLabel}
|
||||
aria-current={isSelected ? 'page' : undefined}
|
||||
data-guild-list-focus-item="true"
|
||||
@@ -152,13 +150,13 @@ export const FluxerButton = observer(() => {
|
||||
data-flx="app.sidebar-nav.fluxer-button.fluxer-button.select"
|
||||
>
|
||||
<AnimatePresence data-flx="app.sidebar-nav.fluxer-button.animate-presence">
|
||||
{(isSelected || isHovering) && (
|
||||
{(isSelected || shouldShowHoverState) && (
|
||||
<div className={styles.guildIndicator} data-flx="app.sidebar-nav.fluxer-button.guild-indicator">
|
||||
<motion.span
|
||||
className={styles.guildIndicatorBar}
|
||||
initial={false}
|
||||
animate={{opacity: 1, scale: 1, height: indicatorHeight}}
|
||||
exit={{opacity: 0, scale: 0, height: 0}}
|
||||
animate={indicatorTarget}
|
||||
exit={{opacity: 0, height: 0}}
|
||||
transition={{duration: 0.2, ease: [0.25, 0.1, 0.25, 1]}}
|
||||
data-flx="app.sidebar-nav.fluxer-button.guild-indicator-bar"
|
||||
/>
|
||||
@@ -174,9 +172,10 @@ export const FluxerButton = observer(() => {
|
||||
transition={{duration: 0.07, ease: 'easeOut'}}
|
||||
data-flx="app.sidebar-nav.fluxer-button.fluxer-button-icon"
|
||||
>
|
||||
<FluxerSymbol
|
||||
className={styles.fluxerSymbolIcon}
|
||||
data-flx="app.sidebar-nav.fluxer-button.fluxer-symbol-icon"
|
||||
<ChatCircleIcon
|
||||
weight="fill"
|
||||
className={styles.messageBubbleIcon}
|
||||
data-flx="app.sidebar-nav.fluxer-button.message-bubble-icon"
|
||||
/>
|
||||
</motion.div>
|
||||
<div
|
||||
|
||||
+12
-26
@@ -6,12 +6,11 @@
|
||||
--folder-hover-fill: 14%;
|
||||
--folder-selected-fill: 18%;
|
||||
--folder-expanded-fill: 8%;
|
||||
--folder-item-size: var(--guild-folder-expanded-surface-size, var(--guild-list-item-box-size, 3rem));
|
||||
--folder-item-box-size: var(--guild-folder-expanded-surface-size, var(--guild-list-item-box-size, 3rem));
|
||||
--folder-item-size: var(--guild-folder-expanded-surface-size);
|
||||
--folder-item-box-size: var(--guild-folder-expanded-surface-size);
|
||||
--folder-item-gap: var(--guild-list-item-gap, 0.25rem);
|
||||
--folder-item-inset: calc((var(--folder-item-box-size) - var(--folder-item-size)) / 2);
|
||||
--folder-expanded-surface-size: var(--guild-folder-expanded-surface-size, var(--folder-item-box-size));
|
||||
--folder-radius: calc(var(--folder-item-size) * 0.3);
|
||||
--folder-expanded-surface-size: var(--guild-folder-expanded-surface-size);
|
||||
--folder-surface: color-mix(
|
||||
in srgb,
|
||||
var(--surface-interactive-hover-bg) calc(100% - var(--folder-fill)),
|
||||
@@ -64,26 +63,6 @@
|
||||
--guild-badge-surface: var(--folder-expanded-surface);
|
||||
}
|
||||
|
||||
.folderIndicator {
|
||||
position: absolute;
|
||||
left: -0.15rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--folder-item-size);
|
||||
width: 0.5rem;
|
||||
pointer-events: none;
|
||||
container-type: layout size;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.folderIndicatorBar {
|
||||
display: block;
|
||||
width: 0.35rem;
|
||||
border-radius: 0 var(--radius-full) var(--radius-full) 0;
|
||||
background-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
height: var(--folder-item-size);
|
||||
@@ -163,10 +142,17 @@
|
||||
z-index: 2;
|
||||
pointer-events: auto;
|
||||
border-radius: var(--radius-md);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.folderBadgeActive {
|
||||
box-shadow: 0 0 0 0.1875rem var(--guild-badge-surface, var(--background-secondary));
|
||||
.folderBadgeActive::before,
|
||||
.folderBadge:not(:empty)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.1875rem;
|
||||
z-index: -1;
|
||||
border-radius: calc(var(--radius-md) + 0.1875rem);
|
||||
background-color: var(--guild-badge-surface);
|
||||
}
|
||||
|
||||
.expandedFolderBackground {
|
||||
|
||||
@@ -3,36 +3,45 @@
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {
|
||||
canGuildDropOnTarget,
|
||||
type GuildFolderReorderIndicator,
|
||||
GuildReorderStateMachine,
|
||||
type GuildReorderTarget,
|
||||
selectGuildReorderIntent,
|
||||
GuildReorderTargetKind,
|
||||
} from '@app/features/app/components/layout/dnd/GuildReorderStateMachine';
|
||||
import {useDragTargetRect} from '@app/features/app/components/layout/dnd/useDragTargetRect';
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css';
|
||||
import styles from '@app/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css';
|
||||
import {resolveGuildListIndicatorBarTarget} from '@app/features/app/components/layout/sidebar_nav/GuildListIndicator';
|
||||
import {GuildListItem} from '@app/features/app/components/layout/sidebar_nav/GuildListItem';
|
||||
import {VoiceBadge, type VoiceBadgeActivity} from '@app/features/app/components/layout/sidebar_nav/VoiceBadge';
|
||||
import {DND_TYPES, type GuildDragItem, type GuildDropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {
|
||||
DragItemType,
|
||||
DropPlacement,
|
||||
type GuildDragItem,
|
||||
type GuildDropResult,
|
||||
} from '@app/features/app/components/layout/types/DndTypes';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import {useHover} from '@app/features/app/hooks/useHover';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import GuildFolderExpanded from '@app/features/guild/state/GuildFolderExpanded';
|
||||
import GuildReadState from '@app/features/guild/state/GuildReadState';
|
||||
import {truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {getInitialsFromName, truncateInitials} from '@app/features/guild/utils/GuildInitialsUtils';
|
||||
import {MENTION_COUNT_ARIA_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import Theme from '@app/features/theme/state/Theme';
|
||||
import {Edge} from '@app/features/ui/AxisOrientation';
|
||||
import {GuildFolderContextMenu} from '@app/features/ui/action_menu/GuildFolderContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import {MentionBadgeAnimated} from '@app/features/ui/components/MentionBadge';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {AnimePresence, AnimeSpan, createAnimeFlxElement} from '@app/features/ui/motion/AnimeElement';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import * as AvatarSourceUtils from '@app/features/user/utils/AvatarSourceUtils';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useVoiceGatewayStateVersion} from '@app/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter';
|
||||
import * as StringUtils from '@app/lib/strings';
|
||||
import {flxElementClassName} from '@app/lib/react';
|
||||
import {
|
||||
GuildFolderFlags,
|
||||
type GuildFolderIcon,
|
||||
@@ -51,20 +60,21 @@ import {
|
||||
ShieldIcon,
|
||||
StarIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {CSSProperties} from 'react';
|
||||
import type {CSSProperties, ReactNode} from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import type {ConnectableElement} from 'react-dnd';
|
||||
import {useDrag, useDrop} from 'react-dnd';
|
||||
import {getEmptyImage} from 'react-dnd-html5-backend';
|
||||
|
||||
const ExpandedFolderBackground = createAnimeFlxElement('flx-app-guild-folder-backdrop');
|
||||
const ExpandedFolderGuilds = createAnimeFlxElement('flx-app-guild-folder-guilds');
|
||||
|
||||
const FOLDER_DESCRIPTOR = msg({
|
||||
message: 'Folder',
|
||||
comment: 'Short label in the sidebar navigation guild folder item.',
|
||||
});
|
||||
const FOLDER_2_DESCRIPTOR = msg({
|
||||
const FOLDER_ARIA_NAME_DESCRIPTOR = msg({
|
||||
message: '{folderName} folder',
|
||||
comment: 'Short label in the sidebar navigation guild folder item. Preserve {folderName}; it is inserted by code.',
|
||||
});
|
||||
@@ -76,11 +86,6 @@ const COLLAPSED_DESCRIPTOR = msg({
|
||||
message: 'collapsed',
|
||||
comment: 'Lowercase screen-reader fragment in the sidebar navigation guild folder item.',
|
||||
});
|
||||
const MENTIONS_DESCRIPTOR = msg({
|
||||
message: '{totalMentionCount} mentions',
|
||||
comment:
|
||||
'Short label in the sidebar navigation guild folder item. Preserve {totalMentionCount}; it is inserted by code.',
|
||||
});
|
||||
const UNREAD_DESCRIPTOR = msg({
|
||||
message: 'unread',
|
||||
comment: 'Lowercase screen-reader fragment in the sidebar navigation guild folder item.',
|
||||
@@ -104,19 +109,95 @@ interface GuildFolder {
|
||||
}
|
||||
|
||||
interface GuildFolderItemProps {
|
||||
folder: GuildFolder;
|
||||
guilds: Array<Guild>;
|
||||
isSelected: boolean;
|
||||
isSortingList?: boolean;
|
||||
onGuildDrop?: (item: GuildDragItem, result: GuildDropResult) => void;
|
||||
onDragStateChange?: (item: GuildDragItem | null) => void;
|
||||
guildNavigationIndexes: ReadonlyMap<string, number>;
|
||||
selectedGuildIndex?: number;
|
||||
readonly folder: GuildFolder;
|
||||
readonly guilds: Array<Guild>;
|
||||
readonly isSelected: boolean;
|
||||
readonly isSortingList: boolean;
|
||||
readonly onGuildDrop: ((item: GuildDragItem, result: GuildDropResult) => void) | null;
|
||||
readonly onDragStateChange: ((item: GuildDragItem | null) => void) | null;
|
||||
readonly guildNavigationIndexes: ReadonlyMap<string, number>;
|
||||
readonly selectedGuildIndex: number | null;
|
||||
readonly registerScrollTarget: ((id: string) => React.RefCallback<HTMLElement>) | null;
|
||||
}
|
||||
|
||||
interface FolderVoiceActivity {
|
||||
readonly hasVoice: boolean;
|
||||
readonly hasScreenshare: boolean;
|
||||
readonly hasVideo: boolean;
|
||||
}
|
||||
|
||||
const FOLDER_BACKGROUND_FADE_TARGET = Object.freeze({opacity: 0});
|
||||
const FOLDER_GUILDS_COLLAPSE_TARGET = Object.freeze({opacity: 0, translateY: '-0.5rem'});
|
||||
|
||||
interface FolderStateAttributesRequest {
|
||||
readonly isExpanded: boolean;
|
||||
readonly isSelected: boolean;
|
||||
readonly shouldShowHoverState: boolean;
|
||||
}
|
||||
|
||||
function buildFolderStateAttributes({
|
||||
isExpanded,
|
||||
isSelected,
|
||||
shouldShowHoverState,
|
||||
}: FolderStateAttributesRequest): Record<string, 'true'> {
|
||||
const attributes: Record<string, 'true'> = {};
|
||||
if (isExpanded) {
|
||||
attributes['data-expanded'] = 'true';
|
||||
}
|
||||
if (isSelected) {
|
||||
attributes['data-selected'] = 'true';
|
||||
}
|
||||
if (shouldShowHoverState) {
|
||||
attributes['data-hovered'] = 'true';
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function resolveFolderDragCursorStyle(isDragging: boolean): CSSProperties {
|
||||
if (!isDragging) {
|
||||
return {};
|
||||
}
|
||||
return {cursor: 'grabbing'};
|
||||
}
|
||||
|
||||
function resolveMotionTarget<Target>(prefersReducedMotion: boolean, target: Target): Target | false {
|
||||
if (prefersReducedMotion) {
|
||||
return false;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function resolveMotionLeave<Target>(
|
||||
prefersReducedMotion: boolean,
|
||||
target: Target,
|
||||
): {leave: Target} | Record<string, never> {
|
||||
if (prefersReducedMotion) {
|
||||
return {};
|
||||
}
|
||||
return {leave: target};
|
||||
}
|
||||
|
||||
function folderIndicatorLeaveOpacity(prefersReducedMotion: boolean): {opacity: number} {
|
||||
if (prefersReducedMotion) {
|
||||
return {opacity: 1};
|
||||
}
|
||||
return {opacity: 0};
|
||||
}
|
||||
|
||||
function deriveGuildFolderName(guilds: ReadonlyArray<Guild>): string {
|
||||
const names: Array<string> = [];
|
||||
for (let index = 0; index < guilds.length && index < 3; index++) {
|
||||
names.push(guilds[index].name);
|
||||
}
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
function getFolderColor(color: number | null, isLightTheme: boolean): string {
|
||||
if (color === null || color === 0) {
|
||||
return isLightTheme ? 'var(--brand-primary)' : 'var(--brand-primary-light)';
|
||||
if (color == null || color === 0) {
|
||||
if (isLightTheme) {
|
||||
return 'var(--brand-primary)';
|
||||
}
|
||||
return 'var(--brand-primary-light)';
|
||||
}
|
||||
return `#${color.toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
@@ -186,41 +267,45 @@ function renderCollapsedFolderIcon(icon: GuildFolderIcon) {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFolderExpansionId(folderId: number | null): number {
|
||||
if (folderId == null) {
|
||||
return UNCATEGORIZED_FOLDER_ID;
|
||||
}
|
||||
return folderId;
|
||||
}
|
||||
|
||||
export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
const {
|
||||
folder,
|
||||
guilds,
|
||||
isSelected,
|
||||
isSortingList = false,
|
||||
isSortingList,
|
||||
onGuildDrop,
|
||||
onDragStateChange,
|
||||
guildNavigationIndexes,
|
||||
selectedGuildIndex,
|
||||
registerScrollTarget,
|
||||
} = props;
|
||||
const {i18n} = useLingui();
|
||||
useVoiceGatewayStateVersion();
|
||||
const location = useLocation();
|
||||
const isExpanded = GuildFolderExpanded.isExpanded(folder.id ?? UNCATEGORIZED_FOLDER_ID);
|
||||
const isExpanded = GuildFolderExpanded.isExpanded(resolveFolderExpansionId(folder.id));
|
||||
const [hoverRef, isHovering] = useHover();
|
||||
const focusableRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusRingTargetRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusableRef = useRef<HTMLElement | null>(null);
|
||||
const focusRingTargetRef = useRef<HTMLElement | null>(null);
|
||||
const itemRef = useRef<HTMLElement | null>(null);
|
||||
const mobileLayout = MobileLayout;
|
||||
const [dropIndicator, setDropIndicator] = useState<'top' | 'bottom' | 'inside' | null>(null);
|
||||
const contextMenuOpen = useContextMenuHoverState(itemRef, !mobileLayout.enabled);
|
||||
const [dropIndicator, setDropIndicator] = useState<GuildFolderReorderIndicator | null>(null);
|
||||
const getDropTargetRect = useDragTargetRect(itemRef);
|
||||
const setFolderDropIndicator = useCallback((indicator: 'top' | 'bottom' | 'inside' | null) => {
|
||||
setDropIndicator((current) => (current === indicator ? current : indicator));
|
||||
const setFolderDropIndicator = useCallback((indicator: GuildFolderReorderIndicator | null) => {
|
||||
setDropIndicator(indicator);
|
||||
}, []);
|
||||
const resetFolderDropIndicator = useCallback(() => {
|
||||
setFolderDropIndicator(null);
|
||||
}, [setFolderDropIndicator]);
|
||||
const derivedFolderName = useMemo(() => {
|
||||
return guilds
|
||||
.slice(0, 3)
|
||||
.map((guild) => guild.name)
|
||||
.join(', ');
|
||||
}, [guilds]);
|
||||
const folderName = folder.name || derivedFolderName || i18n._(FOLDER_DESCRIPTOR);
|
||||
let folderName = folder.name;
|
||||
if (folderName == null || folderName === '') folderName = deriveGuildFolderName(guilds);
|
||||
if (folderName === '') folderName = i18n._(FOLDER_DESCRIPTOR);
|
||||
const isLightTheme = Theme.effectiveTheme === ThemeTypes.LIGHT;
|
||||
const folderColor = getFolderColor(folder.color, isLightTheme);
|
||||
const folderId = `folder-${folder.id}`;
|
||||
@@ -233,25 +318,23 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
);
|
||||
const hasUnreadMessages = guilds.some((guild) => GuildReadState.hasUnread(guild.id));
|
||||
const totalMentionCount = guilds.reduce((sum, guild) => sum + GuildReadState.getMentionCount(guild.id), 0);
|
||||
const folderScrollSeverity: ScrollIndicatorSeverity | undefined = (() => {
|
||||
if (isExpanded) return undefined;
|
||||
if (totalMentionCount > 0) return 'mention';
|
||||
if (hasUnreadMessages) return 'unread';
|
||||
return undefined;
|
||||
})();
|
||||
const folderVoiceActivity = (() => {
|
||||
let folderScrollTargetRef: React.RefCallback<HTMLElement> | null = null;
|
||||
if (registerScrollTarget != null) {
|
||||
folderScrollTargetRef = registerScrollTarget(folderId);
|
||||
}
|
||||
function resolveFolderVoiceActivity(): FolderVoiceActivity {
|
||||
let hasVoice = false;
|
||||
let hasScreenshare = false;
|
||||
let hasVideo = false;
|
||||
for (const guild of guilds) {
|
||||
const guildVoiceStates = MediaEngine.getAllVoiceStatesInGuild(guild.id);
|
||||
if (!guildVoiceStates) continue;
|
||||
if (guildVoiceStates == null) continue;
|
||||
for (const channelId in guildVoiceStates) {
|
||||
const channelStates = guildVoiceStates[channelId];
|
||||
if (!channelStates) continue;
|
||||
if (channelStates == null) continue;
|
||||
for (const connectionId in channelStates) {
|
||||
const voiceState = channelStates[connectionId];
|
||||
if (!voiceState) continue;
|
||||
if (voiceState == null) continue;
|
||||
hasVoice = true;
|
||||
if (voiceState.self_stream === true) {
|
||||
hasScreenshare = true;
|
||||
@@ -266,26 +349,29 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
}
|
||||
}
|
||||
return {hasVoice, hasScreenshare, hasVideo};
|
||||
})();
|
||||
}
|
||||
const folderVoiceActivity = resolveFolderVoiceActivity();
|
||||
const folderActivityType = useMemo<VoiceBadgeActivity | null>(() => {
|
||||
if (!folderVoiceActivity.hasVoice) return null;
|
||||
if (folderVoiceActivity.hasScreenshare) return 'screenshare';
|
||||
if (folderVoiceActivity.hasVideo) return 'video';
|
||||
return 'voice';
|
||||
}, [folderVoiceActivity.hasScreenshare, folderVoiceActivity.hasVideo, folderVoiceActivity.hasVoice]);
|
||||
const folderAriaLabel = useMemo(() => {
|
||||
const parts = [
|
||||
i18n._(FOLDER_2_DESCRIPTOR, {folderName}),
|
||||
isExpanded ? i18n._(EXPANDED_DESCRIPTOR) : i18n._(COLLAPSED_DESCRIPTOR),
|
||||
];
|
||||
if (totalMentionCount > 0) parts.push(i18n._(MENTIONS_DESCRIPTOR, {totalMentionCount}));
|
||||
else if (hasUnreadMessages) parts.push(i18n._(UNREAD_DESCRIPTOR));
|
||||
const folderARIALabel = useMemo(() => {
|
||||
let expansionLabel = i18n._(COLLAPSED_DESCRIPTOR);
|
||||
if (isExpanded) {
|
||||
expansionLabel = i18n._(EXPANDED_DESCRIPTOR);
|
||||
}
|
||||
const parts = [i18n._(FOLDER_ARIA_NAME_DESCRIPTOR, {folderName}), expansionLabel];
|
||||
if (totalMentionCount > 0) {
|
||||
parts.push(i18n._(MENTION_COUNT_ARIA_DESCRIPTOR, {mentionCount: totalMentionCount}));
|
||||
} else if (hasUnreadMessages) parts.push(i18n._(UNREAD_DESCRIPTOR));
|
||||
if (folderVoiceActivity.hasVoice) parts.push(i18n._(VOICE_ACTIVITY_DESCRIPTOR));
|
||||
return parts.join(', ');
|
||||
}, [folderName, folderVoiceActivity.hasVoice, hasUnreadMessages, isExpanded, totalMentionCount, i18n.locale]);
|
||||
const dragItemData = useMemo<GuildDragItem>(
|
||||
() => ({
|
||||
type: DND_TYPES.GUILD_FOLDER,
|
||||
type: DragItemType.GUILD_FOLDER,
|
||||
id: folderId,
|
||||
isFolder: true,
|
||||
folderId: folder.id,
|
||||
@@ -295,21 +381,25 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
const dropTargetData = useMemo<GuildReorderTarget>(
|
||||
() => ({
|
||||
id: folderId,
|
||||
kind: 'folder',
|
||||
kind: GuildReorderTargetKind.FOLDER,
|
||||
}),
|
||||
[folderId],
|
||||
);
|
||||
const [{isDragging}, dragRef, preview] = useDrag(
|
||||
() => ({
|
||||
type: DND_TYPES.GUILD_FOLDER,
|
||||
type: DragItemType.GUILD_FOLDER,
|
||||
item: () => {
|
||||
onDragStateChange?.(dragItemData);
|
||||
if (onDragStateChange != null) {
|
||||
onDragStateChange(dragItemData);
|
||||
}
|
||||
return dragItemData;
|
||||
},
|
||||
canDrag: !mobileLayout.enabled,
|
||||
collect: (monitor) => ({isDragging: monitor.isDragging()}),
|
||||
end: () => {
|
||||
onDragStateChange?.(null);
|
||||
if (onDragStateChange != null) {
|
||||
onDragStateChange(null);
|
||||
}
|
||||
resetFolderDropIndicator();
|
||||
},
|
||||
}),
|
||||
@@ -317,19 +407,24 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
);
|
||||
const [{isOver}, dropRef] = useDrop(
|
||||
() => ({
|
||||
accept: [DND_TYPES.GUILD_ITEM, DND_TYPES.GUILD_FOLDER],
|
||||
canDrop: (item: GuildDragItem) => canGuildDropOnTarget(item, dropTargetData),
|
||||
accept: [DragItemType.GUILD_ITEM, DragItemType.GUILD_FOLDER],
|
||||
canDrop: (item: GuildDragItem) => GuildReorderStateMachine.canDrop({item, target: dropTargetData}),
|
||||
hover: (item: GuildDragItem, monitor) => {
|
||||
if (!canGuildDropOnTarget(item, dropTargetData)) {
|
||||
if (!GuildReorderStateMachine.canDrop({item, target: dropTargetData})) {
|
||||
resetFolderDropIndicator();
|
||||
return;
|
||||
}
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
if (clientOffset == null) return;
|
||||
const boundingRect = getDropTargetRect();
|
||||
if (!boundingRect) return;
|
||||
const intent = selectGuildReorderIntent(item, dropTargetData, clientOffset, boundingRect);
|
||||
if (!intent || intent.indicator === 'combine') {
|
||||
if (boundingRect == null) return;
|
||||
const intent = GuildReorderStateMachine.selectIntent({
|
||||
item,
|
||||
target: dropTargetData,
|
||||
clientOffset,
|
||||
targetRect: boundingRect,
|
||||
});
|
||||
if (intent == null || intent.indicator === DropPlacement.COMBINE) {
|
||||
resetFolderDropIndicator();
|
||||
return;
|
||||
}
|
||||
@@ -341,17 +436,24 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
return;
|
||||
}
|
||||
const node = itemRef.current;
|
||||
if (!node) return;
|
||||
if (node == null) return;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
if (clientOffset == null) return;
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
const intent = selectGuildReorderIntent(item, dropTargetData, clientOffset, boundingRect);
|
||||
if (!intent) {
|
||||
const intent = GuildReorderStateMachine.selectIntent({
|
||||
item,
|
||||
target: dropTargetData,
|
||||
clientOffset,
|
||||
targetRect: boundingRect,
|
||||
});
|
||||
if (intent == null) {
|
||||
resetFolderDropIndicator();
|
||||
return;
|
||||
}
|
||||
const result = intent.result;
|
||||
onGuildDrop?.(item, result);
|
||||
if (onGuildDrop != null) {
|
||||
onGuildDrop(item, result);
|
||||
}
|
||||
resetFolderDropIndicator();
|
||||
return result;
|
||||
},
|
||||
@@ -379,9 +481,16 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
},
|
||||
[dropRef],
|
||||
);
|
||||
const mergedRef = useMergeRefs([dragConnectorRef, dropConnectorRef, hoverRef, focusableRef, itemRef]);
|
||||
const mergedRef = useMergeRefs([
|
||||
dragConnectorRef,
|
||||
dropConnectorRef,
|
||||
hoverRef,
|
||||
focusableRef,
|
||||
itemRef,
|
||||
folderScrollTargetRef,
|
||||
]);
|
||||
const handleToggleExpanded = useCallback(() => {
|
||||
GuildFolderExpanded.toggleExpanded(folder.id ?? UNCATEGORIZED_FOLDER_ID);
|
||||
GuildFolderExpanded.toggleExpanded(resolveFolderExpansionId(folder.id));
|
||||
}, [folder.id]);
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
@@ -396,68 +505,211 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ContextMenuCommands.openFromEvent(event, (props) => (
|
||||
ContextMenuCommands.openFromEvent(event, (contextMenuProps) => (
|
||||
<GuildFolderContextMenu
|
||||
folder={folder}
|
||||
guilds={guilds}
|
||||
onClose={props.onClose}
|
||||
onClose={contextMenuProps.onClose}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.handle-context-menu.guild-folder-context-menu"
|
||||
/>
|
||||
));
|
||||
},
|
||||
[folder, guilds],
|
||||
);
|
||||
const shouldShowHoverState = isHovering;
|
||||
const indicatorHeight =
|
||||
(() => {
|
||||
if (isSelected) return 40;
|
||||
if (shouldShowHoverState) return 20;
|
||||
return 8;
|
||||
})() * getAppZoomFactor();
|
||||
const shouldShowHoverState = isHovering || contextMenuOpen;
|
||||
let showFolderIndicator = hasUnreadMessages;
|
||||
if (isSelected) showFolderIndicator = true;
|
||||
if (shouldShowHoverState) showFolderIndicator = true;
|
||||
const indicatorTarget = resolveGuildListIndicatorBarTarget({isSelected, showHoverState: shouldShowHoverState});
|
||||
const prefersReducedMotion = Accessibility.useReducedMotion;
|
||||
const firstFourGuilds = guilds.slice(0, 4);
|
||||
const showCollapsedIcon = shouldShowCollapsedFolderIcon(folder.flags);
|
||||
const tooltipText = useMemo(() => {
|
||||
return isExpanded ? i18n._(COLLAPSE_DESCRIPTOR, {folderName}) : folderName;
|
||||
if (isExpanded) {
|
||||
return i18n._(COLLAPSE_DESCRIPTOR, {folderName});
|
||||
}
|
||||
return folderName;
|
||||
}, [isExpanded, folderName, i18n.locale]);
|
||||
const expandTransition = useMemo(
|
||||
() => (prefersReducedMotion ? {duration: 0} : {duration: 0.2, ease: [0.25, 0.1, 0.25, 1] as const}),
|
||||
[prefersReducedMotion],
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.folderContainer}
|
||||
style={folderAccentStyle}
|
||||
data-expanded={isExpanded ? 'true' : undefined}
|
||||
data-selected={isSelected ? 'true' : undefined}
|
||||
data-hovered={shouldShowHoverState ? 'true' : undefined}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.folder-container"
|
||||
>
|
||||
<AnimatePresence initial={false} data-flx="app.sidebar-nav.guild-folder-item.animate-presence">
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
className={styles.expandedFolderBackground}
|
||||
initial={prefersReducedMotion ? false : {opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={prefersReducedMotion ? undefined : {opacity: 0}}
|
||||
transition={expandTransition}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.expanded-folder-background"
|
||||
const expandTransition = useMemo(() => {
|
||||
if (prefersReducedMotion) {
|
||||
return {duration: 0};
|
||||
}
|
||||
return {duration: 0.2, ease: [0.25, 0.1, 0.25, 1] as const};
|
||||
}, [prefersReducedMotion]);
|
||||
let indicatorTweenDuration = 0.2;
|
||||
if (prefersReducedMotion) {
|
||||
indicatorTweenDuration = 0;
|
||||
}
|
||||
function renderExpandedFolderBackground(): ReactNode {
|
||||
if (!isExpanded) return null;
|
||||
return (
|
||||
<ExpandedFolderBackground
|
||||
className={styles.expandedFolderBackground}
|
||||
from={resolveMotionTarget(prefersReducedMotion, FOLDER_BACKGROUND_FADE_TARGET)}
|
||||
to={{opacity: 1}}
|
||||
tween={expandTransition}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-expanded-folder-background.expanded-folder-background"
|
||||
{...resolveMotionLeave(prefersReducedMotion, FOLDER_BACKGROUND_FADE_TARGET)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function renderCollapsedFolderIndicator(): ReactNode {
|
||||
if (isExpanded) return null;
|
||||
if (isSortingList) return null;
|
||||
if (!showFolderIndicator) return null;
|
||||
return (
|
||||
<flx-app-guild-folder-indicator
|
||||
className={flxElementClassName(guildStyles.guildIndicator)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-collapsed-folder-indicator.flx-app-guild-folder-indicator"
|
||||
>
|
||||
<AnimeSpan
|
||||
className={guildStyles.guildIndicatorBar}
|
||||
from={false}
|
||||
to={indicatorTarget}
|
||||
leave={folderIndicatorLeaveOpacity(prefersReducedMotion)}
|
||||
tween={{duration: indicatorTweenDuration, ease: [0.25, 0.1, 0.25, 1]}}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-collapsed-folder-indicator.anime-span"
|
||||
/>
|
||||
</flx-app-guild-folder-indicator>
|
||||
);
|
||||
}
|
||||
function renderExpandedFolderIcon(): ReactNode {
|
||||
if (!isExpanded) return null;
|
||||
return (
|
||||
<flx-app-guild-folder-item-icon
|
||||
className={flxElementClassName(styles.folderHeaderButton)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-expanded-folder-icon.folder-header-button"
|
||||
>
|
||||
{renderCollapsedFolderIcon(folder.icon)}
|
||||
</flx-app-guild-folder-item-icon>
|
||||
);
|
||||
}
|
||||
function renderMiniGuildIcon(guild: Guild): ReactNode {
|
||||
return (
|
||||
<MiniGuildIcon
|
||||
key={guild.id}
|
||||
guild={guild}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-mini-guild-icon.mini-guild-icon"
|
||||
/>
|
||||
);
|
||||
}
|
||||
function renderCollapsedFolder(): ReactNode {
|
||||
if (isExpanded) return null;
|
||||
let folderVisual: ReactNode;
|
||||
if (showCollapsedIcon) {
|
||||
folderVisual = (
|
||||
<flx-app-guild-folder-item-icon
|
||||
className={flxElementClassName(styles.folderHeaderButton)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-collapsed-folder.folder-header-button"
|
||||
>
|
||||
{renderCollapsedFolderIcon(folder.icon)}
|
||||
</flx-app-guild-folder-item-icon>
|
||||
);
|
||||
} else {
|
||||
folderVisual = (
|
||||
<flx-app-guild-folder-item-grid
|
||||
className={flxElementClassName(styles.collapsedFolder)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-collapsed-folder.collapsed-folder"
|
||||
>
|
||||
{firstFourGuilds.map(renderMiniGuildIcon)}
|
||||
</flx-app-guild-folder-item-grid>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<flx-app-guild-folder-item-backdrop
|
||||
className={flxElementClassName(styles.collapsedFolderBackground)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-collapsed-folder.collapsed-folder-background"
|
||||
/>
|
||||
{folderVisual}
|
||||
{folderActivityType != null && (
|
||||
<VoiceBadge
|
||||
activity={folderActivityType}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-collapsed-folder.voice-badge"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* biome-ignore lint/a11y/noAriaHiddenOnFocusable: decorative badge, not focusable */}
|
||||
<flx-app-guild-folder-item-badge
|
||||
aria-hidden="true"
|
||||
className={flxElementClassName(styles.folderBadge, totalMentionCount > 0 && styles.folderBadgeActive)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-collapsed-folder.folder-badge"
|
||||
>
|
||||
<MentionBadgeAnimated
|
||||
mentionCount={totalMentionCount}
|
||||
size="small"
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-collapsed-folder.mention-badge-animated"
|
||||
/>
|
||||
</flx-app-guild-folder-item-badge>
|
||||
</>
|
||||
);
|
||||
}
|
||||
function renderExpandedGuild(guild: Guild, index: number): ReactNode {
|
||||
const isGuildSelected = location.pathname.startsWith(Routes.guildChannel(guild.id));
|
||||
let scrollTargetRef: React.RefCallback<HTMLElement> | null = null;
|
||||
if (registerScrollTarget != null) {
|
||||
scrollTargetRef = registerScrollTarget(`guild-${guild.id}`);
|
||||
}
|
||||
let guildIndex: number | null = null;
|
||||
const navigationIndex = guildNavigationIndexes.get(guild.id);
|
||||
if (navigationIndex != null) {
|
||||
guildIndex = navigationIndex;
|
||||
}
|
||||
return (
|
||||
<GuildListItem
|
||||
key={guild.id}
|
||||
guild={guild}
|
||||
isSelected={isGuildSelected}
|
||||
guildIndex={guildIndex}
|
||||
selectedGuildIndex={selectedGuildIndex}
|
||||
onGuildDrop={onGuildDrop}
|
||||
onDragStateChange={onDragStateChange}
|
||||
insideFolderId={folder.id}
|
||||
isLastInsideFolder={index === guilds.length - 1}
|
||||
isSortingList={isSortingList}
|
||||
disableDrag={false}
|
||||
scrollTargetRef={scrollTargetRef}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-expanded-guild.guild-list-item"
|
||||
/>
|
||||
);
|
||||
}
|
||||
function renderExpandedGuilds(): ReactNode {
|
||||
if (!isExpanded) return null;
|
||||
return (
|
||||
<ExpandedFolderGuilds
|
||||
className={styles.expandedGuilds}
|
||||
from={resolveMotionTarget(prefersReducedMotion, FOLDER_GUILDS_COLLAPSE_TARGET)}
|
||||
to={{opacity: 1, translateY: '0rem'}}
|
||||
tween={expandTransition}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.render-expanded-guilds.expanded-guilds"
|
||||
{...resolveMotionLeave(prefersReducedMotion, FOLDER_GUILDS_COLLAPSE_TARGET)}
|
||||
>
|
||||
{guilds.map(renderExpandedGuild)}
|
||||
</ExpandedFolderGuilds>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<flx-app-guild-folder-item
|
||||
className={flxElementClassName(styles.folderContainer)}
|
||||
style={folderAccentStyle}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.folder-container"
|
||||
{...buildFolderStateAttributes({isExpanded, isSelected, shouldShowHoverState})}
|
||||
>
|
||||
<AnimePresence enterOnMount={false} data-flx="app.sidebar-nav.guild-folder-item.anime-presence">
|
||||
{renderExpandedFolderBackground()}
|
||||
</AnimePresence>
|
||||
<Tooltip
|
||||
position="right"
|
||||
maxWidth="xl"
|
||||
size="large"
|
||||
text={() => (
|
||||
<div
|
||||
className={styles.folderTooltipContainer}
|
||||
<flx-app-guild-folder-item-tooltip
|
||||
className={flxElementClassName(styles.folderTooltipContainer)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.folder-tooltip-container"
|
||||
>
|
||||
<span className={styles.folderTooltipName} data-flx="app.sidebar-nav.guild-folder-item.folder-tooltip-name">
|
||||
{tooltipText}
|
||||
</span>
|
||||
</div>
|
||||
</flx-app-guild-folder-item-tooltip>
|
||||
)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.tooltip"
|
||||
>
|
||||
@@ -467,175 +719,66 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
offset={-2}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.focus-ring"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
<flx-app-guild-folder-item-toggle
|
||||
className={flxElementClassName(
|
||||
styles.folderHeader,
|
||||
dropIndicator === 'top' && styles.dropIndicatorTop,
|
||||
dropIndicator === 'bottom' && styles.dropIndicatorBottom,
|
||||
dropIndicator === 'inside' && styles.dropIndicatorInside,
|
||||
dropIndicator === Edge.TOP && styles.dropIndicatorTop,
|
||||
dropIndicator === Edge.BOTTOM && styles.dropIndicatorBottom,
|
||||
dropIndicator === DropPlacement.INSIDE && styles.dropIndicatorInside,
|
||||
)}
|
||||
ref={mergedRef}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-guild-list-focus-item="true"
|
||||
aria-label={folderAriaLabel}
|
||||
aria-label={folderARIALabel}
|
||||
aria-expanded={isExpanded}
|
||||
onClick={handleToggleExpanded}
|
||||
onContextMenu={handleContextMenu}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{cursor: isDragging ? 'grabbing' : undefined}}
|
||||
data-scroll-indicator={folderScrollSeverity}
|
||||
data-scroll-id={folderId}
|
||||
style={resolveFolderDragCursorStyle(isDragging)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.folder-header.toggle-expanded"
|
||||
>
|
||||
{!isExpanded && (
|
||||
<AnimatePresence data-flx="app.sidebar-nav.guild-folder-item.animate-presence--2">
|
||||
{!isSortingList && (hasUnreadMessages || isSelected || shouldShowHoverState) && (
|
||||
<motion.div
|
||||
className={styles.folderIndicator}
|
||||
initial={false}
|
||||
animate={{opacity: 1}}
|
||||
exit={prefersReducedMotion ? {opacity: 1} : {opacity: 0}}
|
||||
transition={expandTransition}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.folder-indicator"
|
||||
>
|
||||
<motion.span
|
||||
className={styles.folderIndicatorBar}
|
||||
initial={false}
|
||||
animate={{opacity: 1, scale: 1, height: indicatorHeight}}
|
||||
transition={{duration: prefersReducedMotion ? 0 : 0.2, ease: [0.25, 0.1, 0.25, 1]}}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.folder-indicator-bar"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
<div
|
||||
className={styles.relative}
|
||||
<AnimePresence data-flx="app.sidebar-nav.guild-folder-item.anime-presence--2">
|
||||
{renderCollapsedFolderIndicator()}
|
||||
</AnimePresence>
|
||||
<flx-app-guild-folder-item-frame
|
||||
className={flxElementClassName(styles.relative)}
|
||||
ref={focusRingTargetRef}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.relative"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<div
|
||||
className={styles.folderHeaderButton}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.folder-header-button"
|
||||
>
|
||||
{renderCollapsedFolderIcon(folder.icon)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{showCollapsedIcon ? (
|
||||
<>
|
||||
<div
|
||||
className={styles.collapsedFolderBackground}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.collapsed-folder-background"
|
||||
/>
|
||||
<div
|
||||
className={styles.folderHeaderButton}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.folder-header-button--2"
|
||||
>
|
||||
{renderCollapsedFolderIcon(folder.icon)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={styles.collapsedFolderBackground}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.collapsed-folder-background--2"
|
||||
/>
|
||||
<div
|
||||
className={styles.collapsedFolder}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.collapsed-folder"
|
||||
>
|
||||
{firstFourGuilds.map((guild) => (
|
||||
<MiniGuildIcon
|
||||
key={guild.id}
|
||||
guild={guild}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{folderActivityType && (
|
||||
<VoiceBadge
|
||||
activity={folderActivityType}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.voice-badge"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx(styles.folderBadge, totalMentionCount > 0 && styles.folderBadgeActive)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.folder-badge"
|
||||
>
|
||||
<MentionBadgeAnimated
|
||||
mentionCount={totalMentionCount}
|
||||
size="small"
|
||||
data-flx="app.sidebar-nav.guild-folder-item.mention-badge-animated"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{renderExpandedFolderIcon()}
|
||||
{renderCollapsedFolder()}
|
||||
</flx-app-guild-folder-item-frame>
|
||||
</flx-app-guild-folder-item-toggle>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
<AnimatePresence initial={false} data-flx="app.sidebar-nav.guild-folder-item.animate-presence--3">
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
className={styles.expandedGuilds}
|
||||
initial={prefersReducedMotion ? false : {height: 0, opacity: 0}}
|
||||
animate={{height: 'auto', opacity: 1}}
|
||||
exit={prefersReducedMotion ? undefined : {height: 0, opacity: 0}}
|
||||
transition={expandTransition}
|
||||
style={{overflow: 'hidden'}}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.expanded-guilds"
|
||||
>
|
||||
{guilds.map((guild, index) => {
|
||||
const isGuildSelected = location.pathname.startsWith(Routes.guildChannel(guild.id));
|
||||
return (
|
||||
<GuildListItem
|
||||
key={guild.id}
|
||||
guild={guild}
|
||||
isSelected={isGuildSelected}
|
||||
guildIndex={guildNavigationIndexes.get(guild.id)}
|
||||
selectedGuildIndex={selectedGuildIndex}
|
||||
onGuildDrop={onGuildDrop}
|
||||
onDragStateChange={onDragStateChange}
|
||||
insideFolderId={folder.id}
|
||||
isLastInsideFolder={index === guilds.length - 1}
|
||||
isSortingList={isSortingList}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.guild-list-item"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<AnimePresence enterOnMount={false} data-flx="app.sidebar-nav.guild-folder-item.anime-presence--3">
|
||||
{renderExpandedGuilds()}
|
||||
</AnimePresence>
|
||||
</flx-app-guild-folder-item>
|
||||
);
|
||||
});
|
||||
|
||||
interface MiniGuildIconProps {
|
||||
guild: Guild;
|
||||
readonly guild: Guild;
|
||||
}
|
||||
|
||||
const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
|
||||
const iconUrl = AvatarUtils.getGuildIconURL(guild, false);
|
||||
const initials = StringUtils.getInitialsFromName(guild.name);
|
||||
const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false);
|
||||
const initials = getInitialsFromName(guild.name);
|
||||
const displayInitials = truncateInitials(initials, 2);
|
||||
if (iconUrl) {
|
||||
if (iconURL !== '') {
|
||||
return (
|
||||
<div
|
||||
className={styles.miniGuildIcon}
|
||||
style={{backgroundImage: `url(${iconUrl})`}}
|
||||
<flx-app-mini-guild-icon
|
||||
className={flxElementClassName(styles.miniGuildIcon)}
|
||||
style={{backgroundImage: `url(${iconURL})`}}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-icon"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.miniGuildIcon, styles.miniGuildIconWithInitials)}
|
||||
<flx-app-mini-guild-icon
|
||||
className={flxElementClassName(styles.miniGuildIcon, styles.miniGuildIconWithInitials)}
|
||||
data-flx="app.sidebar-nav.guild-folder-item.mini-guild-icon.mini-guild-icon--2"
|
||||
>
|
||||
<span
|
||||
@@ -644,6 +787,6 @@ const MiniGuildIcon = observer(({guild}: MiniGuildIconProps) => {
|
||||
>
|
||||
{displayInitials}
|
||||
</span>
|
||||
</div>
|
||||
</flx-app-mini-guild-icon>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {LongPressable} from '@app/features/app/components/LongPressable';
|
||||
import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css';
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import styles from '@app/features/app/components/layout/sidebar_nav/GuildListDMItem.module.css';
|
||||
import {resolveGuildListIndicatorBarTarget} from '@app/features/app/components/layout/sidebar_nav/GuildListIndicator';
|
||||
import {VoiceBadge, type VoiceBadgeActivity} from '@app/features/app/components/layout/sidebar_nav/VoiceBadge';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {
|
||||
type ChannelUnreadState,
|
||||
getChannelUnreadState,
|
||||
} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
|
||||
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
|
||||
import {useHover} from '@app/features/app/hooks/useHover';
|
||||
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
|
||||
import {DMBottomSheet} from '@app/features/channel/components/bottomsheets/DMBottomSheet';
|
||||
@@ -25,7 +29,6 @@ import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {isMobileExperienceEnabled} from '@app/features/ui/utils/MobileExperience';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
|
||||
@@ -36,7 +39,6 @@ import {
|
||||
sortVoiceParticipantItemsWithSnapshot,
|
||||
} from '@app/features/voice/components/VoiceParticipantSortUtils';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useVoiceGatewayStateVersion} from '@app/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter';
|
||||
import CallState from '@app/features/voice/state/CallState';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -66,6 +68,16 @@ const ACTIVE_CALL_DESCRIPTOR = msg({
|
||||
comment: 'Lowercase screen-reader fragment in the sidebar navigation guild list DM item.',
|
||||
});
|
||||
|
||||
export function resolveDMListItemUnreadState(channelId: string): ChannelUnreadState {
|
||||
return getChannelUnreadState({
|
||||
unreadCount: ReadStates.getUnreadCount(channelId),
|
||||
mentionCount: ReadStates.getMentionCount(channelId),
|
||||
isMuted: UserGuildSettings.isChannelMuted(null, channelId),
|
||||
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
}
|
||||
|
||||
interface DMListItemProps {
|
||||
channel: Channel;
|
||||
isSelected: boolean;
|
||||
@@ -103,29 +115,18 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
recipient,
|
||||
}: ResolvedDMListItemProps) {
|
||||
const {i18n} = useLingui();
|
||||
useVoiceGatewayStateVersion();
|
||||
const [hoverRef, isHovering] = useHover();
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const iconRef = useRef<HTMLDivElement | null>(null);
|
||||
const mergedButtonRef = useMergeRefs([hoverRef, buttonRef]);
|
||||
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
|
||||
const isMobileExperience = isMobileExperienceEnabled();
|
||||
const contextMenuOpen = useContextMenuHoverState(buttonRef, !isMobileExperience);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const {keyboardModeEnabled} = KeyboardMode;
|
||||
const isMuted = UserGuildSettings.isChannelMuted(null, channel.id);
|
||||
const mentionCount = ReadStates.getMentionCount(channel.id);
|
||||
const unreadState = getChannelUnreadState({
|
||||
unreadCount: ReadStates.getUnreadCount(channel.id),
|
||||
mentionCount,
|
||||
isMuted,
|
||||
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
|
||||
});
|
||||
const dmScrollSeverity: ScrollIndicatorSeverity | undefined = (() => {
|
||||
if (mentionCount > 0) return 'mention';
|
||||
if (unreadState.shouldShowUnreadIndicator) return 'unread';
|
||||
return undefined;
|
||||
})();
|
||||
const dmScrollId = `dm-${channel.id}`;
|
||||
const unreadState = resolveDMListItemUnreadState(channel.id);
|
||||
const directMessageName = recipient ? NicknameUtils.getNickname(recipient) : null;
|
||||
const computedDisplayName = ChannelUtils.getDMDisplayName(channel);
|
||||
const displayName = isGroupDM ? computedDisplayName : (directMessageName ?? computedDisplayName);
|
||||
@@ -270,12 +271,8 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
},
|
||||
[channel, isGroupDM, recipient, isMobileExperience],
|
||||
);
|
||||
const indicatorHeight =
|
||||
(() => {
|
||||
if (isSelected) return 40;
|
||||
if (isHovering) return 20;
|
||||
return 8;
|
||||
})() * getAppZoomFactor();
|
||||
const shouldShowHoverState = isHovering || contextMenuOpen;
|
||||
const indicatorTarget = resolveGuildListIndicatorBarTarget({isSelected, showHoverState: shouldShowHoverState});
|
||||
const tooltipContent = useMemo<string | (() => React.ReactNode)>(() => {
|
||||
if (!hasVoiceActivity) {
|
||||
return displayName;
|
||||
@@ -326,7 +323,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
</div>
|
||||
);
|
||||
}, [channel.id, displayName, hasVoiceActivity, voiceRows]);
|
||||
const showControls = isHovering || (keyboardModeEnabled && isFocused);
|
||||
const showControls = shouldShowHoverState || (keyboardModeEnabled && isFocused);
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
@@ -336,10 +333,13 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
data-flx="app.sidebar-nav.guild-list-dm-item.dm-list-item.tooltip"
|
||||
>
|
||||
<LongPressable
|
||||
className={clsx(guildStyles.dmListItem, className, isMuted && styles.muted)}
|
||||
className={clsx(
|
||||
guildStyles.dmListItem,
|
||||
className,
|
||||
contextMenuOpen && guildStyles.contextMenuHover,
|
||||
isMuted && styles.muted,
|
||||
)}
|
||||
onLongPress={handleLongPress}
|
||||
data-scroll-indicator={dmScrollSeverity}
|
||||
data-scroll-id={dmScrollId}
|
||||
data-flx="app.sidebar-nav.guild-list-dm-item.dm-list-item.muted"
|
||||
>
|
||||
<FocusRing
|
||||
@@ -372,12 +372,8 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
<motion.span
|
||||
className={guildStyles.guildIndicatorBar}
|
||||
initial={false}
|
||||
animate={{opacity: 1, scale: 1, height: indicatorHeight}}
|
||||
exit={
|
||||
Accessibility.useReducedMotion
|
||||
? {opacity: 1, scale: 1, height: indicatorHeight}
|
||||
: {opacity: 0, scale: 0, height: 0}
|
||||
}
|
||||
animate={indicatorTarget}
|
||||
exit={Accessibility.useReducedMotion ? indicatorTarget : {opacity: 0, height: 0}}
|
||||
transition={{duration: Accessibility.useReducedMotion ? 0 : 0.2, ease: [0.25, 0.1, 0.25, 1]}}
|
||||
data-flx="app.sidebar-nav.guild-list-dm-item.dm-list-item.span"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
|
||||
export interface ResolveGuildListIndicatorHeightRequest {
|
||||
readonly isSelected: boolean;
|
||||
readonly showHoverState: boolean;
|
||||
}
|
||||
|
||||
export type GuildListIndicatorBarTarget = {
|
||||
readonly opacity: number;
|
||||
readonly height: `${number}rem`;
|
||||
};
|
||||
|
||||
function resolveGuildListIndicatorHeight({isSelected, showHoverState}: ResolveGuildListIndicatorHeightRequest): number {
|
||||
if (isSelected) return 40;
|
||||
if (showHoverState) return 20;
|
||||
return 8;
|
||||
}
|
||||
|
||||
export function resolveGuildListIndicatorBarTarget(
|
||||
request: ResolveGuildListIndicatorHeightRequest,
|
||||
): GuildListIndicatorBarTarget {
|
||||
return {opacity: 1, height: remFromPx(resolveGuildListIndicatorHeight(request))};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user