initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.text {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.4;
}
.guildRow {
display: flex;
align-items: center;
gap: 0;
}
.guildIcon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 0.25rem;
--guild-icon-size: 1.25rem;
}
.guildName {
color: var(--text-primary);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 0.125rem;
}
.verifiedIcon {
width: 1rem;
height: 1rem;
flex-shrink: 0;
color: var(--text-primary);
margin-left: 0.125rem;
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {SealCheckIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {GuildFeatures} from '~/Constants';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {Emoji} from '~/stores/EmojiStore';
import GuildListStore from '~/stores/GuildListStore';
import GuildStore from '~/stores/GuildStore';
import styles from './EmojiInfoContent.module.css';
interface EmojiInfoContentProps {
emoji: Emoji;
}
export const EmojiInfoContent = observer(function EmojiInfoContent({emoji}: EmojiInfoContentProps) {
const {t} = useLingui();
const isCustomEmoji = Boolean(emoji.guildId || emoji.id);
if (!isCustomEmoji) {
return (
<div className={styles.container}>
<span className={styles.text}>
<Trans>This is a default emoji on Fluxer.</Trans>
</span>
</div>
);
}
const guildId = emoji.guildId;
const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false;
if (!isMember) {
return (
<div className={styles.container}>
<span className={styles.text}>
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
</span>
</div>
);
}
const guild = guildId ? GuildStore.getGuild(guildId) : null;
if (!guild) {
return (
<div className={styles.container}>
<span className={styles.text}>
<Trans>This is a custom emoji from a community.</Trans>
</span>
</div>
);
}
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
return (
<div className={styles.container}>
<span className={styles.text}>
<Trans>This is a custom emoji from</Trans>
</span>
<div className={styles.guildRow}>
<div className={styles.guildIcon}>
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} />
</div>
<span className={styles.guildName}>{guild.name}</span>
{isVerified && (
<Tooltip text={t`Verified Community`} position="top">
<SealCheckIcon className={styles.verifiedIcon} />
</Tooltip>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,371 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.header {
display: none;
grid-template-columns: 72px minmax(200px, 1fr) minmax(180px, 0.85fr);
align-items: center;
gap: 0.75rem;
padding: 0 0.75rem 0.5rem calc(0.75rem + 1px);
}
.headerCell:first-child {
text-align: center;
}
.headerCell:nth-child(2) {
padding-left: 8px;
}
.headerCell {
font-weight: 600;
color: var(--text-primary-muted);
font-size: 0.75rem;
text-transform: uppercase;
}
@media (min-width: 640px) {
.header {
display: grid;
}
}
.card {
position: relative;
border-radius: 0.375rem;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
padding: 0.75rem;
transition:
border-color 150ms ease,
box-shadow 150ms ease;
}
.card:hover {
border-color: var(--background-modifier-accent);
box-shadow: 0 10px 25px -18px rgb(0 0 0 / 0.4);
}
.cardWrapper {
position: relative;
overflow: visible;
}
.gridCardWrapper,
.listCardWrapper {
position: relative;
overflow: visible;
}
.deleteButton {
position: absolute;
top: 0;
right: 0;
transform: translate(40%, -40%);
border-radius: 9999px;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-primary);
padding: 0.5rem;
color: var(--text-primary-muted);
opacity: 0;
z-index: 2;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
transition:
opacity 150ms,
background-color 150ms,
border-color 150ms,
color 150ms;
cursor: pointer;
}
.card:hover .deleteButton {
opacity: 1;
}
.cardWrapper:hover .deleteButton {
opacity: 1;
}
.deleteButton:focus-visible {
opacity: 1;
}
.deleteButton:hover {
border-color: var(--status-danger);
background-color: var(--status-danger);
color: white;
}
.deleteIcon {
height: 0.75rem;
width: 0.75rem;
}
.deleteButtonFloating {
box-shadow: none;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary);
transform: translate(40%, -40%);
}
.listCard {
display: grid;
grid-template-columns: 72px minmax(200px, 1fr) minmax(180px, 0.85fr);
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
}
@media (max-width: 640px) {
.listCard {
grid-template-columns: 1fr;
align-items: flex-start;
gap: 0.5rem;
}
}
.listEmoji {
display: flex;
align-items: center;
justify-content: center;
}
.listEmojiImage {
height: 2.5rem;
width: 2.5rem;
object-fit: contain;
image-rendering: pixelated;
}
.nameInlineEdit {
width: 100%;
max-width: 100%;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
overflow: hidden;
}
.nameInlineEditButton {
max-width: 100%;
justify-content: center;
width: 100%;
}
.nameInlineEditInput {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.listName {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-start;
text-align: left;
overflow: hidden;
overflow-wrap: anywhere;
word-break: break-word;
}
.listName .nameInlineEdit {
justify-content: flex-start;
text-align: left;
}
.listName .nameInlineEditButton {
justify-content: flex-start;
text-align: left;
}
.listName .nameInlineEditInput {
text-align: left;
}
.nameInlineEdit[data-mode='idle'] .nameInlineEditInput {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.nameInlineEdit[data-mode='editing'] .nameInlineEditInput,
.nameInlineEdit[data-mode='saving'] .nameInlineEditInput {
text-overflow: clip;
white-space: normal;
overflow: visible;
word-break: break-word;
max-width: min(22ch, 100%);
}
.listUploader {
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
}
.avatar {
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
border-radius: 9999px;
}
.username {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
line-height: 1.25rem;
max-height: 1.25rem;
}
.unknownUser {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.gridCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
text-align: center;
width: 100%;
}
.gridEmojiWrapper {
position: relative;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.gridEmojiImage {
height: 3rem;
width: 3rem;
object-fit: contain;
image-rendering: pixelated;
}
.gridAvatar {
position: absolute;
top: -0.35rem;
left: -0.35rem;
height: 1.75rem;
width: 1.75rem;
border-radius: 9999px;
border: 2px solid var(--background-secondary);
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.15),
0 2px 4px -2px rgb(0 0 0 / 0.12);
background-color: var(--background-secondary);
object-fit: cover;
}
.gridName {
width: 100%;
font-weight: 600;
display: grid;
place-items: center;
text-align: center;
min-height: 1.5rem;
min-width: 0;
}
.gridNameText {
width: 100%;
max-width: 16ch;
min-width: 0;
display: block;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gridNameButton {
width: 100%;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.15rem 0.25rem;
border: none;
background: none;
color: inherit;
cursor: pointer;
text-align: center;
overflow: hidden;
}
.gridNameButton:hover {
color: var(--text-primary);
}
.renamePopout {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 0.5rem;
background-color: var(--background-primary);
border: 1px solid var(--background-modifier-accent);
box-shadow:
0 20px 38px -12px rgb(0 0 0 / 0.3),
0 8px 16px -8px rgb(0 0 0 / 0.25);
min-width: min(280px, 90vw);
}
.renamePopoutHeader {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.renamePopoutTitle {
font-weight: 700;
font-size: 0.95rem;
}
.renamePopoutHint {
color: var(--text-primary-muted);
font-size: 0.85rem;
}
.renamePopoutActions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}

View File

@@ -0,0 +1,299 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {XIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GuildEmojiActionCreators from '~/actions/GuildEmojiActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {GuildFeatures} from '~/Constants';
import {Input} from '~/components/form/Input';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {InlineEdit} from '~/components/uikit/InlineEdit';
import {Popout} from '~/components/uikit/Popout/Popout';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {GuildEmojiWithUser} from '~/records/GuildEmojiRecord';
import GuildStore from '~/stores/GuildStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import styles from './EmojiListItem.module.css';
interface EmojiRenamePopoutContentProps {
initialName: string;
onSave: (newName: string) => Promise<void>;
onClose: () => void;
}
const EmojiRenamePopoutContent: React.FC<EmojiRenamePopoutContentProps> = ({initialName, onSave, onClose}) => {
const {t} = useLingui();
const [draft, setDraft] = React.useState(initialName);
const [isSaving, setIsSaving] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
React.useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
const sanitizedDraft = draft.replace(/[^a-zA-Z0-9_]/g, '');
const isDraftValid = sanitizedDraft.length >= 2 && sanitizedDraft.length <= 32;
const handleSubmit = async () => {
if (!isDraftValid || isSaving) return;
setIsSaving(true);
try {
await onSave(sanitizedDraft);
onClose();
} finally {
setIsSaving(false);
}
};
const handleInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
const {value, selectionStart, selectionEnd} = e.target;
const next = value.replace(/[^a-zA-Z0-9_]/g, '');
const removed = value.length - next.length;
setDraft(next);
if (inputRef.current && selectionStart !== null && selectionEnd !== null) {
const newStart = Math.max(0, selectionStart - removed);
const newEnd = Math.max(0, selectionEnd - removed);
requestAnimationFrame(() => inputRef.current?.setSelectionRange(newStart, newEnd));
}
};
return (
<form
className={styles.renamePopout}
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
>
<div className={styles.renamePopoutHeader}>
<span className={styles.renamePopoutTitle}>
<Trans>Rename Emoji</Trans>
</span>
<span className={styles.renamePopoutHint}>
<Trans>2-32 characters, letters, numbers, underscores.</Trans>
</span>
</div>
<Input
autoFocus
ref={inputRef}
value={draft}
onChange={handleInputChange}
maxLength={32}
placeholder={t`Emoji name`}
/>
<div className={styles.renamePopoutActions}>
<Button
variant="secondary"
type="button"
small
onClick={() => {
setDraft(initialName);
onClose();
}}
>
<Trans>Cancel</Trans>
</Button>
<Button variant="primary" type="submit" small disabled={!isDraftValid || isSaving} submitting={isSaving}>
<Trans>Save</Trans>
</Button>
</div>
</form>
);
};
export const EmojiListHeader: React.FC = observer(() => (
<div className={styles.header}>
<div className={styles.headerCell}>
<Trans>Emoji</Trans>
</div>
<div className={styles.headerCell}>
<Trans>Name</Trans>
</div>
<div className={styles.headerCell}>
<Trans>Uploaded By</Trans>
</div>
</div>
));
export const EmojiListItem: React.FC<{
guildId: string;
emoji: GuildEmojiWithUser;
layout: 'list' | 'grid';
onRename: (emojiId: string, newName: string) => void;
onRemove: (emojiId: string) => void;
}> = observer(({guildId, emoji, layout, onRename, onRemove}) => {
const {t} = useLingui();
const avatarUrl = emoji.user ? AvatarUtils.getUserAvatarURL(emoji.user, false) : null;
const gridNameButtonRef = React.useRef<HTMLButtonElement | null>(null);
const handleSave = async (newName: string) => {
const sanitizedName = newName.replace(/[^a-zA-Z0-9_]/g, '');
if (sanitizedName.length < 2) {
ToastActionCreators.error(t`Emoji name must be at least 2 characters long`);
throw new Error('Name too short');
}
if (sanitizedName.length > 32) {
ToastActionCreators.error(t`Emoji name must be at most 32 characters long`);
throw new Error('Name too long');
}
if (sanitizedName === emoji.name) return;
const prevName = emoji.name;
onRename(emoji.id, sanitizedName);
try {
await GuildEmojiActionCreators.update(guildId, emoji.id, {name: sanitizedName});
} catch (err) {
onRename(emoji.id, prevName);
console.error('Failed to update emoji name:', err);
ToastActionCreators.error(t`Failed to update emoji name. Reverted to the previous name.`);
throw err;
}
};
const guild = GuildStore.getGuild(guildId);
const canExpressionPurge = guild?.features.has(GuildFeatures.EXPRESSION_PURGE_ALLOWED) ?? false;
const handleDelete = () => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Delete Emoji`}
description={t`Are you sure you want to delete :${emoji.name}:? This action cannot be undone.`}
primaryText={t`Delete`}
primaryVariant="danger-primary"
checkboxContent={
canExpressionPurge ? <Checkbox>{t`Purge this emoji from storage and CDN`}</Checkbox> : undefined
}
onPrimary={async (checkboxChecked = false) => {
await GuildEmojiActionCreators.remove(guildId, emoji.id, checkboxChecked && canExpressionPurge);
onRemove(emoji.id);
}}
/>
)),
);
};
const emojiUrl = AvatarUtils.getEmojiURL({id: emoji.id, animated: emoji.animated});
if (layout === 'grid') {
return (
<div className={clsx(styles.cardWrapper, styles.gridCardWrapper)}>
<div className={clsx(styles.card, styles.gridCard)}>
<div className={styles.gridEmojiWrapper}>
<img src={emojiUrl} alt={emoji.name} className={styles.gridEmojiImage} loading="lazy" />
{emoji.user && avatarUrl && (
<Tooltip text={emoji.user.username}>
<img src={avatarUrl} alt="" className={styles.gridAvatar} loading="lazy" />
</Tooltip>
)}
</div>
<div className={styles.gridName}>
<Popout
position="bottom"
offsetMainAxis={8}
offsetCrossAxis={0}
returnFocusRef={gridNameButtonRef}
render={({onClose}) => (
<EmojiRenamePopoutContent initialName={emoji.name} onSave={handleSave} onClose={onClose} />
)}
>
<button
type="button"
ref={gridNameButtonRef}
className={styles.gridNameButton}
aria-label={t`Rename :${emoji.name}:`}
>
<span className={styles.gridNameText}>:{emoji.name}:</span>
</button>
</Popout>
</div>
</div>
<Tooltip text={t`Delete`}>
<FocusRing offset={-2}>
<button
type="button"
onClick={handleDelete}
className={clsx(styles.deleteButton, styles.deleteButtonFloating)}
>
<XIcon className={styles.deleteIcon} weight="bold" />
</button>
</FocusRing>
</Tooltip>
</div>
);
}
return (
<div className={clsx(styles.cardWrapper, styles.listCardWrapper)}>
<div className={clsx(styles.card, styles.listCard)}>
<div className={styles.listEmoji}>
<img src={emojiUrl} alt={emoji.name} className={styles.listEmojiImage} loading="lazy" />
</div>
<div className={styles.listName}>
<InlineEdit
value={emoji.name}
onSave={handleSave}
prefix=":"
suffix=":"
maxLength={32}
width="100%"
className={styles.nameInlineEdit}
inputClassName={styles.nameInlineEditInput}
buttonClassName={styles.nameInlineEditButton}
/>
</div>
<div className={styles.listUploader}>
{emoji.user && avatarUrl ? (
<>
<img src={avatarUrl} alt="" className={styles.avatar} loading="lazy" />
<span className={styles.username}>{emoji.user.username}</span>
</>
) : (
<span className={styles.unknownUser}>
<Trans>Unknown</Trans>
</span>
)}
</div>
</div>
<Tooltip text={t`Delete`}>
<FocusRing offset={-2}>
<button type="button" onClick={handleDelete} className={styles.deleteButton}>
<XIcon className={styles.deleteIcon} weight="bold" />
</button>
</FocusRing>
</Tooltip>
</div>
);
});