fix: various fixes to sentry-reported errors

This commit is contained in:
Hampus Kraft 2026-02-21 01:32:04 +00:00
parent eb194ae5be
commit 24e9a1529d
No known key found for this signature in database
GPG Key ID: 6090864C465A454D
29 changed files with 290 additions and 75 deletions

View File

@ -894,7 +894,7 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
}
}
const users = memberIds.map((id) => UserStore.getUser(id)).filter((u): u is UserRecord => u !== null);
const users = memberIds.map((id) => UserStore.getUser(id)).filter((u): u is UserRecord => u != null);
return MemberListUtils.getGroupDMMemberGroups(users);
})();

View File

@ -39,15 +39,15 @@ export const ChannelIndexPage = observer(() => {
messageId?: string;
};
if (!channelId) {
return null;
}
const channel = ChannelStore.getChannel(channelId);
const channel = channelId ? ChannelStore.getChannel(channelId) : undefined;
const isInFavorites = location.pathname.startsWith('/channels/@favorites');
const derivedGuildId = isInFavorites ? channel?.guildId : routeGuildId || channel?.guildId;
useEffect(() => {
if (!channelId) {
return;
}
if (!channel) {
return;
}
@ -62,7 +62,11 @@ export const ChannelIndexPage = observer(() => {
}
NavigationActionCreators.selectChannel(fallbackGuildId, undefined, undefined, 'replace');
}, [channel, routeGuildId, isInFavorites]);
}, [channelId, channel, routeGuildId, isInFavorites]);
if (!channelId) {
return null;
}
if (channel && (channel.type === ChannelTypes.GUILD_CATEGORY || channel.type === ChannelTypes.GUILD_LINK)) {
return null;

View File

@ -328,7 +328,7 @@ export const ChannelMembers = observer(function ChannelMembers({guild = null, ch
if (channel.type === ChannelTypes.GROUP_DM) {
const currentUserId = AuthenticationStore.currentUserId;
const allUserIds = currentUserId ? [currentUserId, ...channel.recipientIds] : channel.recipientIds;
const users = allUserIds.map((id) => UserStore.getUser(id)).filter((user): user is UserRecord => user !== null);
const users = allUserIds.map((id) => UserStore.getUser(id)).filter((user): user is UserRecord => user != null);
const memberGroups = MemberListUtils.getGroupDMMemberGroups(users);
return (

View File

@ -42,6 +42,7 @@ import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import PermissionStore from '@app/stores/PermissionStore';
import RelationshipStore from '@app/stores/RelationshipStore';
import SavedMessagesStore from '@app/stores/SavedMessagesStore';
import UserStore from '@app/stores/UserStore';
import type {UnicodeEmoji} from '@app/types/EmojiTypes';
import {isSystemDmChannel} from '@app/utils/ChannelUtils';
import {buildMessageJumpLink} from '@app/utils/MessageLinkUtils';
@ -450,7 +451,12 @@ export function requestMessageForward(message: MessageRecord): void {
return;
}
ModalActionCreators.push(modal(() => <ForwardModal message={message} />));
const currentUser = UserStore.currentUser;
if (!currentUser) {
return;
}
ModalActionCreators.push(modal(() => <ForwardModal message={message} user={currentUser} />));
}
export function requestCopyMessageText(message: MessageRecord, i18n: I18n): void {

View File

@ -186,7 +186,7 @@ const DMListItem = observer(({channel, isSelected}: {channel: ChannelRecord; isS
}, []);
const contextMenuOpen = useContextMenuHoverState(scrollTargetRef);
const closeAllSheets = useCallback(() => {
closeAllSheets();
setMenuOpen(false);
setNestedSheet(null);
}, []);
const openNestedSheet = useCallback((title: string, groups: Array<MenuGroupType>) => {

View File

@ -408,7 +408,7 @@ export const MessageSearchBar = observer(
useEffect(() => {
const context = MemberSearchStore.getSearchContext((results) => {
const users = results.map((result) => UserStore.getUser(result.id)).filter((u): u is UserRecord => u !== null);
const users = results.map((result) => UserStore.getUser(result.id)).filter((u): u is UserRecord => u != null);
setMemberSearchResults(users);
}, 25);
@ -660,7 +660,7 @@ export const MessageSearchBar = observer(
if (channel) {
const users = channel.recipientIds
.map((id) => UserStore.getUser(id))
.filter((u): u is UserRecord => u !== null);
.filter((u): u is UserRecord => u != null);
return matchSorter(users, searchTerm, {keys: ['username', 'tag']}).slice(0, 12);
}

View File

@ -150,6 +150,41 @@ describe('ChannelMoveOperation', () => {
});
});
it('moves a top-level category above another category with root-level preceding sibling', () => {
const milsims = createChannel({id: 'milsims', type: ChannelTypes.GUILD_CATEGORY, position: 3});
const coopGames = createChannel({id: 'coop-games', type: ChannelTypes.GUILD_CATEGORY, position: 11});
const frontDoor = createChannel({id: 'front-door', type: ChannelTypes.GUILD_CATEGORY, position: 30});
const coopVoice = createChannel({
id: 'coop-voice',
type: ChannelTypes.GUILD_VOICE,
position: 12,
parentId: coopGames.id,
});
const coopText = createChannel({
id: 'coop-text',
type: ChannelTypes.GUILD_TEXT,
position: 13,
parentId: coopGames.id,
});
const operation = createChannelMoveOperation({
channels: [milsims, coopGames, frontDoor, coopVoice, coopText],
dragItem: createDragItem(frontDoor),
dropResult: {
targetId: coopGames.id,
position: 'before',
targetParentId: null,
},
});
expect(operation).toEqual({
channelId: frontDoor.id,
newParentId: null,
precedingSiblingId: milsims.id,
position: 1,
});
});
it('returns null when dropping to the same effective placement', () => {
const category = createChannel({id: 'category', type: ChannelTypes.GUILD_CATEGORY, position: 0});
const textOne = createChannel({

View File

@ -24,7 +24,7 @@ import styles from '@app/components/modals/BackupCodesModal.module.css';
import {BackupCodesRegenerateModal} from '@app/components/modals/BackupCodesRegenerateModal';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import UserStore from '@app/stores/UserStore';
import type {UserRecord} from '@app/records/UserRecord';
import type {BackupCode} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {CheckIcon, ClipboardIcon, DownloadIcon} from '@phosphor-icons/react';
@ -32,11 +32,11 @@ import {observer} from 'mobx-react-lite';
interface BackupCodesModalProps {
backupCodes: ReadonlyArray<BackupCode>;
user: UserRecord;
}
export const BackupCodesModal = observer(({backupCodes}: BackupCodesModalProps) => {
export const BackupCodesModal = observer(({backupCodes, user}: BackupCodesModalProps) => {
const {t, i18n} = useLingui();
const user = UserStore.getCurrentUser()!;
return (
<Modal.Root size="small" centered>
@ -89,7 +89,7 @@ export const BackupCodesModal = observer(({backupCodes}: BackupCodesModalProps)
<Button
variant="danger-secondary"
small={true}
onClick={() => ModalActionCreators.push(modal(() => <BackupCodesRegenerateModal />))}
onClick={() => ModalActionCreators.push(modal(() => <BackupCodesRegenerateModal user={user} />))}
>
<Trans>Regenerate</Trans>
</Button>

View File

@ -27,6 +27,7 @@ import {BackupCodesModal} from '@app/components/modals/BackupCodesModal';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import type {UserRecord} from '@app/records/UserRecord';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useForm} from 'react-hook-form';
@ -35,14 +36,20 @@ interface FormInputs {
form: string;
}
export const BackupCodesRegenerateModal = observer(() => {
interface BackupCodesRegenerateModalProps {
user: UserRecord;
}
export const BackupCodesRegenerateModal = observer(({user}: BackupCodesRegenerateModalProps) => {
const {t} = useLingui();
const form = useForm<FormInputs>();
const onSubmit = async () => {
const backupCodes = await MfaActionCreators.getBackupCodes(true);
ModalActionCreators.pop();
ModalActionCreators.update('backup-codes', () => modal(() => <BackupCodesModal backupCodes={backupCodes} />));
ModalActionCreators.update('backup-codes', () =>
modal(() => <BackupCodesModal backupCodes={backupCodes} user={user} />),
);
ToastActionCreators.createToast({
type: 'success',
children: t`Backup codes regenerated`,

View File

@ -26,6 +26,7 @@ import {BackupCodesModal} from '@app/components/modals/BackupCodesModal';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import type {UserRecord} from '@app/records/UserRecord';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useForm} from 'react-hook-form';
@ -34,7 +35,11 @@ interface FormInputs {
form: string;
}
export const BackupCodesViewModal = observer(() => {
interface BackupCodesViewModalProps {
user: UserRecord;
}
export const BackupCodesViewModal = observer(({user}: BackupCodesViewModalProps) => {
const {t} = useLingui();
const form = useForm<FormInputs>();
@ -42,7 +47,7 @@ export const BackupCodesViewModal = observer(() => {
const backupCodes = await MfaActionCreators.getBackupCodes();
ModalActionCreators.pop();
ModalActionCreators.pushWithKey(
modal(() => <BackupCodesModal backupCodes={backupCodes} />),
modal(() => <BackupCodesModal backupCodes={backupCodes} user={user} />),
'backup-codes',
);
};

View File

@ -26,7 +26,7 @@ import styles from '@app/components/modals/EmailChangeModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import UserStore from '@app/stores/UserStore';
import type {UserRecord} from '@app/records/UserRecord';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useEffect, useMemo, useState} from 'react';
@ -38,9 +38,12 @@ interface NewEmailForm {
email: string;
}
export const EmailChangeModal = observer(() => {
interface EmailChangeModalProps {
user: UserRecord;
}
export const EmailChangeModal = observer(({user}: EmailChangeModalProps) => {
const {t} = useLingui();
const user = UserStore.getCurrentUser()!;
const newEmailForm = useForm<NewEmailForm>({defaultValues: {email: ''}});
const [stage, setStage] = useState<Stage>('intro');
const [ticket, setTicket] = useState<string | null>(null);

View File

@ -33,7 +33,7 @@ import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {PlutoniumUpsell} from '@app/components/uikit/plutonium_upsell/PlutoniumUpsell';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import UserStore from '@app/stores/UserStore';
import type {UserRecord} from '@app/records/UserRecord';
import {LimitResolver} from '@app/utils/limits/LimitResolverAdapter';
import {isLimitToggleEnabled} from '@app/utils/limits/LimitUtils';
import {shouldShowPremiumFeatures} from '@app/utils/PremiumUtils';
@ -47,9 +47,12 @@ interface FormInputs {
discriminator: string;
}
export const FluxerTagChangeModal = observer(() => {
interface FluxerTagChangeModalProps {
user: UserRecord;
}
export const FluxerTagChangeModal = observer(({user}: FluxerTagChangeModalProps) => {
const {t} = useLingui();
const user = UserStore.getCurrentUser()!;
const usernameRef = useRef<HTMLInputElement>(null);
const hasCustomDiscriminator = isLimitToggleEnabled(
{feature_custom_discriminator: LimitResolver.resolve({key: 'feature_custom_discriminator', fallback: 0})},
@ -143,7 +146,7 @@ export const FluxerTagChangeModal = observer(() => {
ModalActionCreators.pop();
ToastActionCreators.createToast({type: 'success', children: t`FluxerTag updated`});
},
[hasCustomDiscriminator, user.username, user.discriminator],
[hasCustomDiscriminator],
);
const {handleSubmit, isSubmitting} = useFormSubmit({

View File

@ -52,6 +52,7 @@ import {Logger} from '@app/lib/Logger';
import {TextareaAutosize} from '@app/lib/TextareaAutosize';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import type {MessageRecord} from '@app/records/MessageRecord';
import type {UserRecord} from '@app/records/UserRecord';
import ChannelStore from '@app/stores/ChannelStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import UserStore from '@app/stores/UserStore';
@ -66,7 +67,12 @@ import {useCallback, useMemo, useRef, useState} from 'react';
const logger = new Logger('ForwardModal');
export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
interface ForwardModalProps {
message: MessageRecord;
user: UserRecord;
}
export const ForwardModal = observer(({message, user}: ForwardModalProps) => {
const {t} = useLingui();
const {filteredChannels, handleToggleChannel, isChannelDisabled, searchQuery, selectedChannelIds, setSearchQuery} =
useForwardChannelSelection({excludedChannelId: message.channelId});
@ -75,7 +81,6 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
const [expressionPickerOpen, setExpressionPickerOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const currentUser = UserStore.currentUser!;
const premiumMaxLength = Limits.getPremiumValue('max_message_length', MAX_MESSAGE_LENGTH_PREMIUM);
const mobileLayout = MobileLayoutStore;
@ -88,7 +93,7 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
textareaRef,
segmentManagerRef,
previousValueRef,
maxActualLength: currentUser.maxMessageLength,
maxActualLength: user.maxMessageLength,
onExceedMaxLength: handleOptionalMessageExceedsLimit,
});
@ -109,7 +114,7 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
textareaRef,
segmentManagerRef,
previousValueRef,
maxActualLength: currentUser.maxMessageLength,
maxActualLength: user.maxMessageLength,
onExceedMaxLength: handleOptionalMessageExceedsLimit,
});
@ -119,14 +124,14 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
segmentManagerRef,
setValue: setOptionalMessage,
previousValueRef,
maxMessageLength: currentUser.maxMessageLength,
maxMessageLength: user.maxMessageLength,
onPasteExceedsLimit: () => handleOptionalMessageExceedsLimit(),
});
const actualOptionalMessage = useMemo(() => displayToActual(optionalMessage), [displayToActual, optionalMessage]);
const optionalMessageDisplayMaxLength = useMemo(() => {
return Math.max(0, optionalMessage.length + (currentUser.maxMessageLength - actualOptionalMessage.length));
}, [actualOptionalMessage.length, currentUser.maxMessageLength, optionalMessage.length]);
return Math.max(0, optionalMessage.length + (user.maxMessageLength - actualOptionalMessage.length));
}, [actualOptionalMessage.length, user.maxMessageLength, optionalMessage.length]);
const handleForward = async () => {
if (selectedChannelIds.size === 0 || isForwarding) return;
@ -310,8 +315,8 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
/>
<MessageCharacterCounter
currentLength={actualOptionalMessage.length}
maxLength={currentUser.maxMessageLength}
canUpgrade={currentUser.maxMessageLength < premiumMaxLength}
maxLength={user.maxMessageLength}
canUpgrade={user.maxMessageLength < premiumMaxLength}
premiumMaxLength={premiumMaxLength}
/>
<div className={modalStyles.messageInputActions}>

View File

@ -29,7 +29,7 @@ import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {QRCodeCanvas} from '@app/components/uikit/QRCodeCanvas';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import UserStore from '@app/stores/UserStore';
import type {UserRecord} from '@app/records/UserRecord';
import * as MfaUtils from '@app/utils/MfaUtils';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
@ -40,9 +40,12 @@ interface FormInputs {
code: string;
}
export const MfaTotpEnableModal = observer(() => {
interface MfaTotpEnableModalProps {
user: UserRecord;
}
export const MfaTotpEnableModal = observer(({user}: MfaTotpEnableModalProps) => {
const {t} = useLingui();
const user = UserStore.getCurrentUser()!;
const form = useForm<FormInputs>();
const [secret] = useState(() => MfaUtils.generateTotpSecret());
@ -54,7 +57,7 @@ export const MfaTotpEnableModal = observer(() => {
ModalActionCreators.pop();
ToastActionCreators.createToast({type: 'success', children: <Trans>Two-factor authentication enabled</Trans>});
ModalActionCreators.pushWithKey(
modal(() => <BackupCodesModal backupCodes={backupCodes} />),
modal(() => <BackupCodesModal backupCodes={backupCodes} user={user} />),
'backup-codes',
);
};

View File

@ -61,6 +61,11 @@ export const MobileVideoViewer = observer(function MobileVideoViewer({
const hudTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
const safelyPlayVideo = useCallback((video: HTMLVideoElement) => {
const playPromise = video.play();
void playPromise?.catch(() => {});
}, []);
const progress = duration > 0 ? currentTime / duration : 0;
const scheduleHudHide = useCallback(() => {
@ -86,18 +91,21 @@ export const MobileVideoViewer = observer(function MobileVideoViewer({
setZoomScale(state.scale);
}, []);
const handlePlayPause = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
const video = videoRef.current;
if (!video) return;
const handlePlayPause = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const video = videoRef.current;
if (!video) return;
if (video.paused) {
video.play();
transformRef.current?.resetTransform();
} else {
video.pause();
}
}, []);
if (video.paused) {
safelyPlayVideo(video);
transformRef.current?.resetTransform();
} else {
video.pause();
}
},
[safelyPlayVideo],
);
const handleToggleMute = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@ -139,7 +147,7 @@ export const MobileVideoViewer = observer(function MobileVideoViewer({
if (initialTime && !hasInitialized) {
video.currentTime = initialTime;
setHasInitialized(true);
video.play();
safelyPlayVideo(video);
}
};
const handleDurationChange = () => setDuration(video.duration);
@ -157,7 +165,7 @@ export const MobileVideoViewer = observer(function MobileVideoViewer({
video.removeEventListener('loadedmetadata', handleLoadedMetadata);
video.removeEventListener('durationchange', handleDurationChange);
};
}, [initialTime, hasInitialized, scheduleHudHide]);
}, [initialTime, hasInitialized, scheduleHudHide, safelyPlayVideo]);
useEffect(() => {
const video = videoRef.current;

View File

@ -672,7 +672,7 @@ const MyProfileTabComponent = observer(function MyProfileTabComponent({
) : (
<div className={styles.contentLayout}>
<div className={styles.formColumn}>
{!isPerGuildProfile && <UsernameSection isClaimed={isClaimed} discriminator={user.discriminator} />}
{!isPerGuildProfile && <UsernameSection isClaimed={isClaimed} user={user} />}
{isPerGuildProfile && (
<div>

View File

@ -77,7 +77,10 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
</button>
</div>
</div>
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <EmailChangeModal />))}>
<Button
small={true}
onClick={() => ModalActionCreators.push(modal(() => <EmailChangeModal user={user} />))}
>
<Trans>Change Email</Trans>
</Button>
</div>

View File

@ -240,7 +240,10 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
<Trans>Disable</Trans>
</Button>
) : (
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <MfaTotpEnableModal />))}>
<Button
small={true}
onClick={() => ModalActionCreators.push(modal(() => <MfaTotpEnableModal user={user} />))}
>
<Trans>Enable</Trans>
</Button>
)}
@ -260,7 +263,7 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
<Button
variant="secondary"
small={true}
onClick={() => ModalActionCreators.push(modal(() => <BackupCodesViewModal />))}
onClick={() => ModalActionCreators.push(modal(() => <BackupCodesViewModal user={user} />))}
>
<Trans>View Codes</Trans>
</Button>

View File

@ -24,6 +24,7 @@ import {FluxerTagChangeModal} from '@app/components/modals/FluxerTagChangeModal'
import styles from '@app/components/modals/tabs/my_profile_tab/UsernameSection.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import type {UserRecord} from '@app/records/UserRecord';
import {LimitResolver} from '@app/utils/limits/LimitResolverAdapter';
import {isLimitToggleEnabled} from '@app/utils/limits/LimitUtils';
import {shouldShowPremiumFeatures} from '@app/utils/PremiumUtils';
@ -34,10 +35,10 @@ import {observer} from 'mobx-react-lite';
interface UsernameSectionProps {
isClaimed: boolean;
discriminator: string;
user: UserRecord;
}
export const UsernameSection = observer(({isClaimed, discriminator}: UsernameSectionProps) => {
export const UsernameSection = observer(({isClaimed, user}: UsernameSectionProps) => {
const {t} = useLingui();
const hasCustomDiscriminator = isLimitToggleEnabled(
@ -64,14 +65,14 @@ export const UsernameSection = observer(({isClaimed, discriminator}: UsernameSec
<Button
variant="primary"
small
onClick={() => ModalActionCreators.push(modal(() => <FluxerTagChangeModal />))}
onClick={() => ModalActionCreators.push(modal(() => <FluxerTagChangeModal user={user} />))}
>
<Trans>Change FluxerTag</Trans>
</Button>
)}
{!hasCustomDiscriminator && shouldShowPremiumFeatures() && (
<Tooltip text={t(msg`Customize your 4-digit tag (#${discriminator}) to your liking with Plutonium`)}>
<Tooltip text={t(msg`Customize your 4-digit tag (#${user.discriminator}) to your liking with Plutonium`)}>
<button
type="button"
onClick={() => {

View File

@ -62,7 +62,7 @@ export const UserFilterSheet: React.FC<UserFilterSheetProps> = observer(
const members = GuildMemberStore.getMembers(channel.guildId);
return members.map((m) => m.user);
}
return channel.recipientIds.map((id) => UserStore.getUser(id)).filter((u): u is UserRecord => u !== null);
return channel.recipientIds.map((id) => UserStore.getUser(id)).filter((u): u is UserRecord => u != null);
}, [channel.guildId, channel.recipientIds]);
const filteredUsers = useMemo(() => {

View File

@ -139,6 +139,14 @@ function initSentry(): void {
if (error.name === 'HTTPResponseError' || error.name === 'TimeoutError') {
return null;
}
const isBlobWorkerImportScriptsFailure =
error.name === 'NetworkError' &&
error.message.includes("Failed to execute 'importScripts' on 'WorkerGlobalScope'") &&
error.message.includes('blob:');
if (isBlobWorkerImportScriptsFailure) {
return null;
}
}
return event;
},

View File

@ -89,7 +89,7 @@ export class GuildRoleRepository extends IGuildRoleRepository {
}),
(current) => ({
pk: {guild_id: guildId, role_id: roleId},
patch: buildPatchFromData(data, current, GUILD_ROLE_COLUMNS, ['guild_id', 'role_id']),
patch: buildPatchFromData(data, oldData ?? current, GUILD_ROLE_COLUMNS, ['guild_id', 'role_id']),
}),
GuildRoles,
{initialData: oldData},

View File

@ -26,6 +26,6 @@ export abstract class IGuildRoleRepository {
abstract listRoles(guildId: GuildID): Promise<Array<GuildRole>>;
abstract listRolesByIds(roleIds: Array<RoleID>, guildId: GuildID): Promise<Array<GuildRole>>;
abstract countRoles(guildId: GuildID): Promise<number>;
abstract upsertRole(data: GuildRoleRow): Promise<GuildRole>;
abstract upsertRole(data: GuildRoleRow, oldData?: GuildRoleRow | null): Promise<GuildRole>;
abstract deleteRole(guildId: GuildID, roleId: RoleID): Promise<void>;
}

View File

@ -257,7 +257,7 @@ export class GuildRoleService {
mentionable: updateData.mentionable ?? role.isMentionable,
};
const updatedRole = await this.guildRoleRepository.upsertRole(updatedRoleData);
const updatedRole = await this.guildRoleRepository.upsertRole(updatedRoleData, role.toRow());
await this.dispatchGuildRoleUpdate({guildId, role: updatedRole});
@ -424,10 +424,14 @@ export class GuildRoleService {
const role = roleMap.get(update.roleId)!;
if (role.hoistPosition === update.hoistPosition) continue;
const updatedRole = await this.guildRoleRepository.upsertRole({
...role.toRow(),
hoist_position: update.hoistPosition,
});
const roleRow = role.toRow();
const updatedRole = await this.guildRoleRepository.upsertRole(
{
...roleRow,
hoist_position: update.hoistPosition,
},
roleRow,
);
changedRoles.push(updatedRole);
}
@ -460,10 +464,14 @@ export class GuildRoleService {
for (const role of allRoles) {
if (role.hoistPosition === null) continue;
const updatedRole = await this.guildRoleRepository.upsertRole({
...role.toRow(),
hoist_position: null,
});
const roleRow = role.toRow();
const updatedRole = await this.guildRoleRepository.upsertRole(
{
...roleRow,
hoist_position: null,
},
roleRow,
);
changedRoles.push(updatedRole);
}
@ -649,7 +657,11 @@ export class GuildRoleService {
const reorderedIds = targetOrder.map((r) => r.id);
const reorderedRoles = this.reorderRolePositions({allRoles, reorderedIds, guildId});
const updatePromises = reorderedRoles.map((role) => this.guildRoleRepository.upsertRole(role.toRow()));
const updatePromises = reorderedRoles.map((role) => {
const roleRow = role.toRow();
const oldRole = roleMap.get(role.id);
return this.guildRoleRepository.upsertRole(roleRow, oldRole ? oldRole.toRow() : undefined);
});
await Promise.all(updatePromises);
const updatedRoles = await this.guildRoleRepository.listRoles(guildId);

View File

@ -82,6 +82,7 @@ export class ChannelHelpers {
static validateChannelVoicePlacement(
finalChannels: Array<Channel>,
parentMap: Map<ChannelID, ChannelID | null>,
parentIdsToValidate?: ReadonlySet<ChannelID>,
): void {
const orderedByParent = new Map<ChannelID | null, Array<Channel>>();
@ -95,6 +96,9 @@ export class ChannelHelpers {
for (const [parentId, siblings] of orderedByParent.entries()) {
if (parentId === null) continue;
if (parentIdsToValidate && !parentIdsToValidate.has(parentId)) {
continue;
}
let encounteredVoice = false;
for (const sibling of siblings) {
if (sibling.type === ChannelTypes.GUILD_VOICE) {

View File

@ -570,11 +570,18 @@ export class ChannelOperationsService {
const desiredParentId = plan.desiredParentById.get(operation.channelId) ?? null;
const targetChannel = allChannels.find((ch) => ch.id === operation.channelId);
const currentParentId = targetChannel?.parentId ?? null;
const parentIdsToValidate = new Set<ChannelID>();
if (currentParentId) {
parentIdsToValidate.add(currentParentId);
}
if (desiredParentId) {
parentIdsToValidate.add(desiredParentId);
}
if (desiredParentId && desiredParentId !== currentParentId) {
await this.ensureCategoryHasCapacity({guildId, categoryId: desiredParentId});
}
ChannelHelpers.validateChannelVoicePlacement(plan.finalChannels, plan.desiredParentById);
ChannelHelpers.validateChannelVoicePlacement(plan.finalChannels, plan.desiredParentById, parentIdsToValidate);
if (plan.orderUnchanged) {
return;

View File

@ -18,6 +18,8 @@
*/
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createGuildID} from '@fluxer/api/src/BrandedTypes';
import {ChannelRepository} from '@fluxer/api/src/channel/ChannelRepository';
import {
acceptInvite,
addMemberRole,
@ -385,6 +387,60 @@ describe('Guild Channel Positions', () => {
expect(response.errors[0]?.code).toBe(ValidationErrorCodes.CANNOT_POSITION_CHANNEL_RELATIVE_TO_ITSELF);
});
test('should reorder top-level categories despite unrelated legacy voice and text ordering', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
const milsims = await createChannel(harness, account.token, guild.id, 'MILSIMS', ChannelTypes.GUILD_CATEGORY);
const coopGames = await createChannel(harness, account.token, guild.id, 'COOP GAMES', ChannelTypes.GUILD_CATEGORY);
const frontDoor = await createChannel(harness, account.token, guild.id, 'FRONT DOOR', ChannelTypes.GUILD_CATEGORY);
const coopText = await createChannel(harness, account.token, guild.id, 'coop-text', ChannelTypes.GUILD_TEXT);
const coopVoice = await createChannel(harness, account.token, guild.id, 'coop-voice', ChannelTypes.GUILD_VOICE);
await updateChannelPositions(harness, account.token, guild.id, [
{id: coopText.id, parent_id: coopGames.id},
{id: coopVoice.id, parent_id: coopGames.id},
]);
const channelRepository = new ChannelRepository();
const channelsBeforeMove = await channelRepository.listGuildChannels(createGuildID(BigInt(guild.id)));
const storedCoopText = channelsBeforeMove.find((channel) => channel.id.toString() === coopText.id);
const storedCoopVoice = channelsBeforeMove.find((channel) => channel.id.toString() === coopVoice.id);
expect(storedCoopText).toBeDefined();
expect(storedCoopVoice).toBeDefined();
if (!storedCoopText || !storedCoopVoice) {
return;
}
await channelRepository.upsert({...storedCoopVoice.toRow(), position: 1});
await channelRepository.upsert({...storedCoopText.toRow(), position: 2});
await updateChannelPositions(harness, account.token, guild.id, [
{
id: frontDoor.id,
parent_id: null,
preceding_sibling_id: milsims.id,
},
]);
const channels = await getGuildChannels(harness, account.token, guild.id);
const orderedRootCategories = channels
.filter((channel) => channel.type === ChannelTypes.GUILD_CATEGORY && channel.parent_id == null)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
.map((channel) => channel.id);
const milsimsIndex = orderedRootCategories.indexOf(milsims.id);
const frontDoorIndex = orderedRootCategories.indexOf(frontDoor.id);
const coopGamesIndex = orderedRootCategories.indexOf(coopGames.id);
expect(milsimsIndex).toBeGreaterThanOrEqual(0);
expect(frontDoorIndex).toBeGreaterThanOrEqual(0);
expect(coopGamesIndex).toBeGreaterThanOrEqual(0);
expect(frontDoorIndex).toBeGreaterThan(milsimsIndex);
expect(frontDoorIndex).toBeLessThan(coopGamesIndex);
});
test('should reject text channels being positioned below voice channels via preceding_sibling_id', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');

View File

@ -18,6 +18,8 @@
*/
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {createGuildID, createRoleID} from '@fluxer/api/src/BrandedTypes';
import {GuildRoleRepository} from '@fluxer/api/src/guild/repositories/GuildRoleRepository';
import {
acceptInvite,
addMemberRole,
@ -175,4 +177,43 @@ describe('Guild Role Operations', () => {
expect(newRole.name).toBe('Member Created Role');
});
test('should preserve concurrent position updates when applying stale role snapshot', async () => {
const account = await createTestAccount(harness);
const guild = await createGuild(harness, account.token, 'Test Guild');
const role = await createRole(harness, account.token, guild.id, {
name: 'Role',
});
const guildId = createGuildID(BigInt(guild.id));
const roleId = createRoleID(BigInt(role.id));
const roleRepository = new GuildRoleRepository();
const staleRole = await roleRepository.getRole(roleId, guildId);
expect(staleRole).toBeDefined();
if (!staleRole) {
return;
}
const staleRoleRow = staleRole.toRow();
const movedRole = await roleRepository.upsertRole(
{
...staleRoleRow,
position: staleRoleRow.position + 5,
},
staleRoleRow,
);
await roleRepository.upsertRole(
{
...staleRoleRow,
name: 'Renamed Role',
},
staleRoleRow,
);
const finalRole = await roleRepository.getRole(roleId, guildId);
expect(finalRole?.name).toBe('Renamed Role');
expect(finalRole?.position).toBe(movedRole.position);
});
});

View File

@ -191,6 +191,7 @@ export async function initializeWorkerDependencies(snowflakeService: SnowflakeSe
const limitConfigSubscriber = getKVClient();
const limitConfigService = new LimitConfigService(instanceConfigRepository, cacheService, limitConfigSubscriber);
await limitConfigService.initialize();
limitConfigService.setAsGlobalInstance();
const userCacheService = new UserCacheService(cacheService, userRepository);
const storageService = createStorageService({s3Service: getInjectedS3Service()});
const csamEvidenceRetentionService = new CsamEvidenceRetentionService(storageService);