mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 20:26:24 +00:00
Fun picker improvements
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
132
ts/components/fun/data/segments.ts
Normal file
132
ts/components/fun/data/segments.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user