Merge remote-tracking branch 'misskey-dev/develop' into io

This commit is contained in:
まっちゃとーにゅ
2024-01-23 04:10:51 +09:00
266 changed files with 20375 additions and 6619 deletions

View File

@@ -8,13 +8,13 @@ import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode/';
import { popup } from '@/os.js';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
export class Autocomplete {
private suggestion: {
x: Ref<number>;
y: Ref<number>;
q: Ref<string | null>;
q: Ref<any>;
close: () => void;
} | null;
private textarea: HTMLInputElement | HTMLTextAreaElement;
@@ -49,7 +49,7 @@ export class Autocomplete {
this.textarea = textarea;
this.textRef = textRef;
this.opening = false;
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag'];
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag', 'mfmParam'];
this.attach();
}
@@ -80,6 +80,7 @@ export class Autocomplete {
const hashtagIndex = text.lastIndexOf('#');
const emojiIndex = text.lastIndexOf(':');
const mfmTagIndex = text.lastIndexOf('$');
const mfmParamIndex = text.lastIndexOf('.');
const max = Math.max(
mentionIndex,
@@ -94,7 +95,8 @@ export class Autocomplete {
const isMention = mentionIndex !== -1;
const isHashtag = hashtagIndex !== -1;
const isMfmTag = mfmTagIndex !== -1;
const isMfmParam = mfmParamIndex !== -1 && text.split(/\$\[[a-zA-Z]+/).pop()?.includes('.');
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
let opened = false;
@@ -134,6 +136,17 @@ export class Autocomplete {
}
}
if (isMfmParam && !opened && this.onlyType.includes('mfmParam')) {
const mfmParam = text.substring(mfmParamIndex + 1);
if (!mfmParam.includes(' ')) {
this.open('mfmParam', {
tag: text.substring(mfmTagIndex + 2, mfmParamIndex),
params: mfmParam.split(','),
});
opened = true;
}
}
if (!opened) {
this.close();
}
@@ -142,7 +155,7 @@ export class Autocomplete {
/**
* サジェストを提示します。
*/
private async open(type: string, q: string | null) {
private async open(type: string, q: any) {
if (type !== this.currentType) {
this.close();
}
@@ -280,6 +293,22 @@ export class Autocomplete {
const pos = trimmedBefore.length + (value.length + 3);
this.textarea.setSelectionRange(pos, pos);
});
} else if (type === 'mfmParam') {
const source = this.text;
const before = source.substring(0, caret);
const trimmedBefore = before.substring(0, before.lastIndexOf('.'));
const after = source.substring(caret);
// 挿入
this.text = `${trimmedBefore}.${value}${after}`;
// キャレットを戻す
nextTick(() => {
this.textarea.focus();
const pos = trimmedBefore.length + (value.length + 1);
this.textarea.setSelectionRange(pos, pos);
});
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -66,7 +66,7 @@ function addApp() {
async function deleteFile(file: Misskey.entities.DriveFile) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('driveFileDeleteConfirm', { name: file.name }),
text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }),
});
if (canceled) return;

View File

@@ -47,7 +47,7 @@ export async function getNoteClipMenu(props: {
if (err.id === '734806c4-542c-463a-9311-15c512803965') {
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }),
});
if (!confirm.canceled) {
os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
@@ -231,7 +231,7 @@ export function getNoteMenu(props: {
function share(): void {
navigator.share({
title: i18n.t('noteOf', { user: appearNote.user.name }),
title: i18n.tsx.noteOf({ user: appearNote.user.name }),
text: appearNote.text,
url: `${url}/notes/${appearNote.id}`,
});

View File

@@ -30,7 +30,7 @@ export const getNoteSummary = (note: Misskey.entities.Note): string => {
// ファイルが添付されているとき
if ((note.files || []).length !== 0) {
summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`;
summary += ` (${i18n.tsx.withNFiles({ n: note.files.length })})`;
}
// 投票が添付されているとき

View File

@@ -2,33 +2,293 @@
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ILocale, ParameterizedString } from '../../../../locales/index.js';
export class I18n<T extends Record<string, any>> {
public ts: T;
type FlattenKeys<T extends ILocale, TPrediction> = keyof {
[K in keyof T as T[K] extends ILocale
? FlattenKeys<T[K], TPrediction> extends infer C extends string
? `${K & string}.${C}`
: never
: T[K] extends TPrediction
? K
: never]: T[K];
};
constructor(locale: T) {
this.ts = locale;
type ParametersOf<T extends ILocale, TKey extends FlattenKeys<T, ParameterizedString>> = TKey extends `${infer K}.${infer C}`
// @ts-expect-error -- C は明らかに FlattenKeys<T[K], ParameterizedString> になるが、型システムはここでは TKey がドット区切りであることのコンテキストを持たないので、型システムに合法にて示すことはできない。
? ParametersOf<T[K], C>
: TKey extends keyof T
? T[TKey] extends ParameterizedString<infer P>
? P
: never
: never;
type Tsx<T extends ILocale> = {
readonly [K in keyof T as T[K] extends string ? never : K]: T[K] extends ParameterizedString<infer P>
? (arg: { readonly [_ in P]: string | number }) => string
// @ts-expect-error -- 証明省略
: Tsx<T[K]>;
};
export class I18n<T extends ILocale> {
private tsxCache?: Tsx<T>;
constructor(public locale: T) {
//#region BIND
this.t = this.t.bind(this);
//#endregion
}
// string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, string | number>): string {
try {
let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
public get ts(): T {
if (_DEV_) {
class Handler<TTarget extends ILocale> implements ProxyHandler<TTarget> {
get(target: TTarget, p: string | symbol): unknown {
const value = target[p as keyof TTarget];
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replace(`{${k}}`, v.toString());
if (typeof value === 'object') {
return new Proxy(value, new Handler<TTarget[keyof TTarget] & ILocale>());
}
if (typeof value === 'string') {
const parameters = Array.from(value.matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter);
if (parameters.length) {
console.error(`Missing locale parameters: ${parameters.join(', ')} at ${String(p)}`);
}
return value;
}
console.error(`Unexpected locale key: ${String(p)}`);
return p;
}
}
return str;
} catch (err) {
console.warn(`missing localization '${key}'`);
return key;
return new Proxy(this.locale, new Handler());
}
return this.locale;
}
public get tsx(): Tsx<T> {
if (_DEV_) {
if (this.tsxCache) {
return this.tsxCache;
}
class Handler<TTarget extends ILocale> implements ProxyHandler<TTarget> {
get(target: TTarget, p: string | symbol): unknown {
const value = target[p as keyof TTarget];
if (typeof value === 'object') {
return new Proxy(value, new Handler<TTarget[keyof TTarget] & ILocale>());
}
if (typeof value === 'string') {
const quasis: string[] = [];
const expressions: string[] = [];
let cursor = 0;
while (~cursor) {
const start = value.indexOf('{', cursor);
if (!~start) {
quasis.push(value.slice(cursor));
break;
}
quasis.push(value.slice(cursor, start));
const end = value.indexOf('}', start);
expressions.push(value.slice(start + 1, end));
cursor = end + 1;
}
if (!expressions.length) {
console.error(`Unexpected locale key: ${String(p)}`);
return () => value;
}
return (arg) => {
let str = quasis[0];
for (let i = 0; i < expressions.length; i++) {
if (!Object.hasOwn(arg, expressions[i])) {
console.error(`Missing locale parameters: ${expressions[i]} at ${String(p)}`);
}
str += arg[expressions[i]] + quasis[i + 1];
}
return str;
};
}
console.error(`Unexpected locale key: ${String(p)}`);
return p;
}
}
return this.tsxCache = new Proxy(this.locale, new Handler()) as unknown as Tsx<T>;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.tsxCache) {
return this.tsxCache;
}
function build(target: ILocale): Tsx<T> {
const result = {} as Tsx<T>;
for (const k in target) {
if (!Object.hasOwn(target, k)) {
continue;
}
const value = target[k as keyof typeof target];
if (typeof value === 'object') {
result[k] = build(value as ILocale);
} else if (typeof value === 'string') {
const quasis: string[] = [];
const expressions: string[] = [];
let cursor = 0;
while (~cursor) {
const start = value.indexOf('{', cursor);
if (!~start) {
quasis.push(value.slice(cursor));
break;
}
quasis.push(value.slice(cursor, start));
const end = value.indexOf('}', start);
expressions.push(value.slice(start + 1, end));
cursor = end + 1;
}
if (!expressions.length) {
continue;
}
result[k] = (arg) => {
let str = quasis[0];
for (let i = 0; i < expressions.length; i++) {
str += arg[expressions[i]] + quasis[i + 1];
}
return str;
};
}
}
return result;
}
return this.tsxCache = build(this.locale);
}
/**
* @deprecated なるべくこのメソッド使うよりも ts 直接参照の方が vue のキャッシュ効いてパフォーマンスが良いかも
*/
public t<TKey extends FlattenKeys<T, string>>(key: TKey): string;
/**
* @deprecated なるべくこのメソッド使うよりも tsx 直接参照の方が vue のキャッシュ効いてパフォーマンスが良いかも
*/
public t<TKey extends FlattenKeys<T, ParameterizedString>>(key: TKey, args: { readonly [_ in ParametersOf<T, TKey>]: string | number }): string;
public t(key: string, args?: { readonly [_: string]: string | number }) {
let str: string | ParameterizedString | ILocale = this.locale;
for (const k of key.split('.')) {
str = str[k];
if (_DEV_) {
if (typeof str === 'undefined') {
console.error(`Unexpected locale key: ${key}`);
return key;
}
}
}
if (args) {
if (_DEV_) {
const missing = Array.from((str as string).matchAll(/\{(\w+)\}/g), ([, parameter]) => parameter).filter(parameter => !Object.hasOwn(args, parameter));
if (missing.length) {
console.error(`Missing locale parameters: ${missing.join(', ')} at ${key}`);
}
}
for (const [k, v] of Object.entries(args)) {
const search = `{${k}}`;
if (_DEV_) {
if (!(str as string).includes(search)) {
console.error(`Unexpected locale parameter: ${k} at ${key}`);
}
}
str = (str as string).replace(search, v.toString());
}
}
return str;
}
}
if (import.meta.vitest) {
const { describe, expect, it } = import.meta.vitest;
describe('i18n', () => {
it('t', () => {
const i18n = new I18n({
foo: 'foo',
bar: {
baz: 'baz',
qux: 'qux {0}' as unknown as ParameterizedString<'0'>,
quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>,
},
});
expect(i18n.t('foo')).toBe('foo');
expect(i18n.t('bar.baz')).toBe('baz');
expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge');
expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga');
});
it('ts', () => {
const i18n = new I18n({
foo: 'foo',
bar: {
baz: 'baz',
qux: 'qux {0}' as unknown as ParameterizedString<'0'>,
quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>,
},
});
expect(i18n.ts.foo).toBe('foo');
expect(i18n.ts.bar.baz).toBe('baz');
});
it('tsx', () => {
const i18n = new I18n({
foo: 'foo',
bar: {
baz: 'baz',
qux: 'qux {0}' as unknown as ParameterizedString<'0'>,
quux: 'quux {0} {1}' as unknown as ParameterizedString<'0' | '1'>,
},
});
expect(i18n.tsx.bar.qux({ 0: 'hoge' })).toBe('qux hoge');
expect(i18n.tsx.bar.quux({ 0: 'hoge', 1: 'fuga' })).toBe('quux hoge fuga');
});
});
}

View File

@@ -33,6 +33,10 @@ try {
}
export const dateTimeFormat = _dateTimeFormat;
export const timeZone = dateTimeFormat.resolvedOptions().timeZone;
export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N';
let _numberFormat: Intl.NumberFormat;
try {
_numberFormat = new Intl.NumberFormat(versatileLang);

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { onMounted, onUnmounted } from 'vue';
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
export function useInterval(fn: () => void, interval: number, options: {
immediate: boolean;
@@ -28,6 +28,16 @@ export function useInterval(fn: () => void, interval: number, options: {
intervalId = null;
};
onActivated(() => {
if (intervalId) return;
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
});
onDeactivated(() => {
clear();
});
onUnmounted(() => {
clear();
});