Merge commit 'a706fce6781104b3aa1315be14f1fb8ccd4fcb1b' into glitch-soc/merge-upstream
This commit is contained in:
commit
0b3c848958
25 files changed
+505
-348
No files matched your search
@@ -4,6 +4,7 @@ import { useCallback, useId, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Combobox } from '@/mastodon/components/form_fields';
|
||||
import { ComboboxMenuItem } from '@/mastodon/components/form_fields/combobox_field';
|
||||
import { useSearchTags } from '@/mastodon/hooks/useSearchTags';
|
||||
import type { TagSearchResult } from '@/mastodon/hooks/useSearchTags';
|
||||
import { addFeaturedTags } from '@/mastodon/reducers/slices/profile_edit';
|
||||
@@ -77,4 +78,6 @@ export const AccountEditTagSearch: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = (item: TagSearchResult) => item.label ?? `#${item.name}`;
|
||||
const renderItem = (item: TagSearchResult) => (
|
||||
<ComboboxMenuItem>{item.label ?? `#${item.name}`}</ComboboxMenuItem>
|
||||
);
|
||||
@@ -65,16 +65,23 @@ export const ImageUploadModal: FC<
|
||||
|
||||
const handleFile = useCallback((file: File) => {
|
||||
try {
|
||||
parseImageFile(file, (result, isAnimated) => {
|
||||
if (isAnimated) {
|
||||
// If the image is animated, skip cropping and go straight to alt text.
|
||||
setImageBlob(file);
|
||||
setStep('alt');
|
||||
} else {
|
||||
setImageSrc(result);
|
||||
setStep('crop');
|
||||
// If the image is animated, skip cropping and go straight to alt text.
|
||||
if (file.type === 'image/gif') {
|
||||
setImageBlob(file);
|
||||
setStep('alt');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUri = reader.result;
|
||||
if (typeof dataUri !== 'string') {
|
||||
throw new Error('Expected a string');
|
||||
}
|
||||
});
|
||||
setImageSrc(dataUri);
|
||||
setStep('crop');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.warn('Error with image parsing:', error);
|
||||
setStep('select');
|
||||
@@ -408,54 +415,6 @@ const StepAlt: FC<{
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses an image file and determines if it's an animated GIF and returns a data URI for cropping.
|
||||
* Based on https://gist.github.com/zakirt/faa4a58cec5a7505b10e3686a226f285.
|
||||
*/
|
||||
function parseImageFile(
|
||||
file: File,
|
||||
cb: (buffer: string, isAnimated: boolean) => void,
|
||||
): void {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const buffer = reader.result;
|
||||
if (!(buffer instanceof ArrayBuffer)) {
|
||||
throw new Error('Expected an ArrayBuffer');
|
||||
}
|
||||
|
||||
// Convert the ArrayBuffer to a base64 data URI.
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const base64 = btoa(String.fromCharCode(...bytes));
|
||||
const dataUri = `data:${file.type};base64,${base64}`;
|
||||
|
||||
// If the file type is not a GIF, then it's not animated as we don't support animated WebP or PNG.
|
||||
if (file.type !== 'image/gif') {
|
||||
cb(dataUri, false);
|
||||
}
|
||||
|
||||
const view = new DataView(buffer, 10); // Start from the last 4 bytes of the Logical Screen Descriptor.
|
||||
let offset = 3;
|
||||
|
||||
// Check the first bit for the global color table flag.
|
||||
const globalColorTable = view.getInt8(0);
|
||||
if (globalColorTable & 0x08) {
|
||||
// Grab last three bits to calculate the global color table size, and skip it.
|
||||
offset += 3 * Math.pow(2, (globalColorTable & 0x7) + 1);
|
||||
}
|
||||
|
||||
// Check Graphics Control Extension and Graphics Control Label to access animated data.
|
||||
let delayTime = 0;
|
||||
if (view.getUint8(offset) & 0x21 && view.getUint8(offset + 1) & 0xf9) {
|
||||
// Skip to the delay time, which is stored in the next two bytes.
|
||||
delayTime = view.getUint16(offset + 4);
|
||||
}
|
||||
|
||||
// If there is a delay time, the GIF is animated.
|
||||
cb(dataUri, delayTime > 0);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
async function calculateCroppedImage(
|
||||
imageSrc: string,
|
||||
crop: Area,
|
||||
|
||||
@@ -4,6 +4,9 @@ import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { useComboboxItemProps } from '@/mastodon/components/form_fields/combobox_field';
|
||||
import type { ApiMutedAccountJSON } from 'mastodon/api_types/accounts';
|
||||
import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
|
||||
import { AccountListItem } from 'mastodon/components/account_list_item';
|
||||
@@ -25,6 +28,7 @@ import {
|
||||
import { useAccount } from 'mastodon/hooks/useAccount';
|
||||
import { useSearchAccounts } from 'mastodon/hooks/useSearchAccounts';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import type { Relationship } from 'mastodon/models/relationship';
|
||||
import {
|
||||
addCollectionItem,
|
||||
getCollectionItemIds,
|
||||
@@ -64,18 +68,18 @@ const AddedAccountItem: React.FC<{
|
||||
const SuggestedAccountItem: React.FC<{ id: string }> = ({ id }) => {
|
||||
const account = useAccount(id);
|
||||
const handle = useAccountHandle(account, domain);
|
||||
const comboboxItemProps = useComboboxItemProps();
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<ListItemWrapper
|
||||
className={classes.suggestion}
|
||||
icon={<Avatar account={account} size={40} />}
|
||||
>
|
||||
<ListItemContent subtitle={handle}>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
</ListItemContent>
|
||||
</ListItemWrapper>
|
||||
<li {...comboboxItemProps} className={classes.suggestion}>
|
||||
<ListItemWrapper icon={<Avatar account={account} size={40} />}>
|
||||
<ListItemContent subtitle={handle}>
|
||||
<DisplayName account={account} variant='simple' />
|
||||
</ListItemContent>
|
||||
</ListItemWrapper>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -85,19 +89,37 @@ const renderAccountItem = (account: ApiMutedAccountJSON) => (
|
||||
|
||||
type GroupKey = 'available' | 'mustFollow' | 'disabled';
|
||||
|
||||
function groupSuggestions(accounts: ApiMutedAccountJSON[]) {
|
||||
const { available, disabled } = Object.groupBy(accounts, (account) => {
|
||||
if (getIsItemDisabled(account)) {
|
||||
const canAccountBeAdded = (account: ApiMutedAccountJSON) =>
|
||||
['automatic', 'manual'].includes(account.feature_approval.current_user);
|
||||
|
||||
function groupSuggestions(
|
||||
accounts: ApiMutedAccountJSON[],
|
||||
relationships: ImmutableMap<string, Relationship>,
|
||||
) {
|
||||
const { available, mustFollow, disabled } = Object.groupBy(
|
||||
accounts,
|
||||
(account): GroupKey => {
|
||||
if (canAccountBeAdded(account)) {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
const canAccountBeAddedByFollowers =
|
||||
account.feature_approval.automatic.includes('followers') ||
|
||||
account.feature_approval.manual.includes('followers');
|
||||
|
||||
if (
|
||||
canAccountBeAddedByFollowers &&
|
||||
!relationships.get(account.id)?.following
|
||||
) {
|
||||
return 'mustFollow';
|
||||
}
|
||||
|
||||
return 'disabled';
|
||||
}
|
||||
// if (account.locked && !relationship?.following) {
|
||||
// return 'mustFollow';
|
||||
// }
|
||||
return 'available';
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Returning a new object ensures a fixed property order
|
||||
return { available, disabled };
|
||||
return { available, mustFollow, disabled };
|
||||
}
|
||||
|
||||
const renderGroupTitle = (groupKey: GroupKey, titleId: string) => {
|
||||
@@ -137,19 +159,19 @@ const renderGroupTitle = (groupKey: GroupKey, titleId: string) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItemWrapper className={classes.suggestionGroup}>
|
||||
<ListItemContent id={titleId} subtitle={description}>
|
||||
{title}
|
||||
</ListItemContent>
|
||||
</ListItemWrapper>
|
||||
<li role='presentation'>
|
||||
<ListItemWrapper className={classes.suggestionGroup}>
|
||||
<ListItemContent id={titleId} subtitle={description}>
|
||||
{title}
|
||||
</ListItemContent>
|
||||
</ListItemWrapper>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const getItemId = (account: ApiMutedAccountJSON) => account.id;
|
||||
|
||||
// Disable accounts who can't be added to a collection
|
||||
const getIsItemDisabled = (account: ApiMutedAccountJSON) =>
|
||||
!['automatic', 'manual'].includes(account.feature_approval.current_user);
|
||||
!canAccountBeAdded(account);
|
||||
|
||||
export const CollectionAccounts: React.FC<{
|
||||
collection?: ApiCollectionJSON | null;
|
||||
@@ -189,6 +211,10 @@ export const CollectionAccounts: React.FC<{
|
||||
filterResults: (account) => !accountIds.includes(account.id),
|
||||
});
|
||||
|
||||
const relationships = useAppSelector((state) => state.relationships);
|
||||
|
||||
const groupedItems = groupSuggestions(suggestedAccounts, relationships);
|
||||
|
||||
const handleSearchValueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchValue(e.target.value);
|
||||
@@ -330,7 +356,7 @@ export const CollectionAccounts: React.FC<{
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
disabled={hasMaxAccounts}
|
||||
isLoading={isLoadingSuggestions}
|
||||
items={groupSuggestions(suggestedAccounts)}
|
||||
items={groupedItems}
|
||||
getItemId={getItemId}
|
||||
getIsItemDisabled={getIsItemDisabled}
|
||||
renderItem={renderAccountItem}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { isFulfilled } from '@reduxjs/toolkit';
|
||||
|
||||
import { ComboboxMenuItem } from '@/mastodon/components/form_fields/combobox_field';
|
||||
import { languages } from '@/mastodon/initial_state';
|
||||
import {
|
||||
hasSpecialCharacters,
|
||||
@@ -373,7 +374,9 @@ const TopicField: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderTagItem = (item: TagSearchResult) => item.label ?? `#${item.name}`;
|
||||
const renderTagItem = (item: TagSearchResult) => (
|
||||
<ComboboxMenuItem>{item.label ?? `#${item.name}`}</ComboboxMenuItem>
|
||||
);
|
||||
|
||||
const LanguageField: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -60,17 +60,20 @@
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
padding: 4px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
|
||||
[aria-disabled='true'] & {
|
||||
&[data-highlighted='true'] {
|
||||
background: var(--color-bg-overlay-highlight);
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionGroup {
|
||||
padding: 4px 0;
|
||||
|
||||
// Undo default group styles:
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
Reference in new issue
Block a user