Fun picker improvements

This commit is contained in:
Jamie Kyle
2025-03-26 12:35:32 -07:00
committed by GitHub
parent 427f91f903
commit b0653d06fe
142 changed files with 3581 additions and 1280 deletions

View File

@@ -52,8 +52,24 @@ export enum EmojiSkinTone {
Type5 = 'EmojiSkinTone.Type5', // 1F3FF
}
export function isValidEmojiSkinTone(value: unknown): value is EmojiSkinTone {
return (
typeof value === 'string' &&
EMOJI_SKIN_TONE_ORDER.includes(value as EmojiSkinTone)
);
}
export const EMOJI_SKIN_TONE_ORDER: ReadonlyArray<EmojiSkinTone> = [
EmojiSkinTone.None,
EmojiSkinTone.Type1,
EmojiSkinTone.Type2,
EmojiSkinTone.Type3,
EmojiSkinTone.Type4,
EmojiSkinTone.Type5,
];
/** @deprecated We should use `EmojiSkinTone` everywhere */
export const SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
export const EMOJI_SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
[EmojiSkinTone.None, 0],
[EmojiSkinTone.Type1, 1],
[EmojiSkinTone.Type2, 2],
@@ -63,24 +79,22 @@ export const SKIN_TONE_TO_NUMBER: Map<EmojiSkinTone, number> = new Map([
]);
/** @deprecated We should use `EmojiSkinTone` everywhere */
export const NUMBER_TO_SKIN_TONE: Map<number, EmojiSkinTone> = new Map([
[0, EmojiSkinTone.None],
[1, EmojiSkinTone.Type1],
[2, EmojiSkinTone.Type2],
[3, EmojiSkinTone.Type3],
[4, EmojiSkinTone.Type4],
[5, EmojiSkinTone.Type5],
export const KEY_TO_EMOJI_SKIN_TONE = new Map<string, EmojiSkinTone>([
['1F3FB', EmojiSkinTone.Type1],
['1F3FC', EmojiSkinTone.Type2],
['1F3FD', EmojiSkinTone.Type3],
['1F3FE', EmojiSkinTone.Type4],
['1F3FF', EmojiSkinTone.Type5],
]);
export type EmojiSkinToneVariant = Exclude<EmojiSkinTone, EmojiSkinTone.None>;
const KeyToEmojiSkinTone: Record<string, EmojiSkinToneVariant> = {
'1F3FB': EmojiSkinTone.Type1,
'1F3FC': EmojiSkinTone.Type2,
'1F3FD': EmojiSkinTone.Type3,
'1F3FE': EmojiSkinTone.Type4,
'1F3FF': EmojiSkinTone.Type5,
};
/** @deprecated We should use `EmojiSkinTone` everywhere */
export const EMOJI_SKIN_TONE_TO_KEY: Map<EmojiSkinTone, string> = new Map([
[EmojiSkinTone.Type1, '1F3FB'],
[EmojiSkinTone.Type2, '1F3FC'],
[EmojiSkinTone.Type3, '1F3FD'],
[EmojiSkinTone.Type4, '1F3FE'],
[EmojiSkinTone.Type5, '1F3FF'],
]);
export type EmojiParentKey = string & { EmojiParentKey: never };
export type EmojiVariantKey = string & { EmojiVariantKey: never };
@@ -94,18 +108,17 @@ export type EmojiEnglishShortName = string & { EmojiEnglishShortName: never };
export type EmojiVariantData = Readonly<{
key: EmojiVariantKey;
value: EmojiVariantValue;
valueNonqualified: EmojiVariantValue | null;
sheetX: number;
sheetY: number;
}>;
type EmojiDefaultSkinToneVariants = Record<
EmojiSkinToneVariant,
EmojiVariantKey
>;
type EmojiDefaultSkinToneVariants = Record<EmojiSkinTone, EmojiVariantKey>;
export type EmojiParentData = Readonly<{
key: EmojiParentKey;
value: EmojiParentValue;
valueNonqualified: EmojiParentValue | null;
unicodeCategory: EmojiUnicodeCategory;
pickerCategory: EmojiPickerCategory | null;
defaultVariant: EmojiVariantKey;
@@ -124,6 +137,7 @@ export type EmojiParentData = Readonly<{
const RawEmojiSkinToneSchema = z.object({
unified: z.string(),
non_qualified: z.union([z.string(), z.null()]),
sheet_x: z.number(),
sheet_y: z.number(),
has_img_apple: z.boolean(),
@@ -133,6 +147,7 @@ const RawEmojiSkinToneMapSchema = z.record(z.string(), RawEmojiSkinToneSchema);
const RawEmojiSchema = z.object({
unified: z.string(),
non_qualified: z.union([z.string(), z.null()]),
category: z.string(),
sort_order: z.number(),
sheet_x: z.number(),
@@ -282,6 +297,9 @@ const EMOJI_INDEX: EmojiIndex = {
function addParent(parent: EmojiParentData, rank: number) {
EMOJI_INDEX.parentByKey[parent.key] = parent;
EMOJI_INDEX.parentKeysByValue[parent.value] = parent.key;
if (parent.valueNonqualified != null) {
EMOJI_INDEX.parentKeysByValue[parent.valueNonqualified] = parent.key;
}
EMOJI_INDEX.parentKeysByName[parent.englishShortNameDefault] = parent.key;
EMOJI_INDEX.unicodeCategories[parent.unicodeCategory].push(parent.key);
if (parent.pickerCategory != null) {
@@ -306,6 +324,9 @@ function addVariant(parentKey: EmojiParentKey, variant: EmojiVariantData) {
EMOJI_INDEX.parentKeysByVariantKeys[variant.key] = parentKey;
EMOJI_INDEX.variantByKey[variant.key] = variant;
EMOJI_INDEX.variantKeysByValue[variant.value] = variant.key;
if (variant.valueNonqualified) {
EMOJI_INDEX.variantKeysByValue[variant.valueNonqualified] = variant.key;
}
}
for (const rawEmoji of RAW_EMOJI_DATA) {
@@ -314,6 +335,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
const defaultVariant: EmojiVariantData = {
key: toEmojiVariantKey(rawEmoji.unified),
value: toEmojiVariantValue(rawEmoji.unified),
valueNonqualified:
rawEmoji.non_qualified != null
? toEmojiVariantValue(rawEmoji.non_qualified)
: null,
sheetX: rawEmoji.sheet_x,
sheetY: rawEmoji.sheet_y,
};
@@ -331,6 +356,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
const skinToneVariant: EmojiVariantData = {
key: variantKey,
value: toEmojiVariantValue(value.unified),
valueNonqualified:
rawEmoji.non_qualified != null
? toEmojiVariantValue(rawEmoji.non_qualified)
: null,
sheetX: value.sheet_x,
sheetY: value.sheet_y,
};
@@ -339,7 +368,7 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
}
const result: Partial<EmojiDefaultSkinToneVariants> = {};
for (const [key, skinTone] of Object.entries(KeyToEmojiSkinTone)) {
for (const [key, skinTone] of KEY_TO_EMOJI_SKIN_TONE) {
const one = map.get(key) ?? null;
const two = map.get(`${key}-${key}`) ?? null;
const variantKey = one ?? two;
@@ -356,6 +385,10 @@ for (const rawEmoji of RAW_EMOJI_DATA) {
const parent: EmojiParentData = {
key: toEmojiParentKey(rawEmoji.unified),
value: toEmojiParentValue(rawEmoji.unified),
valueNonqualified:
rawEmoji.non_qualified != null
? toEmojiParentValue(rawEmoji.non_qualified)
: null,
unicodeCategory: toEmojiUnicodeCategory(rawEmoji.category),
pickerCategory: toEmojiPickerCategory(rawEmoji.category),
defaultVariant: defaultVariant.key,
@@ -404,16 +437,6 @@ export function getEmojiVariantByKey(key: EmojiVariantKey): EmojiVariantData {
return data;
}
export function getEmojiParentKeyByValueUnsafe(input: string): EmojiParentKey {
strictAssert(
isEmojiParentValue(input),
`Missing emoji parent value for input "${input}"`
);
const key = EMOJI_INDEX.parentKeysByValue[input];
strictAssert(key, `Missing emoji parent key for input "${input}"`);
return key;
}
export function getEmojiParentKeyByValue(
value: EmojiParentValue
): EmojiParentKey {
@@ -492,6 +515,14 @@ export function* _allEmojiVariantKeys(): Iterable<EmojiVariantKey> {
yield* Object.keys(EMOJI_INDEX.variantByKey) as Array<EmojiVariantKey>;
}
export function emojiParentKeyConstant(input: string): EmojiParentKey {
strictAssert(
isEmojiParentValue(input),
`Missing emoji parent for value "${input}"`
);
return getEmojiParentKeyByValue(input);
}
export function emojiVariantConstant(input: string): EmojiVariantData {
strictAssert(
isEmojiVariantValue(input),

View File

@@ -10,7 +10,7 @@ import type {
} from './tenor';
import { tenor, isTenorTailCursor } from './tenor';
const PREVIEW_CONTENT_FORMAT: TenorContentFormat = 'mediumgif';
const PREVIEW_CONTENT_FORMAT: TenorContentFormat = 'tinymp4';
const ATTACHMENT_CONTENT_FORMAT: TenorContentFormat = 'mp4';
function toGif(result: TenorResponseResult): GifType {
@@ -40,7 +40,7 @@ export type GifsPaginated = Readonly<{
gifs: ReadonlyArray<GifType>;
}>;
export async function fetchFeatured(
export async function fetchGifsFeatured(
limit: number,
cursor: TenorNextCursor | null,
signal?: AbortSignal
@@ -48,7 +48,7 @@ export async function fetchFeatured(
const response = await tenor(
'v2/featured',
{
// contentfilter: 'medium',
contentfilter: 'low',
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
limit,
pos: cursor ?? undefined,
@@ -61,7 +61,7 @@ export async function fetchFeatured(
return { next, gifs };
}
export async function fetchSearch(
export async function fetchGifsSearch(
query: string,
limit: number,
cursor: TenorNextCursor | null,
@@ -71,7 +71,7 @@ export async function fetchSearch(
'v2/search',
{
q: query,
contentfilter: 'medium',
contentfilter: 'low',
media_filter: [PREVIEW_CONTENT_FORMAT, ATTACHMENT_CONTENT_FORMAT],
limit,
pos: cursor ?? undefined,

View File

@@ -52,7 +52,7 @@ export function useInfiniteQuery<Query, Page>(
const [edition, setEdition] = useState(0);
const [state, setState] = useState<InfiniteQueryState<Query, Page>>({
query: options.query,
pending: false,
pending: true,
rejected: false,
pages: [],
hasNextPage: false,

View File

@@ -0,0 +1,132 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { strictAssert } from '../../../util/assert';
/** @internal Exported for testing */
export const _SEGMENT_SIZE_BUCKETS: ReadonlyArray<number> = [
// highest to lowest
1024 * 1024, // 1MiB
1024 * 500, // 500 KiB
1024 * 100, // 100 KiB
1024 * 50, // 50 KiB
1024 * 10, // 10 KiB
1024 * 1, // 1 KiB
];
/** @internal Exported for testing */
export type _SegmentRange = Readonly<{
startIndex: number;
endIndexInclusive: number;
sliceStart: number;
segmentSize: number;
sliceSize: number;
}>;
async function fetchContentLength(
url: string,
signal?: AbortSignal
): Promise<number> {
const { messaging } = window.textsecure;
strictAssert(messaging, 'Missing window.textsecure.messaging');
const { response } = await messaging.server.fetchBytesViaProxy({
url,
method: 'HEAD',
signal,
});
const contentLength = Number(response.headers.get('Content-Length'));
strictAssert(
Number.isInteger(contentLength),
'Content-Length must be integer'
);
return contentLength;
}
/** @internal Exported for testing */
export function _getSegmentSize(contentLength: number): number {
const nextLargestSegmentSize = _SEGMENT_SIZE_BUCKETS.find(segmentSize => {
return contentLength >= segmentSize;
});
// If too small, return the content length
return nextLargestSegmentSize ?? contentLength;
}
/** @internal Exported for testing */
export function _getSegmentRanges(
contentLength: number,
segmentSize: number
): ReadonlyArray<_SegmentRange> {
const segmentRanges: Array<_SegmentRange> = [];
const segmentCount = Math.ceil(contentLength / segmentSize);
for (let index = 0; index < segmentCount; index += 1) {
let startIndex = segmentSize * index;
let endIndexInclusive = startIndex + segmentSize - 1;
let sliceSize = segmentSize;
let sliceStart = 0;
if (endIndexInclusive > contentLength) {
endIndexInclusive = contentLength - 1;
startIndex = contentLength - segmentSize;
sliceSize = contentLength % segmentSize;
sliceStart = segmentSize - sliceSize;
}
segmentRanges.push({
startIndex,
endIndexInclusive,
sliceStart,
segmentSize,
sliceSize,
});
}
return segmentRanges;
}
async function fetchSegment(
url: string,
segmentRange: _SegmentRange,
signal?: AbortSignal
): Promise<ArrayBufferView> {
const { messaging } = window.textsecure;
strictAssert(messaging, 'Missing window.textsecure.messaging');
const { data } = await messaging.server.fetchBytesViaProxy({
method: 'GET',
url,
signal,
headers: {
Range: `bytes=${segmentRange.startIndex}-${segmentRange.endIndexInclusive}`,
},
});
strictAssert(
data.buffer.byteLength === segmentRange.segmentSize,
'Response buffer should be exact length of segment range'
);
let slice: ArrayBufferView;
// Trim duplicate bytes from start of last segment
if (segmentRange.sliceStart > 0) {
slice = new Uint8Array(data.buffer.slice(segmentRange.sliceStart));
} else {
slice = data;
}
strictAssert(
slice.byteLength === segmentRange.sliceSize,
'Slice buffer should be exact length of segment range slice'
);
return slice;
}
export async function fetchInSegments(
url: string,
signal?: AbortSignal
): Promise<Blob> {
const contentLength = await fetchContentLength(url, signal);
const segmentSize = _getSegmentSize(contentLength);
const segmentRanges = _getSegmentRanges(contentLength, segmentSize);
const segmentBuffers = await Promise.all(
segmentRanges.map(segmentRange => {
return fetchSegment(url, segmentRange, signal);
})
);
return new Blob(segmentBuffers);
}

View File

@@ -5,6 +5,7 @@ import { z } from 'zod';
import type { Simplify } from 'type-fest';
import { strictAssert } from '../../../util/assert';
import { parseUnknown } from '../../../util/schemas';
import { fetchInSegments } from './segments';
const BASE_URL = 'https://tenor.googleapis.com/v2';
const API_KEY = 'AIzaSyBt6SUfSsCQic2P2VkNkLjsGI7HGWZI95g';
@@ -215,23 +216,18 @@ export async function tenor<Path extends keyof TenorEndpoints>(
url.searchParams.set(key, param);
}
const response = await messaging.server.fetchJsonViaProxy(
url.toString(),
signal
);
const response = await messaging.server.fetchJsonViaProxy({
method: 'GET',
url: url.toString(),
signal,
});
const result = parseUnknown(schema, response.data);
return result;
}
export async function tenorDownload(
export function tenorDownload(
tenorCdnUrl: string,
signal?: AbortSignal
): Promise<Uint8Array> {
const { messaging } = window.textsecure;
strictAssert(messaging, 'Missing window.textsecure.messaging');
const response = await messaging.server.fetchBytesViaProxy(
tenorCdnUrl,
signal
);
return response.data;
): Promise<Blob> {
return fetchInSegments(tenorCdnUrl, signal);
}