Compare commits

...
35 changed files with 668 additions and 138 deletions

No files matched your search

@@ -33,7 +33,6 @@ import {
StartThreadFromMessageRequest,
StartThreadRequest,
ThreadChannelResponse,
ThreadLocationQuery,
ThreadMemberGetQuery,
ThreadMemberListResponse,
ThreadMembersListQuery,
@@ -389,7 +388,6 @@ export function ThreadController(app: HonoApp) {
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_PUT),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'join_thread',
summary: 'Join a thread',
@@ -415,7 +413,6 @@ export function ThreadController(app: HonoApp) {
RateLimitMiddleware(RateLimitConfigs.CHANNEL_THREAD_MEMBER_DELETE),
LoginRequired,
Validator('param', ChannelIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'leave_thread',
summary: 'Leave a thread',
@@ -474,7 +471,6 @@ export function ThreadController(app: HonoApp) {
LoginRequired,
SelfThreadMemberAlias('join'),
Validator('param', ChannelIdUserIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'add_thread_member',
summary: 'Add a thread member',
@@ -504,7 +500,6 @@ export function ThreadController(app: HonoApp) {
LoginRequired,
SelfThreadMemberAlias('leave'),
Validator('param', ChannelIdUserIdParam),
Validator('query', ThreadLocationQuery),
OpenAPI({
operationId: 'remove_thread_member',
summary: 'Remove a thread member',
+3 -34
View File
@@ -5850,13 +5850,6 @@
"required": true,
"schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the channel"
},
{
"name": "location",
"in": "query",
"required": false,
"schema": {"description": "Accepted and ignored", "type": "string", "maxLength": 100},
"description": "Accepted and ignored"
}
]
},
@@ -5915,13 +5908,6 @@
"required": true,
"schema": {"description": "The ID of the channel", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the channel"
},
{
"name": "location",
"in": "query",
"required": false,
"schema": {"description": "Accepted and ignored", "type": "string", "maxLength": 100},
"description": "Accepted and ignored"
}
]
}
@@ -6140,13 +6126,6 @@
"required": true,
"schema": {"description": "The ID of the user", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the user"
},
{
"name": "location",
"in": "query",
"required": false,
"schema": {"description": "Accepted and ignored", "type": "string", "maxLength": 100},
"description": "Accepted and ignored"
}
]
},
@@ -6212,13 +6191,6 @@
"required": true,
"schema": {"description": "The ID of the user", "$ref": "#/components/schemas/SnowflakeType"},
"description": "The ID of the user"
},
{
"name": "location",
"in": "query",
"required": false,
"schema": {"description": "Accepted and ignored", "type": "string", "maxLength": 100},
"description": "Accepted and ignored"
}
]
}
@@ -31490,8 +31462,7 @@
"maximum": 21600,
"description": "Seconds a user has to wait before sending another message (0-21600)",
"$ref": "#/components/schemas/Int32Type"
},
"location": {"description": "Accepted and ignored", "type": "string", "maxLength": 100}
}
},
"required": ["name"]
},
@@ -36252,8 +36223,7 @@
},
"description": "The first message of the post",
"$ref": "#/components/schemas/ForumThreadMessageRequest"
},
"location": {"description": "Accepted and ignored", "type": "string", "maxLength": 100}
}
},
"required": ["name", "message"],
"x-fluxer-experiment": "channel_threads"
@@ -36272,8 +36242,7 @@
"invitable": {
"description": "Whether non-moderators can add other non-moderators (private threads)",
"type": "boolean"
},
"location": {"description": "Accepted and ignored", "type": "string", "maxLength": 100}
}
},
"required": ["name", "type"]
},
@@ -8,6 +8,7 @@ import {applyPatchToRow, buildPatchFromData, executeVersionedUpdate} from '@app/
import type {UserRow} from '@app/api/database/types/UserTypes';
import {EMPTY_USER_ROW, USER_COLUMNS} from '@app/api/database/types/UserTypes';
import {emitAccountChangedIfRelevant} from '@app/api/infrastructure/activity/AccountChangeEvents';
import {getInstanceProductName} from '@app/api/instance/ProductName';
import {User} from '@app/api/models/User';
import {Users} from '@app/api/Tables';
import {shiftGiftExtensionPastPremiumUntil} from '@app/api/user/GiftExtensionShift';
@@ -67,7 +68,7 @@ export class UserDataRepository {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(FLUXER_BOT_USER_ID),
username: 'Fluxer',
username: getInstanceProductName(),
discriminator: 0,
bot: true,
system: true,
+34 -6
View File
@@ -31,6 +31,7 @@ interface TokenDef {
lightness?: number;
alpha?: number;
useSaturationFactor?: boolean;
instanceOverride?: string;
}
interface Config {
@@ -457,10 +458,28 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
{name: '--control-button-active-text', value: 'var(--text-primary)'},
{name: '--control-button-danger-text', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 60},
{name: '--control-button-danger-hover-bg', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 20},
{name: '--brand-primary', family: 'brand', lightness: 55},
{name: '--brand-secondary', family: 'brand', saturation: 60, lightness: 49},
{name: '--brand-primary-light', family: 'brand', saturation: 100, lightness: 84},
{name: '--brand-primary-fill', hue: 0, saturation: 0, lightness: 100},
{name: '--brand-primary', family: 'brand', lightness: 55, instanceOverride: '--instance-brand-primary'},
{
name: '--brand-secondary',
family: 'brand',
saturation: 60,
lightness: 49,
instanceOverride: '--instance-brand-secondary',
},
{
name: '--brand-primary-light',
family: 'brand',
saturation: 100,
lightness: 84,
instanceOverride: '--instance-brand-primary-light',
},
{
name: '--brand-primary-fill',
hue: 0,
saturation: 0,
lightness: 100,
instanceOverride: '--instance-brand-primary-fill',
},
{name: '--status-online', family: 'statusOnline', lightness: 40},
{name: '--status-idle', family: 'statusIdle', lightness: 50},
{name: '--status-dnd', family: 'statusDnd', lightness: 60},
@@ -474,7 +493,13 @@ hsl(258, calc(10% * var(--saturation-factor)), 2%) 10%
{name: '--plutonium-icon', family: 'brandIcon', lightness: 50},
{name: '--invite-verified-icon-color', value: 'var(--text-on-brand-primary)'},
{name: '--text-link', family: 'link', lightness: 70},
{name: '--text-on-brand-primary', hue: 0, saturation: 0, lightness: 98},
{
name: '--text-on-brand-primary',
hue: 0,
saturation: 0,
lightness: 98,
instanceOverride: '--instance-text-on-brand-primary',
},
...DARK_CODE_TOKENS,
{name: '--text-selection', hue: 198, saturation: 92, useSaturationFactor: true, lightness: 70, alpha: 0.35},
{name: '--markup-mention-text', value: 'var(--text-link)'},
@@ -792,6 +817,7 @@ interface OutputToken {
alpha?: number;
useSaturationFactor?: boolean;
value?: string;
instanceOverride?: string;
}
function clamp01(value: number): number {
@@ -866,6 +892,7 @@ function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Arr
lightness: def.lightness,
alpha: def.alpha,
useSaturationFactor: def.useSaturationFactor,
instanceOverride: def.instanceOverride,
});
}
}
@@ -919,7 +946,8 @@ function formatTone(token: OutputToken, families: Record<string, ColorFamily>):
function formatValue(token: OutputToken, families: Record<string, ColorFamily>): string {
if (token.type === 'tone') {
return formatTone(token, families);
const tone = formatTone(token, families);
return token.instanceOverride ? `var(${token.instanceOverride}, ${tone})` : tone;
}
return token.value!.trim();
}
+4
View File
@@ -20,6 +20,7 @@ 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 {type LayoutVariant, LayoutVariantProvider} from '@app/features/app/state/LayoutVariantContext';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import RuntimeCrash from '@app/features/app/state/RuntimeCrash';
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
@@ -190,6 +191,9 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
messageGutter: Accessibility.messageGutter,
messageGroupSpacing: Accessibility.getMessageGroupSpacingValue(messageDisplayCompact),
hdrDisplayMode: Accessibility.hdrDisplayMode,
instanceThemeColor: RuntimeConfig.isSelfHosted()
? (RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.theme_color ?? null)
: null,
});
useCustomThemeStyle({
enabledThemeCss: ThemeLibrary.activeThemeCss,
@@ -7,6 +7,7 @@ import styles from '@app/features/channel/components/MemberListItem.module.css';
import {PreloadableUserPopout} from '@app/features/channel/components/PreloadableUserPopout';
import Guilds from '@app/features/guild/state/Guilds';
import {useMemberListCustomStatus} from '@app/features/member/hooks/useMemberListCustomStatus';
import {useMemberListMobile} from '@app/features/member/hooks/useMemberListMobile';
import {useMemberListPresence} from '@app/features/member/hooks/useMemberListPresence';
import type {GuildMember} from '@app/features/member/models/GuildMember';
import TypingIndicator from '@app/features/typing/state/TypingIndicator';
@@ -51,6 +52,7 @@ interface MemberListItemProps {
guildMember?: GuildMember;
status?: StatusType;
customStatus?: CustomStatus | null;
isMobile?: boolean;
isOwner?: boolean;
roleColor?: string;
displayName?: string;
@@ -67,6 +69,7 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
guildMember,
status: providedStatus,
customStatus: providedCustomStatus,
isMobile: providedIsMobile,
isOwner = false,
roleColor,
displayName,
@@ -91,6 +94,13 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const contextMenuTicketRef = useRef(0);
const isCurrentUser = user.id === Authentication.currentUserId;
const hookIsMobile = useMemberListMobile({
guildId: guildId ?? '',
channelId,
userId: user.id,
enabled: guildId !== undefined && providedIsMobile === undefined && !isCurrentUser,
});
const isMobile = isCurrentUser ? undefined : (providedIsMobile ?? hookIsMobile);
const isTyping = TypingIndicator.isMemberListTyping(channelId, user.id, Authentication.currentUserId);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
@@ -205,6 +215,7 @@ export const MemberListItem: React.FC<MemberListItemProps> = observer((props) =>
showOffline={isCurrentUser || isTyping}
guildId={guildId}
status={status}
isMobile={isMobile}
avatarUrl={memberAvatarUrl}
hoverAvatarUrl={memberHoverAvatarUrl}
mediaSize={avatarMediaSize}
@@ -11,6 +11,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
import type {Guild} from '@app/features/guild/models/Guild';
import {OFFLINE_DESCRIPTOR, ONLINE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {resolveMemberListCustomStatus} from '@app/features/member/hooks/useMemberListCustomStatus';
import {resolveMemberListMobile} from '@app/features/member/hooks/useMemberListMobile';
import {resolveMemberListPresence} from '@app/features/member/hooks/useMemberListPresence';
import {useMemberListSubscription} from '@app/features/member/hooks/useMemberListSubscription';
import type {GuildMember} from '@app/features/member/models/GuildMember';
@@ -118,6 +119,9 @@ const MobileMemberListItem = observer(
const {i18n} = useLingui();
const isTyping = TypingIndicator.isMemberListTyping(channelId, member.user.id, Authentication.currentUserId);
const status = resolveMemberListPresence({guildId: guild.id, channelId, userId: member.user.id});
const isMobile = member.isCurrentUser()
? undefined
: resolveMemberListMobile({guildId: guild.id, channelId, userId: member.user.id});
const memberListCustomStatus = resolveMemberListCustomStatus({
guildId: guild.id,
channelId,
@@ -175,6 +179,7 @@ const MobileMemberListItem = observer(
showOffline={member.user.id === Authentication.currentUserId || isTyping}
guildId={guild.id}
status={status}
isMobile={isMobile}
avatarUrl={avatarUrl}
hoverAvatarUrl={hoverAvatarUrl}
mediaSize={MEDIA_PROXY_AVATAR_SIZE_DEFAULT}
@@ -27,6 +27,7 @@ import {UserProfileBadges} from '@app/features/user/components/popouts/UserProfi
import type {Profile} from '@app/features/user/models/Profile';
import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import {msg, ph, plural} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
@@ -96,7 +97,9 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
return null;
}
const displayName = NicknameUtils.getNickname(user, null, channel?.id);
const fluxerTag = NicknameUtils.formatTagForStreamerMode(user.tag);
const fluxerTag = NicknameUtils.formatTagForStreamerMode(
user.id === FLUXERBOT_ID ? `${displayName}#${user.discriminator}` : user.tag,
);
const handleSendFriendRequest = () => {
RelationshipActionUtils.sendFriendRequest(i18n, user.id);
};
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import MemberSidebar from '@app/features/member/state/MemberSidebar';
import {reaction} from 'mobx';
import {useEffect, useState} from 'react';
interface UseMemberListMobileOptions {
guildId: string;
channelId: string;
userId: string;
enabled?: boolean;
}
export function resolveMemberListMobile({
guildId,
channelId,
userId,
enabled = true,
}: UseMemberListMobileOptions): boolean | undefined {
if (!enabled) return undefined;
return MemberSidebar.isMobile(guildId, channelId, userId) ?? undefined;
}
export function useMemberListMobile({
guildId,
channelId,
userId,
enabled = true,
}: UseMemberListMobileOptions): boolean | undefined {
const [isMobile, setIsMobile] = useState(() => resolveMemberListMobile({guildId, channelId, userId, enabled}));
useEffect(() => {
return reaction(() => resolveMemberListMobile({guildId, channelId, userId, enabled}), setIsMobile, {
fireImmediately: true,
});
}, [guildId, channelId, userId, enabled]);
return isMobile;
}
@@ -61,6 +61,25 @@ export class GuildMember {
this.mentionFlags = (guildMember.mention_flags ?? 0) as MentionReplyPreference;
}
matches(guildMember: GuildMemberData): boolean {
const until = guildMember.communication_disabled_until
? new Date(guildMember.communication_disabled_until).getTime()
: null;
return (
this.nick === (guildMember.nick ?? null) &&
this.avatar === (guildMember.avatar ?? null) &&
this.banner === (guildMember.banner ?? null) &&
this.accentColor === (guildMember.accent_color ?? null) &&
this.mute === (guildMember.mute ?? false) &&
this.deaf === (guildMember.deaf ?? false) &&
this.profileFlags === (guildMember.profile_flags ?? 0) &&
this.mentionFlags === (guildMember.mention_flags ?? 0) &&
(this.communicationDisabledUntil?.getTime() ?? null) === until &&
this.roles.size === guildMember.roles.length &&
guildMember.roles.every((roleId) => this.roles.has(roleId))
);
}
isAvatarUnset(): boolean {
return (this.profileFlags & GuildMemberProfileFlags.AVATAR_UNSET) !== 0;
}
@@ -7,6 +7,7 @@ import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
import {Logger} from '@app/features/platform/utils/AppLogger';
import Users from '@app/features/user/state/Users';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {User as WireUser} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {makeAutoObservable} from 'mobx';
type Members = Record<string, GuildMember>;
@@ -215,6 +216,19 @@ class GuildMembers {
this.nonMembers[guildId]?.delete(member.user.id);
}
hydrateOrRefresh(guildId: string, member: GuildMemberData): void {
const existing = this.members[guildId]?.[member.user.id];
if (!existing) {
this.hydrateIfMissing(guildId, member);
return;
}
Users.handleUserUpdate(member.user as WireUser);
if (existing.user === Users.getUser(member.user.id) && existing.matches(member)) {
return;
}
this.members[guildId][member.user.id] = new GuildMember(guildId, member);
}
handleMemberRemove(guildId: string, userId: string): void {
this.markAsNonMember(guildId, userId);
const existingMembers = this.members[guildId];
@@ -58,6 +58,7 @@ interface MemberListState {
requestedRanges: NormalizedMemberListRanges;
subscribedRanges: NormalizedMemberListRanges;
presences: Map<string, StatusType>;
mobileUserIds: Set<string>;
customStatuses: Map<string, CustomStatus | null>;
knownCustomStatuses: Map<string, CustomStatus | null>;
}
@@ -69,6 +70,7 @@ interface MemberListRow {
member?: GuildMemberData;
presence?: {
status?: string;
mobile?: boolean;
custom_status?: GatewayCustomStatusPayload | null;
} | null;
}
@@ -76,6 +78,7 @@ interface MemberListRow {
type MemberListOperationMember = GuildMemberData & {
presence?: {
status?: string;
mobile?: boolean;
custom_status?: GatewayCustomStatusPayload | null;
} | null;
};
@@ -655,6 +658,7 @@ class MemberSidebar {
const newRows = new Map<number, MemberListRow>();
const newItems = new Map<number, MemberListItem>();
const newPresences = new Map<string, StatusType>();
const newMobileUserIds = new Set<string>();
const newCustomStatuses = new Map<string, CustomStatus | null>();
const newMembersByUserId = new Map<string, GuildMemberData>();
const nextKnownCustomStatuses = new Map(listState.knownCustomStatuses);
@@ -704,6 +708,9 @@ class MemberSidebar {
const presenceStatus = this.extractPresenceFromRow(row);
if (presenceStatus) {
newPresences.set(userId, presenceStatus);
if (presenceStatus !== StatusTypes.OFFLINE && row.presence?.mobile === true) {
newMobileUserIds.add(userId);
}
}
if (row.presence && Object.hasOwn(row.presence, 'custom_status')) {
const customStatus = fromGatewayCustomStatus(row.presence.custom_status ?? null);
@@ -757,6 +764,7 @@ class MemberSidebar {
listState.items = newItems;
listState.membersByUserId = newMembersByUserId;
listState.presences = newPresences;
listState.mobileUserIds = newMobileUserIds;
listState.customStatuses = newCustomStatuses;
listState.knownCustomStatuses = nextKnownCustomStatuses;
listState.subscribedRanges = getHydratedMemberListRangesFromNormalized(
@@ -1183,6 +1191,14 @@ class MemberSidebar {
return listState.presences.get(userId) ?? null;
}
isMobile(guildId: string, listId: string, userId: string): boolean | null {
const listState = this.getList(guildId, listId);
if (!listState?.presences.has(userId)) {
return null;
}
return listState.mobileUserIds.has(userId);
}
getCustomStatus(guildId: string, listId: string, userId: string): CustomStatus | null | undefined {
const listState = this.getList(guildId, listId);
if (!listState) {
@@ -1262,6 +1278,7 @@ class MemberSidebar {
items: new Map(),
membersByUserId: new Map(),
presences: new Map(),
mobileUserIds: new Set(),
customStatuses: new Map(),
};
}
@@ -1274,10 +1291,14 @@ class MemberSidebar {
prunedMembersByUserId.set(item.data.userId, item.data.member);
}
const prunedPresences = new Map<string, StatusType>();
const prunedMobileUserIds = new Set<string>();
const prunedCustomStatuses = new Map<string, CustomStatus | null>();
for (const [userId, status] of listState.presences) {
if (retainedUserIds.has(userId)) {
prunedPresences.set(userId, status);
if (listState.mobileUserIds.has(userId)) {
prunedMobileUserIds.add(userId);
}
}
}
for (const [userId, customStatus] of listState.customStatuses) {
@@ -1302,6 +1323,7 @@ class MemberSidebar {
items: prunedItems,
membersByUserId: prunedMembersByUserId,
presences: prunedPresences,
mobileUserIds: prunedMobileUserIds,
customStatuses: prunedCustomStatuses,
};
}
@@ -1350,6 +1372,7 @@ class MemberSidebar {
requestedRanges: normalizeMemberListRanges(requestedRanges),
subscribedRanges: EMPTY_MEMBER_LIST_RANGES,
presences: new Map(),
mobileUserIds: new Set(),
customStatuses: new Map(),
knownCustomStatuses: new Map(),
};
@@ -55,6 +55,7 @@ function ThemedApp({
messageGutter: 16,
messageGroupSpacing: groupSpacing,
hdrDisplayMode: HdrDisplayMode.FULL,
instanceThemeColor: null,
});
return (
<MeasuringChild
@@ -2,6 +2,7 @@
import {HdrDisplayMode} from '@app/features/accessibility/state/Accessibility';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {getInstanceBrandVariables, INSTANCE_BRAND_VARIABLES} from '@app/features/theme/utils/InstanceBrandColors';
import {useLayoutEffect} from 'react';
interface ThemeCssVariablesOptions {
@@ -14,6 +15,7 @@ interface ThemeCssVariablesOptions {
messageGutter: number;
messageGroupSpacing: number;
hdrDisplayMode: HdrDisplayMode;
instanceThemeColor: string | null;
}
export function useThemeCssVariables({
@@ -26,6 +28,7 @@ export function useThemeCssVariables({
messageGutter,
messageGroupSpacing,
hdrDisplayMode,
instanceThemeColor,
}: ThemeCssVariablesOptions): void {
useLayoutEffect(() => {
const htmlNode = document.documentElement;
@@ -63,6 +66,20 @@ export function useThemeCssVariables({
messageGroupSpacing,
hdrDisplayMode,
]);
useLayoutEffect(() => {
if (instanceThemeColor === null) return;
const variables = getInstanceBrandVariables(instanceThemeColor);
if (variables === null) return;
const htmlNode = document.documentElement;
for (const name of INSTANCE_BRAND_VARIABLES) {
htmlNode.style.setProperty(name, variables[name]);
}
return () => {
for (const name of INSTANCE_BRAND_VARIABLES) {
htmlNode.style.removeProperty(name);
}
};
}, [instanceThemeColor]);
useLayoutEffect(() => {
const htmlNode = document.documentElement;
return () => {
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const INSTANCE_BRAND_VARIABLES = [
'--instance-brand-primary',
'--instance-brand-secondary',
'--instance-brand-primary-light',
'--instance-brand-primary-fill',
'--instance-text-on-brand-primary',
] as const;
type InstanceBrandVariable = (typeof INSTANCE_BRAND_VARIABLES)[number];
const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
function parseHexColor(value: string): [number, number, number] | null {
const trimmed = value.trim();
if (!HEX_COLOR_PATTERN.test(trimmed)) return null;
const digits =
trimmed.length === 4
? trimmed
.slice(1)
.split('')
.map((digit) => digit + digit)
.join('')
: trimmed.slice(1);
return [
Number.parseInt(digits.slice(0, 2), 16),
Number.parseInt(digits.slice(2, 4), 16),
Number.parseInt(digits.slice(4, 6), 16),
];
}
function rgbToHsl(red: number, green: number, blue: number): [number, number, number] {
const r = red / 255;
const g = green / 255;
const b = blue / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const lightness = (max + min) / 2;
const delta = max - min;
if (delta === 0) return [0, 0, lightness * 100];
const saturation = delta / (1 - Math.abs(2 * lightness - 1));
let hue: number;
if (max === r) {
hue = ((g - b) / delta) % 6;
} else if (max === g) {
hue = (b - r) / delta + 2;
} else {
hue = (r - g) / delta + 4;
}
return [(hue * 60 + 360) % 360, saturation * 100, lightness * 100];
}
function relativeLuminance(red: number, green: number, blue: number): number {
const linear = (channel: number) => {
const value = channel / 255;
return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue);
}
function formatNumber(value: number): string {
return String(Math.round(value * 100) / 100);
}
function hsl(hue: number, saturation: number, lightness: number): string {
return `hsl(${formatNumber(hue)}, calc(${formatNumber(saturation)}% * var(--saturation-factor)), ${formatNumber(lightness)}%)`;
}
export function getInstanceBrandVariables(themeColor: string): Record<InstanceBrandVariable, string> | null {
const rgb = parseHexColor(themeColor);
if (rgb === null) return null;
const [hue, saturation, lightness] = rgbToHsl(...rgb);
const luminance = relativeLuminance(...rgb);
const prefersDarkText = (luminance + 0.05) / 0.05 > 1.05 / (luminance + 0.05);
return {
'--instance-brand-primary': hsl(hue, saturation, lightness),
'--instance-brand-secondary': hsl(hue, saturation * (6 / 7), lightness * 0.89),
'--instance-brand-primary-light': hsl(hue, saturation, lightness + (100 - lightness) * 0.65),
'--instance-brand-primary-fill': prefersDarkText ? 'hsl(0, 0%, 8%)' : 'hsl(0, 0%, 100%)',
'--instance-text-on-brand-primary': prefersDarkText ? 'hsl(0, 0%, 8%)' : 'hsl(0, 0%, 98%)',
};
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {OutlineFrame} from '@app/features/app/components/layout/OutlineFrame';
import Authentication from '@app/features/auth/state/Authentication';
import styles from '@app/features/channel/components/ChannelMembers.module.css';
import {MemberListContainer} from '@app/features/channel/components/MemberListContainer';
import {MemberListItem} from '@app/features/channel/components/MemberListItem';
@@ -10,11 +11,13 @@ import Guilds from '@app/features/guild/state/Guilds';
import {OFFLINE_DESCRIPTOR, ONLINE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
import GuildMembers from '@app/features/member/state/GuildMembers';
import LocalPresence from '@app/features/presence/state/LocalPresence';
import ThreadRoster, {type ThreadRosterMember} from '@app/features/threads/state/ThreadRoster';
import type {User} from '@app/features/user/models/User';
import type {CustomStatus} from '@app/features/user/state/CustomStatus';
import Users from '@app/features/user/state/Users';
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {isOfflineStatus, type StatusType} from '@fluxer/constants/src/StatusConstants';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useEffect} from 'react';
@@ -28,10 +31,17 @@ export function useThreadRosterSubscription(thread: Channel | undefined): void {
}, [guildId, threadId]);
}
interface RosterRow {
user: User;
status: StatusType;
mobile?: boolean;
customStatus: CustomStatus | null;
}
interface RosterGroupProps {
id: string;
label: string;
members: ReadonlyArray<{user: User; online: boolean}>;
members: ReadonlyArray<RosterRow>;
thread: Channel;
ownerId: string | null;
}
@@ -57,14 +67,16 @@ const RosterGroup = observer(({id, label, members, thread, ownerId}: RosterGroup
</span>
</div>
<div className={styles.membersList} data-flx="threads.thread-members-panel.roster-group.members-list">
{members.map(({user, online}) => (
{members.map(({user, status, mobile, customStatus}) => (
<MemberListItem
key={user.id}
user={user}
channelId={thread.id}
guildId={thread.guildId}
guildMember={thread.guildId ? (GuildMembers.getMember(thread.guildId, user.id) ?? undefined) : undefined}
status={online ? undefined : StatusTypes.OFFLINE}
status={status}
isMobile={mobile}
customStatus={customStatus}
isOwner={user.id === ownerId}
disableBackdrop={true}
data-flx="threads.thread-members-panel.roster-group.member-list-item"
@@ -76,14 +88,17 @@ const RosterGroup = observer(({id, label, members, thread, ownerId}: RosterGroup
);
});
function resolveMembers(
guildId: string | undefined,
roster: ReadonlyArray<ThreadRosterMember>,
): Array<{user: User; online: boolean}> {
const members: Array<{user: User; online: boolean}> = [];
function resolveMembers(guildId: string | undefined, roster: ReadonlyArray<ThreadRosterMember>): Array<RosterRow> {
const members: Array<RosterRow> = [];
for (const entry of roster) {
const user = Users.getUser(entry.userId);
if (user) members.push({user, online: entry.online});
if (!user) continue;
if (user.id === Authentication.currentUserId) {
const status = LocalPresence.getStatus();
members.push({user, status, customStatus: isOfflineStatus(status) ? null : LocalPresence.customStatus});
} else {
members.push({user, status: entry.status, mobile: entry.mobile, customStatus: entry.customStatus});
}
}
members.sort((a, b) =>
NicknameUtils.getNickname(a.user, guildId).localeCompare(NicknameUtils.getNickname(b.user, guildId)),
@@ -110,7 +125,7 @@ export const ThreadMembersList = observer(({thread}: {thread: Channel}) => {
<RosterGroup
id="online"
label={i18n._(ONLINE_DESCRIPTOR)}
members={members.filter((member) => member.online)}
members={members.filter((member) => !isOfflineStatus(member.status))}
thread={thread}
ownerId={ownerId}
data-flx="threads.thread-members-panel.thread-members-list.roster-group.online"
@@ -118,7 +133,7 @@ export const ThreadMembersList = observer(({thread}: {thread: Channel}) => {
<RosterGroup
id="offline"
label={i18n._(OFFLINE_DESCRIPTOR)}
members={members.filter((member) => !member.online)}
members={members.filter((member) => isOfflineStatus(member.status))}
thread={thread}
ownerId={ownerId}
data-flx="threads.thread-members-panel.thread-members-list.roster-group.offline"
@@ -18,6 +18,10 @@ const selectedGuild = observable({selectedGuildId: null as string | null});
const updateGuildSubscriptions = vi.fn();
const gateway = observable({isReady: false, socket: {isConnected: () => true, updateGuildSubscriptions}});
vi.mock('@lingui/core/macro', () => ({
msg: (descriptor: unknown) => descriptor,
t: (descriptor: unknown) => descriptor,
}));
vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}}));
vi.mock('@app/features/user/state/Users', () => ({
default: {getUser: () => undefined, cacheUsers: () => {}, getCurrentUser: () => ({id: 'me', mfaEnabled: true})},
@@ -49,7 +53,7 @@ vi.mock('@app/features/ui/commands/ContextMenuCommands', () => ({close: vi.fn()}
vi.mock('@app/features/ui/commands/PopoutCommands', () => ({closeAll: vi.fn()}));
vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => ({ownerId: 'owner', mfaLevel: 0})}}));
vi.mock('@app/features/member/state/GuildMembers', () => ({
default: {getMember: () => null, hydrateIfMissing: () => {}},
default: {getMember: () => null, hydrateOrRefresh: () => {}},
}));
vi.mock('@app/features/permissions/state/Permission', () => ({
default: {
@@ -416,12 +420,13 @@ describe('control arm', () => {
expect(vi.mocked(http.get)).not.toHaveBeenCalled();
expect(vi.mocked(http.post)).not.toHaveBeenCalled();
ThreadGuilds.handleGuild(guildObject([]));
await Promise.resolve();
expect(updateGuildSubscriptions).toHaveBeenCalledWith({
subscriptions: {[GUILD]: {threads: true, thread_member_lists: []}},
});
});
it('sends thread member lists only for subscribed threads of the active guild', () => {
it('sends thread member lists only for subscribed threads of the active guild', async () => {
ThreadGuilds.handleGuild(guildObject([]));
runInAction(() => {
selectedGuild.selectedGuildId = GUILD;
@@ -429,10 +434,59 @@ describe('control arm', () => {
});
ThreadSubscriptions.handleConnectionReady();
const release = ThreadRoster.subscribe(GUILD, THREAD_A);
await Promise.resolve();
expect(updateGuildSubscriptions).toHaveBeenLastCalledWith({
subscriptions: {[GUILD]: {threads: true, thread_member_lists: [THREAD_A]}},
});
release();
await Promise.resolve();
});
it('switches thread member lists in a single request', async () => {
ThreadGuilds.handleGuild(guildObject([]));
runInAction(() => {
selectedGuild.selectedGuildId = GUILD;
gateway.isReady = true;
});
ThreadSubscriptions.handleConnectionReady();
const releaseA = ThreadRoster.subscribe(GUILD, THREAD_A);
await Promise.resolve();
updateGuildSubscriptions.mockClear();
releaseA();
const releaseB = ThreadRoster.subscribe(GUILD, THREAD_B);
await Promise.resolve();
expect(updateGuildSubscriptions).toHaveBeenCalledTimes(1);
expect(updateGuildSubscriptions).toHaveBeenCalledWith({
subscriptions: {[GUILD]: {threads: true, thread_member_lists: [THREAD_B]}},
});
releaseB();
await Promise.resolve();
});
it('clears the previous guild thread member lists when the selected guild changes', async () => {
const otherGuild = '1500000000000000099';
ThreadGuilds.handleGuild(guildObject([]));
ThreadGuilds.handleGuild({...guildObject([]), id: otherGuild});
runInAction(() => {
selectedGuild.selectedGuildId = GUILD;
gateway.isReady = true;
});
ThreadSubscriptions.handleConnectionReady();
const release = ThreadRoster.subscribe(GUILD, THREAD_A);
await Promise.resolve();
updateGuildSubscriptions.mockClear();
runInAction(() => {
selectedGuild.selectedGuildId = otherGuild;
});
await Promise.resolve();
expect(updateGuildSubscriptions).toHaveBeenCalledWith({
subscriptions: {
[GUILD]: {thread_member_lists: []},
[otherGuild]: {threads: true, thread_member_lists: []},
},
});
release();
await Promise.resolve();
});
it('serializes a control channel without any new keys', () => {
@@ -509,6 +563,102 @@ describe('control arm', () => {
expect(isSyncExcludedChannelId(PARENT)).toBe(false);
expect(isSyncExcludedChannelId('1500000000000000999')).toBe(false);
});
it('clears the previous guild thread member lists after a guild switch during a reconnect', async () => {
const otherGuild = '1500000000000000099';
ThreadGuilds.handleGuild(guildObject([]));
ThreadGuilds.handleGuild({...guildObject([]), id: otherGuild});
runInAction(() => {
selectedGuild.selectedGuildId = GUILD;
gateway.isReady = true;
});
ThreadSubscriptions.handleConnectionReady();
const release = ThreadRoster.subscribe(GUILD, THREAD_A);
await Promise.resolve();
runInAction(() => {
gateway.isReady = false;
});
release();
runInAction(() => {
selectedGuild.selectedGuildId = otherGuild;
});
await Promise.resolve();
updateGuildSubscriptions.mockClear();
runInAction(() => {
gateway.isReady = true;
});
ThreadSubscriptions.handleConnectionReady();
await Promise.resolve();
expect(updateGuildSubscriptions).toHaveBeenCalledTimes(1);
expect(updateGuildSubscriptions).toHaveBeenCalledWith({
subscriptions: {
[GUILD]: {thread_member_lists: []},
[otherGuild]: {threads: true, thread_member_lists: []},
},
});
});
});
describe('thread roster', () => {
const ONLINE = '1600000000000000001';
const IDLE = '1600000000000000002';
const OFFLINE = '1600000000000000003';
function listUpdate(members: Array<{user_id: string; presence: Record<string, unknown>}>) {
return {
guild_id: GUILD,
thread_id: THREAD_A,
members: members.map((entry) => ({...entry, join_timestamp: null, flags: 0, member: null})),
};
}
it('keeps each member status and custom status from the list update', () => {
const release = ThreadRoster.subscribe(GUILD, THREAD_A);
ThreadRoster.handleListUpdate(
listUpdate([
{user_id: ONLINE, presence: {status: 'online', afk: false, mobile: true, custom_status: null}},
{
user_id: IDLE,
presence: {
status: 'idle',
afk: true,
mobile: false,
custom_status: {emoji_id: '1471401583408697449', emoji_name: 'nahua_smug', text: 'im spooky goop'},
},
},
{user_id: OFFLINE, presence: {status: 'offline', afk: false, mobile: false}},
]),
);
expect(ThreadRoster.getMembers(THREAD_A)).toEqual([
{userId: ONLINE, joinTimestamp: null, status: 'online', mobile: true, customStatus: null},
{
userId: IDLE,
joinTimestamp: null,
status: 'idle',
mobile: false,
customStatus: expect.objectContaining({text: 'im spooky goop', emojiName: 'nahua_smug'}),
},
{userId: OFFLINE, joinTimestamp: null, status: 'offline', mobile: false, customStatus: null},
]);
release();
});
it('replaces the roster on every list update and ignores updates after release', () => {
const release = ThreadRoster.subscribe(GUILD, THREAD_A);
ThreadRoster.handleListUpdate(
listUpdate([
{user_id: ONLINE, presence: {status: 'online'}},
{user_id: IDLE, presence: {status: 'idle'}},
]),
);
ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'dnd'}}]));
expect(ThreadRoster.getMembers(THREAD_A)).toEqual([
{userId: ONLINE, joinTimestamp: null, status: 'dnd', mobile: false, customStatus: null},
]);
release();
ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'online'}}]));
expect(ThreadRoster.getMembers(THREAD_A)).toBeUndefined();
});
});
describe('control arm message grouping', () => {
@@ -1,7 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import GuildMembers from '@app/features/member/state/GuildMembers';
import {type StatusType, StatusTypes} from '@fluxer/constants/src/StatusConstants';
import {
type CustomStatus,
fromGatewayCustomStatus,
type GatewayCustomStatusPayload,
} from '@app/features/user/state/CustomStatus';
import {isOfflineStatus, normalizeStatus, type StatusType} from '@fluxer/constants/src/StatusConstants';
import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import {makeAutoObservable, observable} from 'mobx';
@@ -15,20 +20,20 @@ export interface ThreadMemberListPayload {
join_timestamp: string | null;
flags: number;
member: GuildMemberData | null;
presence?: {status?: string} | null;
presence?: {
status?: string | null;
mobile?: boolean;
custom_status?: GatewayCustomStatusPayload | null;
} | null;
}>;
}
export interface ThreadRosterMember {
readonly userId: string;
readonly joinTimestamp: string | null;
readonly online: boolean;
}
const OFFLINE_STATUSES = new Set<string>([StatusTypes.OFFLINE, StatusTypes.INVISIBLE]);
function isOnline(status: string | undefined): boolean {
return status != null && !OFFLINE_STATUSES.has(status as StatusType);
readonly status: StatusType;
readonly mobile: boolean;
readonly customStatus: CustomStatus | null;
}
class ThreadRoster {
@@ -78,12 +83,15 @@ class ThreadRoster {
const members: Array<ThreadRosterMember> = [];
for (const entry of payload.members) {
if (entry.member) {
GuildMembers.hydrateIfMissing(payload.guild_id, entry.member);
GuildMembers.hydrateOrRefresh(payload.guild_id, entry.member);
}
const status = normalizeStatus(entry.presence?.status);
members.push({
userId: entry.user_id,
joinTimestamp: entry.join_timestamp,
online: isOnline(entry.presence?.status),
status,
mobile: !isOfflineStatus(status) && entry.presence?.mobile === true,
customStatus: isOfflineStatus(status) ? null : fromGatewayCustomStatus(entry.presence?.custom_status),
});
}
this.rosters.set(payload.thread_id, members);
@@ -25,9 +25,14 @@ class ThreadSubscriptions {
armed = false;
private sent = new Map<string, SentState>();
private lastGuildId: string | null = null;
private listGuilds = new Set<string>();
constructor() {
makeAutoObservable<this, 'sent' | 'lastGuildId'>(this, {sent: false, lastGuildId: false}, {autoBind: true});
makeAutoObservable<this, 'sent' | 'lastGuildId' | 'listGuilds'>(
this,
{sent: false, lastGuildId: false, listGuilds: false},
{autoBind: true},
);
deferUntilModulesLoaded(() => {
reaction(
(): Desired => {
@@ -42,7 +47,7 @@ class ThreadSubscriptions {
};
},
(desired) => this.apply(desired),
{equals: compareStructural, fireImmediately: true},
{equals: compareStructural, fireImmediately: true, scheduler: (run) => queueMicrotask(run)},
);
reaction(
() => GatewayConnection.isReady,
@@ -74,21 +79,29 @@ class ThreadSubscriptions {
if (this.lastGuildId) this.sent.delete(this.lastGuildId);
this.lastGuildId = desired.guildId;
}
if (!desired.ready || !desired.guildId || !desired.active) return;
if (!desired.ready) return;
const guildId = desired.active ? desired.guildId : null;
const subscriptions: Record<string, {threads?: boolean; thread_member_lists: Array<string>}> = {};
for (const listGuildId of this.listGuilds) {
if (listGuildId !== guildId) subscriptions[listGuildId] = {thread_member_lists: []};
}
const memberLists = desired.memberLists.join(',');
const previous = this.sent.get(desired.guildId);
if (previous?.threads && previous.memberLists === memberLists) return;
const previous = guildId ? this.sent.get(guildId) : undefined;
if (guildId && !(previous?.threads && previous.memberLists === memberLists)) {
subscriptions[guildId] = {threads: true, thread_member_lists: [...desired.memberLists]};
}
if (Object.keys(subscriptions).length === 0) return;
const socket = GatewayConnection.socket;
if (!socket?.isConnected()) return;
socket.updateGuildSubscriptions({
subscriptions: {
[desired.guildId]: {
threads: true,
thread_member_lists: [...desired.memberLists],
},
},
});
this.sent.set(desired.guildId, {threads: true, memberLists});
socket.updateGuildSubscriptions({subscriptions});
for (const clearedGuildId of Object.keys(subscriptions)) {
if (clearedGuildId !== guildId) this.listGuilds.delete(clearedGuildId);
}
if (guildId && subscriptions[guildId]) {
this.sent.set(guildId, {threads: true, memberLists});
if (desired.memberLists.length > 0) this.listGuilds.add(guildId);
else this.listGuilds.delete(guildId);
}
}
private handleActiveGuildsChanged(activeGuildIds: ReadonlyArray<string>): void {
@@ -98,6 +111,7 @@ class ThreadSubscriptions {
if (active.has(guildId)) continue;
this.sent.delete(guildId);
if (socket?.isConnected()) {
this.listGuilds.delete(guildId);
socket.updateGuildSubscriptions({
subscriptions: {[guildId]: {threads: false, thread_member_lists: []}},
});
@@ -64,12 +64,14 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
const {i18n} = useLingui();
const userId = user.id;
const userAvatar = user.avatar;
const systemAvatarUrl = AvatarUtils.getSystemUserBrandingAvatarURL(userId);
const guildMember = GuildMembers.getMember(guildId || '', userId);
const hasGuildMemberAvatarSource = Boolean(guildId) && guildMember != null;
const memberAvatar = guildMember?.avatar ?? null;
const memberAvatarUnset = guildMember?.isAvatarUnset() ?? false;
const avatarUrl = useMemo(() => {
if (customAvatarUrl !== undefined) return customAvatarUrl;
if (systemAvatarUrl !== null) return systemAvatarUrl;
if (guildId && hasGuildMemberAvatarSource) {
return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId,
@@ -88,11 +90,13 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
memberAvatar,
memberAvatarUnset,
mediaSize,
systemAvatarUrl,
userAvatar,
userId,
]);
const hoverAvatarUrl = useMemo(() => {
if (customHoverAvatarUrl !== undefined) return customHoverAvatarUrl;
if (systemAvatarUrl !== null) return systemAvatarUrl;
if (guildId && hasGuildMemberAvatarSource) {
return AvatarUtils.getGuildMemberDisplayAvatarURL({
guildId,
@@ -111,6 +115,7 @@ const AvatarComponent = React.forwardRef<HTMLDivElement, AvatarProps>(
memberAvatar,
memberAvatarUnset,
mediaSize,
systemAvatarUrl,
userAvatar,
userId,
]);
@@ -26,6 +26,7 @@ export interface StatusAwareAvatarProps {
guildId?: string | null;
mediaSize?: MediaProxyImageSize;
status?: string | null;
isMobile?: boolean;
animateStatusCutout?: boolean;
}
@@ -54,6 +55,7 @@ export const StatusAwareAvatar: React.FC<StatusAwareAvatarProps> = observer(
guildId,
mediaSize,
status: externalStatus,
isMobile: externalIsMobile,
animateStatusCutout,
}) => {
if (!user) {
@@ -66,7 +68,7 @@ export const StatusAwareAvatar: React.FC<StatusAwareAvatarProps> = observer(
} else if (externalStatus == null) {
status = getStatusWithTransientFallback(user.id);
}
const isMobile = shouldDisablePresence ? false : Presence.isMobile(user.id);
const isMobile = shouldDisablePresence ? false : (externalIsMobile ?? Presence.isMobile(user.id));
return (
<Avatar
user={user}
@@ -1,11 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {getUserAccentColor} from '@app/features/theme/utils/AccentColorUtils';
import {ProfileBody} from '@app/features/user/components/modals/user_profile_modal/ProfileBody';
import {ProfileMediaHeader} from '@app/features/user/components/modals/user_profile_modal/ProfileMediaHeader';
import type {ProfileModalContentProps} from '@app/features/user/components/modals/user_profile_modal/UserProfileModalShared';
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
import * as ProfileDisplayUtils from '@app/features/user/utils/ProfileDisplayUtils';
import {resolveProfileGuildMembership, toProfileDisplayContext} from '@app/features/user/utils/ProfileGuildMembership';
import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants';
import {
MEDIA_PROXY_AVATAR_SIZE_PROFILE,
MEDIA_PROXY_PROFILE_BANNER_SIZE_MODAL,
@@ -26,7 +29,12 @@ export const ProfileModalContent: React.FC<ProfileModalContentProps> = observer(
showProfileDataWarning,
}) => {
const effectiveProfile = profile?.getEffectiveProfile() ?? null;
const bannerColor = getUserAccentColor(user, effectiveProfile?.accent_color);
const isSystemUser = user.id === FLUXERBOT_ID;
const systemThemeColor =
isSystemUser && RuntimeConfig.isSelfHosted()
? RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.theme_color
: null;
const bannerColor = systemThemeColor ?? getUserAccentColor(user, effectiveProfile?.accent_color);
const membership = resolveProfileGuildMembership(profile);
const profileContext = useMemo<ProfileDisplayUtils.ProfileDisplayContext>(
() =>
@@ -38,10 +46,13 @@ export const ProfileModalContent: React.FC<ProfileModalContentProps> = observer(
}),
[user, profile, membership],
);
const {avatarUrl, hoverAvatarUrl} = useMemo(
const {avatarUrl: profileAvatarUrl, hoverAvatarUrl: profileHoverAvatarUrl} = useMemo(
() => ProfileDisplayUtils.getProfileAvatarUrls(profileContext, previewOverrides, MEDIA_PROXY_AVATAR_SIZE_PROFILE),
[profileContext, previewOverrides],
);
const systemAvatarUrl = AvatarUtils.getSystemUserBrandingAvatarURL(user.id);
const avatarUrl = systemAvatarUrl ?? profileAvatarUrl;
const hoverAvatarUrl = systemAvatarUrl ?? profileHoverAvatarUrl;
const {bannerUrl, hoverBannerUrl} = useMemo(
() =>
ProfileDisplayUtils.getProfileBannerUrls(
@@ -59,8 +70,8 @@ export const ProfileModalContent: React.FC<ProfileModalContentProps> = observer(
profileContext={profileContext}
previewOverrides={previewOverrides}
bannerColor={bannerColor}
bannerUrl={bannerUrl}
hoverBannerUrl={hoverBannerUrl}
bannerUrl={isSystemUser ? null : bannerUrl}
hoverBannerUrl={isSystemUser ? null : hoverBannerUrl}
avatarUrl={avatarUrl}
hoverAvatarUrl={hoverAvatarUrl}
renderActionButtons={renderActionButtons}
@@ -18,6 +18,7 @@ import {
parseAvatarHash,
} from '@app/features/user/utils/AvatarMediaUtils';
import {getDefaultAvatarAssetURL} from '@app/features/user/utils/DefaultAvatars';
import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants';
import {
MEDIA_PROXY_AVATAR_SIZE_DEFAULT,
MEDIA_PROXY_ICON_SIZE_DEFAULT,
@@ -75,6 +76,12 @@ const getViewportSplashSize = (): MediaProxyImageSize => {
export const getDefaultAvatarURLForIndex = (index: number): string => getDefaultAvatarAssetURL(index);
export function getSystemUserBrandingAvatarURL(userId: string): string | null {
if (userId !== FLUXERBOT_ID || !RuntimeConfig.isSelfHosted()) return null;
const branding = RuntimeConfig.getSnapshotOrNull()?.appPublic.branding;
return branding?.icon_url ?? branding?.logo_url ?? null;
}
export function getDefaultAvatarPrimaryColor(id: string) {
return getSharedDefaultAvatarPrimaryColor(id);
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import Channels from '@app/features/channel/state/Channels';
import GuildMembers from '@app/features/member/state/GuildMembers';
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
@@ -8,6 +9,7 @@ import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
import {noteText} from '@app/features/theme/fonts/ScriptFontLoader';
import type {User} from '@app/features/user/models/User';
import {formatUserTag} from '@app/features/user/utils/UserTagUtils';
import {FLUXERBOT_ID} from '@fluxer/constants/src/AppConstants';
export interface UserDisplayNameLike {
username: string;
@@ -51,6 +53,10 @@ export function getDisplayName(user: UserDisplayNameLike): string {
}
export function getNickname(user: User, guildId?: string | null, channelId?: string): string {
if (user.id === FLUXERBOT_ID) {
const productName = RuntimeConfig.getSnapshotOrNull()?.appPublic.branding.product_name;
if (productName) return formatNicknameForStreamerMode(productName);
}
let name = user.displayName || user.globalName || user.username || user.id || '';
const relationship = Relationships.getRelationship(user.id);
if (relationship?.nickname) {
@@ -84,12 +84,6 @@ Joining a locked thread needs an enrolled authenticator while the guild [MFA lev
| --- | --- | --- |
| channel_id | snowflake | The ID of the thread |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| location? | string | A value Fluxer accepts and ignores (max 100 characters) |
### Response
| Status | Body | Condition |
@@ -131,12 +125,6 @@ Adding to a locked thread needs an enrolled authenticator while the guild [MFA l
| channel_id | snowflake | The ID of the thread |
| user_id | snowflake | The ID of the user to add |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| location? | string | A value Fluxer accepts and ignores (max 100 characters) |
### Response
| Status | Body | Condition |
@@ -176,12 +164,6 @@ A timed-out member can leave a thread. A member who can no longer view the threa
| --- | --- | --- |
| channel_id | snowflake | The ID of the thread |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| location? | string | A value Fluxer accepts and ignores (max 100 characters) |
### Response
| Status | Body | Condition |
@@ -219,12 +201,6 @@ Removing a member needs an enrolled authenticator while the guild [MFA level](/h
| channel_id | snowflake | The ID of the thread |
| user_id | snowflake | The ID of the user to remove |
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| location? | string | A value Fluxer accepts and ignores (max 100 characters) |
### Response
| Status | Body | Condition |
@@ -184,7 +184,6 @@ Starts a public thread from a message in a guild text channel, or an announcemen
| name | string | The name of the thread (1-100 characters) |
| auto_archive_duration? | integer | The [auto archive duration](#auto-archive-durations) in minutes (default 4320) |
| rate_limit_per_user? | integer | The slowmode interval in seconds (0-21,600) |
| location? | string | A value Fluxer accepts and ignores (max 100 characters) |
`rate_limit_per_user` defaults to the `default_thread_rate_limit_per_user` of the parent, or `0` when the parent has none.
@@ -252,7 +251,6 @@ The parent type selects the body. Either body can be sent as multipart form data
| auto_archive_duration? | integer | The [auto archive duration](#auto-archive-durations) in minutes (default 4320) |
| rate_limit_per_user? | integer | The slowmode interval in seconds (0-21,600) |
| invitable?<sup>1</sup> | boolean | Whether a member who is not a thread moderator can add members who are not thread moderators (default true) |
| location? | string | A value Fluxer accepts and ignores (max 100 characters) |
<sup>1</sup> Applies only to a private thread
@@ -266,7 +264,6 @@ The parent type selects the body. Either body can be sent as multipart form data
| auto_archive_duration? | integer | The [auto archive duration](#auto-archive-durations) in minutes (default 4320) |
| rate_limit_per_user? | integer | The slowmode interval in seconds (0-21,600) |
| type?<sup>2</sup> | integer | The thread type (`11` only) |
| location? | string | A value Fluxer accepts and ignores (max 100 characters) |
<sup>1</sup> Required when the channel has `REQUIRE_TAG`. A tag marked `moderated` needs a thread moderator
@@ -84,6 +84,7 @@ An over-length string draws two entries for the one path.
| double_tap_reaction? | [reaction emoji](#reaction-emoji-object) object | Emoji a double tap on a message adds as a reaction |
| announcement_prompts? | [announcement prompts state](#announcement-prompts-state-object) object | Hidden announcement channel prompt state |
| double_tap_action | int32 | [Double tap action](#double-tap-actions) on a message |
| swipe_right_to_left_action | int32 | [Swipe right to left action](#swipe-right-to-left-actions) in a channel |
<sup>1</sup> The entries live inside the snapshot, and the account [memes](/http-api/memes/) collection holds none of them
@@ -886,3 +887,13 @@ The `double_tap_action` field selects what a double tap on a message does in the
| 1 | DOUBLE_TAP_ACTION_REACT | Add the `double_tap_reaction` emoji as a reaction |
| 2 | DOUBLE_TAP_ACTION_EDIT | Edit the message, when it is the account's own message |
| 3 | DOUBLE_TAP_ACTION_NONE | Do nothing |
## Swipe right to left actions
The `swipe_right_to_left_action` field selects what a right to left swipe in a channel does in the mobile client.
| Value | Name | Description |
| --- | --- | --- |
| 0 | SWIPE_ACTION_UNSPECIFIED | No explicit action is selected |
| 1 | SWIPE_ACTION_CHANNEL_DETAILS | Open the channel details |
| 2 | SWIPE_ACTION_REPLY | Reply to the swiped message |
@@ -293,12 +293,23 @@ after_store_event(channel_delete, Data, Tab, State) ->
end,
State;
after_store_event(guild_member_remove, Data, Tab, State) ->
_ =
case guild_state_member:extract_user_id(Data) of
UserId when is_integer(UserId) -> guild_thread_store:remove_user(Tab, UserId);
_ -> []
end,
State;
case guild_state_member:extract_user_id(Data) of
UserId when is_integer(UserId) ->
lists:foldl(
fun guild_thread_subscriptions:member_lists_changed/2,
State,
guild_thread_store:remove_user(Tab, UserId)
);
_ ->
State
end;
after_store_event(guild_member_update, Data, _Tab, State) ->
case guild_state_member:extract_user_id(Data) of
UserId when is_integer(UserId) ->
guild_thread_subscriptions:member_changed(UserId, State);
_ ->
State
end;
after_store_event(_Event, _Data, _Tab, State) ->
State.
@@ -12,6 +12,7 @@
member_lists_changed/2,
resync_after_load/1,
presence_changed/2,
member_changed/2,
handle_list_flush/1,
refresh_presence_users/1,
export_handoff/1,
@@ -74,7 +75,6 @@ apply_threads_flag(_Other, Session) ->
-spec apply_member_lists(term(), map(), guild_state()) -> {map(), [integer()]}.
apply_member_lists(Ids, Session, State) when is_list(Ids) ->
Previous = maps:get(thread_member_lists, Session, []),
Viewable = lists:sublist(
[Id || Id <- lists:usort(Ids), viewable_thread(Session, Id, State)], ?MAX_MEMBER_LISTS
),
@@ -83,7 +83,7 @@ apply_member_lists(Ids, Session, State) when is_list(Ids) ->
[] -> maps:remove(thread_member_lists, Session);
_ -> Session#{thread_member_lists => Viewable}
end,
{Updated, Viewable -- Previous};
{Updated, Viewable};
apply_member_lists(_Other, Session, _State) ->
{Session, []}.
@@ -306,6 +306,10 @@ presence_changed(UserId, #{thread_presence_users := Users} = State) ->
presence_changed(_UserId, State) ->
State.
-spec member_changed(integer(), guild_state()) -> guild_state().
member_changed(UserId, State) ->
presence_changed(UserId, State).
-spec mark_dirty([integer()], guild_state()) -> guild_state().
mark_dirty([], State) ->
State;
@@ -1486,6 +1486,74 @@ thread_member_list_follows_presence_changes_test() ->
?assertEqual(State, guild_thread_subscriptions:presence_changed(?A, State))
end).
thread_member_list_follows_member_updates_test() ->
with_guild(fun(State) ->
Subscribed = guild_thread_subscriptions:update(
<<"a">>, #{member_lists => [?T1]}, State
),
[_] = received(a, thread_member_list_update, drain()),
Update = fun(UserId, Nick) ->
#{
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
<<"roles">> => [],
<<"nick">> => Nick
}
end,
Changed = dispatch(guild_member_update, Update(?A, <<"renamed">>), Subscribed),
?assertEqual([], received(a, thread_member_list_update, drain())),
_ = guild_thread_subscriptions:handle_list_flush(Changed),
[#{<<"members">> := Members}] = received(a, thread_member_list_update, drain()),
?assertEqual(
[<<"renamed">>],
[
maps:get(<<"nick">>, Member)
|| #{<<"user_id">> := Id, <<"member">> := Member} <- Members,
Id =:= integer_to_binary(?A)
]
),
Unlisted = dispatch(guild_member_update, Update(?C, <<"other">>), Subscribed),
?assertNot(maps:is_key(thread_list_dirty, Unlisted))
end).
thread_member_list_resubscribe_resends_snapshot_test() ->
with_guild(fun(State) ->
Subscribed = guild_thread_subscriptions:update(
<<"a">>, #{member_lists => [?T1]}, State
),
[_] = received(a, thread_member_list_update, drain()),
Again = guild_thread_subscriptions:update(
<<"a">>, #{member_lists => [?T1]}, Subscribed
),
[_] = received(a, thread_member_list_update, drain()),
_ = guild_thread_subscriptions:update(<<"a">>, #{threads => true}, Again),
?assertEqual([], received(a, thread_member_list_update, drain()))
end).
thread_member_list_drops_removed_guild_members_test() ->
with_guild(fun(State0) ->
Added = #{
<<"id">> => integer_to_binary(?T1),
<<"member_count">> => 2,
<<"added_members">> => [thread_member(?T1, ?V)]
},
State1 = dispatch(thread_members_update, Added, State0),
Subscribed = guild_thread_subscriptions:update(
<<"a">>, #{member_lists => [?T1]}, State1
),
_ = drain(),
Removed = dispatch(
guild_member_remove,
#{<<"user">> => #{<<"id">> => integer_to_binary(?V)}},
Subscribed
),
?assertEqual(#{?T1 => true}, maps:get(thread_list_dirty, Removed)),
_ = guild_thread_subscriptions:handle_list_flush(Removed),
[#{<<"members">> := Members}] = received(a, thread_member_list_update, drain()),
?assertEqual(
[integer_to_binary(?A)], [maps:get(<<"user_id">>, M) || M <- Members]
)
end).
handoff_through_an_old_node_round_trips_test() ->
with_guild(fun(State) ->
Exported = guild_handoff:export_handoff_state(State),
@@ -53,6 +53,7 @@ message SyncedPreferences {
ChannelFrecencyState channel_frecency = 115;
AnnouncementPromptsState announcement_prompts = 116;
DoubleTapAction double_tap_action = 117;
SwipeAction swipe_right_to_left_action = 118;
}
@@ -288,6 +289,12 @@ enum DoubleTapAction {
DOUBLE_TAP_ACTION_NONE = 3;
}
enum SwipeAction {
SWIPE_ACTION_UNSPECIFIED = 0;
SWIPE_ACTION_CHANNEL_DETAILS = 1;
SWIPE_ACTION_REPLY = 2;
}
message ReactionEmoji {
optional string id = 1;
string name = 2;
@@ -38,7 +38,6 @@ import {z} from 'zod';
const EXPERIMENT = {experiment: 'channel_threads'} as const;
const EMOJI_NAME_MAX_LENGTH = 64;
const LOCATION_MAX_LENGTH = 100;
export const ForumTagRequest = withSchemaMetadata(
z.object({
@@ -141,7 +140,6 @@ export const StartForumThreadRequest = withSchemaMetadata(
.describe('Seconds a user has to wait before sending another message (0-21600)'),
applied_tags: AppliedTagsType.optional(),
message: ForumThreadMessageRequest.describe('The first message of the post'),
location: z.string().max(LOCATION_MAX_LENGTH).optional().describe('Accepted and ignored'),
}),
EXPERIMENT,
);
@@ -22,7 +22,6 @@ import {
import {z} from 'zod';
const THREAD_MEMBERS_LIST_MAX_LIMIT = 100;
const LOCATION_MAX_LENGTH = 100;
export const ThreadAutoArchiveDurationSchema = createInt32EnumType(
[
@@ -43,13 +42,10 @@ const ThreadRateLimitPerUserType = Int32Type.max(THREAD_RATE_LIMIT_PER_USER_MAX)
'Seconds a user has to wait before sending another message (0-21600)',
);
const LocationType = z.string().max(LOCATION_MAX_LENGTH).optional().describe('Accepted and ignored');
export const StartThreadFromMessageRequest = z.object({
name: ThreadNameType,
auto_archive_duration: ThreadAutoArchiveDurationSchema.optional(),
rate_limit_per_user: ThreadRateLimitPerUserType.optional(),
location: LocationType,
});
export type StartThreadFromMessageRequest = z.infer<typeof StartThreadFromMessageRequest>;
@@ -68,15 +64,10 @@ export const StartThreadRequest = z.object({
auto_archive_duration: ThreadAutoArchiveDurationSchema.optional(),
rate_limit_per_user: ThreadRateLimitPerUserType.optional(),
invitable: z.boolean().optional().describe('Whether non-moderators can add other non-moderators (private threads)'),
location: LocationType,
});
export type StartThreadRequest = z.infer<typeof StartThreadRequest>;
export const ThreadLocationQuery = z.object({
location: LocationType,
});
export const ThreadMembersListQuery = z.object({
with_member: QueryBooleanType.describe('Whether to include a guild member object for each thread member'),
after: SnowflakeType.optional().describe('Get thread members after this user ID'),
@@ -22,6 +22,7 @@ import {
ChatInputSettingsSchema,
DoubleTapAction,
ReactionEmojiSchema,
SwipeAction,
} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/preferences_pb';
import {describe, expect, it} from 'vitest';
@@ -110,6 +111,13 @@ describe('SyncedPreferencesCodec', () => {
expect(decoded.doubleTapAction).toBe(action);
}
});
it('round-trips every swipe right to left action', () => {
for (const action of [SwipeAction.CHANNEL_DETAILS, SwipeAction.REPLY]) {
const original = create(SyncedPreferencesSchema, {swipeRightToLeftAction: action});
const decoded = decodeSyncedPreferences(encodeSyncedPreferences(original));
expect(decoded.swipeRightToLeftAction).toBe(action);
}
});
it('produces canonical encodings for equal inputs', () => {
const a = create(SyncedPreferencesSchema, {
accessibility: create(AccessibilitySettingsSchema, {fontSize: 14}),
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file fluxer/user/preferences/v1/preferences.proto.
*/
export const file_fluxer_user_preferences_v1_preferences: GenFile = /*@__PURE__*/
fileDesc("CixmbHV4ZXIvdXNlci9wcmVmZXJlbmNlcy92MS9wcmVmZXJlbmNlcy5wcm90bxIaZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEiwRYKEVN5bmNlZFByZWZlcmVuY2VzEkgKDWFjY2Vzc2liaWxpdHkYASABKAsyMS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5BY2Nlc3NpYmlsaXR5U2V0dGluZ3MSUwoXYWNjZXNzaWJpbGl0eV9vdmVycmlkZXMYAiABKAsyMi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5BY2Nlc3NpYmlsaXR5T3ZlcnJpZGVzEksKD3RleHR1YWxfcHJldmlldxgDIAEoCzIyLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlRleHR1YWxQcmV2aWV3U2V0dGluZ3MSQgoMZW1vamlfcGlja2VyGBQgASgLMiwuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuRW1vamlQaWNrZXJTdGF0ZRJGCg5zdGlja2VyX3BpY2tlchgVIAEoCzIuLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlN0aWNrZXJQaWNrZXJTdGF0ZRJCCgxtZW1lc19waWNrZXIYFiABKAsyLC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5NZW1lc1BpY2tlclN0YXRlEjUKBWVtb2ppGBcgASgLMiYuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuRW1vamlTdGF0ZRJUChRlbW9qaV9zdGlja2VyX2xheW91dBgYIAEoCzI2LmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkVtb2ppU3RpY2tlckxheW91dFNldHRpbmdzEkYKDWZhdm9yaXRlX2dpZnMYGSABKAsyLy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5GYXZvcml0ZUdpZlNldHRpbmdzEj0KCWZhdm9yaXRlcxgoIAEoCzIqLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkZhdm9yaXRlc1N0YXRlEksKD3JlY2VudF9tZW50aW9ucxgpIAEoCzIyLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlJlY2VudE1lbnRpb25zU2V0dGluZ3MSPwoHc2lkZWJhchgqIAEoCzIuLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlNpZGViYXJQcmVmZXJlbmNlcxJACgttZW1iZXJfbGlzdBgrIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLk1lbWJlckxpc3RTdGF0ZRJICg91bnJlYWRfY2hhbm5lbHMYLCABKAsyLy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5VbnJlYWRDaGFubmVsc1N0YXRlEkoKEG1lbnRpb25fZnJlY2VuY3kYLSABKAsyMC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5NZW50aW9uRnJlY2VuY3lTdGF0ZRI9CgduYWdiYXJzGDwgASgLMiwuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuTmFnYmFyRGlzbWlzc2FscxJHChFkaXNtaXNzZWRfdXBzZWxscxg9IAEoCzIsLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkRpc21pc3NlZFVwc2VsbHMSTgoVZ3VpbGRfbnNmd19hZ3JlZW1lbnRzGD4gASgLMi8uZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGROc2Z3QWdyZWVtZW50cxI8Cgl3aGF0c19uZXcYPyABKAsyKS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5XaGF0c05ld1N0YXRlEj8KB3ByaXZhY3kYUCABKAsyLi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Qcml2YWN5UHJlZmVyZW5jZXMSUAoUbG9jYWxfc3BhbV9vdmVycmlkZXMYUSABKAsyMi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Mb2NhbFVzZXJTcGFtT3ZlcnJpZGVzEhUKDXNhbml0aXplX3VybHMYUiABKAgSOAoFc291bmQYZCABKAsyKS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Tb3VuZFNldHRpbmdzEkIKCnNwZWxsY2hlY2sYZSABKAsyLi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5TcGVsbGNoZWNrU2V0dGluZ3MSSAoOc2VhcmNoX2VuZ2luZXMYZiABKAsyMC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5TZWFyY2hFbmdpbmVTZXR0aW5ncxJPChFwZXJtaXNzaW9uX2xheW91dBhnIAEoCzI0LmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlBlcm1pc3Npb25MYXlvdXRTZXR0aW5ncxJSChNndWlsZF9tZW1iZXJfbGF5b3V0GGggASgLMjUuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGRNZW1iZXJMYXlvdXRTZXR0aW5ncxJLCg1ndWlsZF9mb2xkZXJzGGkgASgLMjQuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGRGb2xkZXJFeHBhbmRlZFN0YXRlElAKFGhpZGRlbl9ndWlsZF9idXR0b25zGGogASgLMjIuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuSGlkZGVuR3VpbGRMaXN0QnV0dG9ucxJPChNrZXlib2FyZF9tb2RlX2ludHJvGGsgASgLMjIuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuS2V5Ym9hcmRNb2RlSW50cm9TdGF0ZRJRChBpbnB1dF9tb25pdG9yaW5nGGwgASgLMjcuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuSW5wdXRNb25pdG9yaW5nUHJvbXB0c1N0YXRlEkQKDXZvaWNlX3Byb21wdHMYbSABKAsyLS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Wb2ljZVByb21wdHNTdGF0ZRJACgtzdWRvX3Byb21wdBhuIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlN1ZG9Qcm9tcHRTdGF0ZRI9CghrZXliaW5kcxhvIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLktleWJpbmRTZXR0aW5ncxJBCgpjaGF0X2lucHV0GHAgASgLMi0uZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuQ2hhdElucHV0U2V0dGluZ3MSKgodc2F2ZV9jYW1lcmFfdXBsb2Fkc190b19kZXZpY2UYcSABKAhIAIgBARJGChNkb3VibGVfdGFwX3JlYWN0aW9uGHIgASgLMikuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuUmVhY3Rpb25FbW9qaRJKChBjaGFubmVsX2ZyZWNlbmN5GHMgASgLMjAuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuQ2hhbm5lbEZyZWNlbmN5U3RhdGUSUgoUYW5ub3VuY2VtZW50X3Byb21wdHMYdCABKAsyNC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Bbm5vdW5jZW1lbnRQcm9tcHRzU3RhdGUSRgoRZG91YmxlX3RhcF9hY3Rpb24YdSABKA4yKy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Eb3VibGVUYXBBY3Rpb25CIAoeX3NhdmVfY2FtZXJhX3VwbG9hZHNfdG9fZGV2aWNlIrABChJTcGVsbGNoZWNrU2V0dGluZ3MSFAoHZW5hYmxlZBgBIAEoCEgAiAEBEhEKCWxhbmd1YWdlcxgCIAMoCRIbChNwZXJzb25hbF9kaWN0aW9uYXJ5GAMgAygJEhgKC2F1dG9fZGV0ZWN0GAQgASgISAGIAQESEwoGZW5naW5lGAUgASgJSAKIAQFCCgoIX2VuYWJsZWRCDgoMX2F1dG9fZGV0ZWN0QgkKB19lbmdpbmUi5gEKFFNlYXJjaEVuZ2luZVNldHRpbmdzEiIKFXRleHRfc2VhcmNoX2VuZ2luZV9pZBgBIAEoCUgAiAEBEisKHnJldmVyc2VfaW1hZ2Vfc2VhcmNoX2VuZ2luZV9pZBgCIAEoCUgBiAEBEiQKF3RyYW5zbGF0aW9uX3Byb3ZpZGVyX2lkGAMgASgJSAKIAQFCGAoWX3RleHRfc2VhcmNoX2VuZ2luZV9pZEIhCh9fcmV2ZXJzZV9pbWFnZV9zZWFyY2hfZW5naW5lX2lkQhoKGF90cmFuc2xhdGlvbl9wcm92aWRlcl9pZCK1AQoSUHJpdmFjeVByZWZlcmVuY2VzEh8KF2Rpc2FibGVfc3RyZWFtX3ByZXZpZXdzGAEgASgIEhwKD3Nob3dfYWN0aXZlX25vdxgCIAEoCEgAiAEBEioKHXByZXVwbG9hZF9tZXNzYWdlX2F0dGFjaG1lbnRzGAMgASgISAGIAQFCEgoQX3Nob3dfYWN0aXZlX25vd0IgCh5fcHJldXBsb2FkX21lc3NhZ2VfYXR0YWNobWVudHMiUAoWTG9jYWxVc2VyU3BhbU92ZXJyaWRlcxIYChBzcGFtbWVyX3VzZXJfaWRzGAEgAygJEhwKFG5vdF9zcGFtbWVyX3VzZXJfaWRzGAIgAygJIisKFlRleHR1YWxQcmV2aWV3U2V0dGluZ3MSEQoJd3JhcF90ZXh0GAEgASgIItwBChJTaWRlYmFyUHJlZmVyZW5jZXMSHAoUaW5saW5lX2Rtc19jb2xsYXBzZWQYASABKAgSLAofc2hLine truncated
fileDesc("CixmbHV4ZXIvdXNlci9wcmVmZXJlbmNlcy92MS9wcmVmZXJlbmNlcy5wcm90bxIaZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEijhcKEVN5bmNlZFByZWZlcmVuY2VzEkgKDWFjY2Vzc2liaWxpdHkYASABKAsyMS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5BY2Nlc3NpYmlsaXR5U2V0dGluZ3MSUwoXYWNjZXNzaWJpbGl0eV9vdmVycmlkZXMYAiABKAsyMi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5BY2Nlc3NpYmlsaXR5T3ZlcnJpZGVzEksKD3RleHR1YWxfcHJldmlldxgDIAEoCzIyLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlRleHR1YWxQcmV2aWV3U2V0dGluZ3MSQgoMZW1vamlfcGlja2VyGBQgASgLMiwuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuRW1vamlQaWNrZXJTdGF0ZRJGCg5zdGlja2VyX3BpY2tlchgVIAEoCzIuLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlN0aWNrZXJQaWNrZXJTdGF0ZRJCCgxtZW1lc19waWNrZXIYFiABKAsyLC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5NZW1lc1BpY2tlclN0YXRlEjUKBWVtb2ppGBcgASgLMiYuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuRW1vamlTdGF0ZRJUChRlbW9qaV9zdGlja2VyX2xheW91dBgYIAEoCzI2LmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkVtb2ppU3RpY2tlckxheW91dFNldHRpbmdzEkYKDWZhdm9yaXRlX2dpZnMYGSABKAsyLy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5GYXZvcml0ZUdpZlNldHRpbmdzEj0KCWZhdm9yaXRlcxgoIAEoCzIqLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkZhdm9yaXRlc1N0YXRlEksKD3JlY2VudF9tZW50aW9ucxgpIAEoCzIyLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlJlY2VudE1lbnRpb25zU2V0dGluZ3MSPwoHc2lkZWJhchgqIAEoCzIuLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlNpZGViYXJQcmVmZXJlbmNlcxJACgttZW1iZXJfbGlzdBgrIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLk1lbWJlckxpc3RTdGF0ZRJICg91bnJlYWRfY2hhbm5lbHMYLCABKAsyLy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5VbnJlYWRDaGFubmVsc1N0YXRlEkoKEG1lbnRpb25fZnJlY2VuY3kYLSABKAsyMC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5NZW50aW9uRnJlY2VuY3lTdGF0ZRI9CgduYWdiYXJzGDwgASgLMiwuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuTmFnYmFyRGlzbWlzc2FscxJHChFkaXNtaXNzZWRfdXBzZWxscxg9IAEoCzIsLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLkRpc21pc3NlZFVwc2VsbHMSTgoVZ3VpbGRfbnNmd19hZ3JlZW1lbnRzGD4gASgLMi8uZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGROc2Z3QWdyZWVtZW50cxI8Cgl3aGF0c19uZXcYPyABKAsyKS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5XaGF0c05ld1N0YXRlEj8KB3ByaXZhY3kYUCABKAsyLi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Qcml2YWN5UHJlZmVyZW5jZXMSUAoUbG9jYWxfc3BhbV9vdmVycmlkZXMYUSABKAsyMi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Mb2NhbFVzZXJTcGFtT3ZlcnJpZGVzEhUKDXNhbml0aXplX3VybHMYUiABKAgSOAoFc291bmQYZCABKAsyKS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Tb3VuZFNldHRpbmdzEkIKCnNwZWxsY2hlY2sYZSABKAsyLi5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5TcGVsbGNoZWNrU2V0dGluZ3MSSAoOc2VhcmNoX2VuZ2luZXMYZiABKAsyMC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5TZWFyY2hFbmdpbmVTZXR0aW5ncxJPChFwZXJtaXNzaW9uX2xheW91dBhnIAEoCzI0LmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlBlcm1pc3Npb25MYXlvdXRTZXR0aW5ncxJSChNndWlsZF9tZW1iZXJfbGF5b3V0GGggASgLMjUuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGRNZW1iZXJMYXlvdXRTZXR0aW5ncxJLCg1ndWlsZF9mb2xkZXJzGGkgASgLMjQuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuR3VpbGRGb2xkZXJFeHBhbmRlZFN0YXRlElAKFGhpZGRlbl9ndWlsZF9idXR0b25zGGogASgLMjIuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuSGlkZGVuR3VpbGRMaXN0QnV0dG9ucxJPChNrZXlib2FyZF9tb2RlX2ludHJvGGsgASgLMjIuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuS2V5Ym9hcmRNb2RlSW50cm9TdGF0ZRJRChBpbnB1dF9tb25pdG9yaW5nGGwgASgLMjcuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuSW5wdXRNb25pdG9yaW5nUHJvbXB0c1N0YXRlEkQKDXZvaWNlX3Byb21wdHMYbSABKAsyLS5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Wb2ljZVByb21wdHNTdGF0ZRJACgtzdWRvX3Byb21wdBhuIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLlN1ZG9Qcm9tcHRTdGF0ZRI9CghrZXliaW5kcxhvIAEoCzIrLmZsdXhlci51c2VyLnByZWZlcmVuY2VzLnYxLktleWJpbmRTZXR0aW5ncxJBCgpjaGF0X2lucHV0GHAgASgLMi0uZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuQ2hhdElucHV0U2V0dGluZ3MSKgodc2F2ZV9jYW1lcmFfdXBsb2Fkc190b19kZXZpY2UYcSABKAhIAIgBARJGChNkb3VibGVfdGFwX3JlYWN0aW9uGHIgASgLMikuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuUmVhY3Rpb25FbW9qaRJKChBjaGFubmVsX2ZyZWNlbmN5GHMgASgLMjAuZmx1eGVyLnVzZXIucHJlZmVyZW5jZXMudjEuQ2hhbm5lbEZyZWNlbmN5U3RhdGUSUgoUYW5ub3VuY2VtZW50X3Byb21wdHMYdCABKAsyNC5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Bbm5vdW5jZW1lbnRQcm9tcHRzU3RhdGUSRgoRZG91YmxlX3RhcF9hY3Rpb24YdSABKA4yKy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Eb3VibGVUYXBBY3Rpb24SSwoac3dpcGVfcmlnaHRfdG9fbGVmdF9hY3Rpb24YdiABKA4yJy5mbHV4ZXIudXNlci5wcmVmZXJlbmNlcy52MS5Td2lwZUFjdGlvbkIgCh5fc2F2ZV9jYW1lcmFfdXBsb2Fkc190b19kZXZpY2UisAEKElNwZWxsY2hlY2tTZXR0aW5ncxIUCgdlbmFibGVkGAEgASgISACIAQESEQoJbGFuZ3VhZ2VzGAIgAygJEhsKE3BlcnNvbmFsX2RpY3Rpb25hcnkYAyADKAkSGAoLYXV0b19kZXRlY3QYBCABKAhIAYgBARITCgZlbmdpbmUYBSABKAlIAogBAUIKCghfZW5hYmxlZEIOCgxfYXV0b19kZXRlY3RCCQoHX2VuZ2luZSLmAQoUU2VhcmNoRW5naW5lU2V0dGluZ3MSIgoVdGV4dF9zZWFyY2hfZW5naW5lX2lkGAEgASgJSACIAQESKwoecmV2ZXJzZV9pbWFnZV9zZWFyY2hfZW5naW5lX2lkGAIgASgJSAGIAQESJAoXdHJhbnNsYXRpb25fcHJvdmlkZXJfaWQYAyABKAlIAogBAUIYChZfdGV4dF9zZWFyY2hfZW5naW5lX2lkQiEKH19yZXZlcnNlX2ltYWdlX3NlYXJjaF9lbmdpbmVfaWRCGgoYX3RyYW5zbGF0aW9uX3Byb3ZpZGVyX2lkIrUBChJQcml2YWN5UHJlZmVyZW5jZXMSHwoXZGlzYWJsZV9zdHJlYW1fcHJldmlld3MYASABKAgSHAoPc2hvd19hY3RpdmVfbm93GAIgASgISACIAQESKgodcHJldXBsb2FkX21lc3NhZ2VfYXR0YWNobWVudHMYAyABKAhIAYgBAUISChBfc2hvd19hY3RpdmVfbm93QiAKHl9wcmV1cGxvYWRfbWVzc2FnZV9hdHRhY2htZW50cyJQChZMb2NhbFVzZXJTcGFtT3ZlcnJpZGVzEhgKEHNwYW1tZXJfdXNlcl9pZHMYASADKAkSHAoUbm90X3NwYW1tZXJfdXNlcl9pZHMYAiADKAkiKwoWVGV4dHVhbFByZXZpZXdTZXR0aW5ncxILine truncated
/**
* @generated from message fluxer.user.preferences.v1.SyncedPreferences
@@ -215,6 +215,11 @@ export type SyncedPreferences = Message<"fluxer.user.preferences.v1.SyncedPrefer
* @generated from field: fluxer.user.preferences.v1.DoubleTapAction double_tap_action = 117;
*/
doubleTapAction: DoubleTapAction;
/**
* @generated from field: fluxer.user.preferences.v1.SwipeAction swipe_right_to_left_action = 118;
*/
swipeRightToLeftAction: SwipeAction;
};
/**
@@ -1288,3 +1293,29 @@ export enum DoubleTapAction {
*/
export const DoubleTapActionSchema: GenEnum<DoubleTapAction> = /*@__PURE__*/
enumDesc(file_fluxer_user_preferences_v1_preferences, 4);
/**
* @generated from enum fluxer.user.preferences.v1.SwipeAction
*/
export enum SwipeAction {
/**
* @generated from enum value: SWIPE_ACTION_UNSPECIFIED = 0;
*/
UNSPECIFIED = 0,
/**
* @generated from enum value: SWIPE_ACTION_CHANNEL_DETAILS = 1;
*/
CHANNEL_DETAILS = 1,
/**
* @generated from enum value: SWIPE_ACTION_REPLY = 2;
*/
REPLY = 2,
}
/**
* Describes the enum fluxer.user.preferences.v1.SwipeAction.
*/
export const SwipeActionSchema: GenEnum<SwipeAction> = /*@__PURE__*/
enumDesc(file_fluxer_user_preferences_v1_preferences, 5);