summaryrefslogtreecommitdiff
path: root/packages/client/src/scripts
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
commit0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch)
tree40874799472fa07416f17b50a398ac33b7771905 /packages/client/src/scripts
parentupdate deps (diff)
downloadsharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2
sharkey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip
refactoring
Resolve #7779
Diffstat (limited to 'packages/client/src/scripts')
-rw-r--r--packages/client/src/scripts/2fa.ts33
-rw-r--r--packages/client/src/scripts/aiscript/api.ts44
-rw-r--r--packages/client/src/scripts/array.ts138
-rw-r--r--packages/client/src/scripts/autocomplete.ts276
-rw-r--r--packages/client/src/scripts/check-word-mute.ts26
-rw-r--r--packages/client/src/scripts/collect-page-vars.ts48
-rw-r--r--packages/client/src/scripts/contains.ts9
-rw-r--r--packages/client/src/scripts/copy-to-clipboard.ts33
-rw-r--r--packages/client/src/scripts/emojilist.ts7
-rw-r--r--packages/client/src/scripts/extract-avg-color-from-blurhash.ts9
-rw-r--r--packages/client/src/scripts/extract-mentions.ts11
-rw-r--r--packages/client/src/scripts/extract-url-from-mfm.ts19
-rw-r--r--packages/client/src/scripts/focus.ts27
-rw-r--r--packages/client/src/scripts/form.ts31
-rw-r--r--packages/client/src/scripts/format-time-string.ts50
-rw-r--r--packages/client/src/scripts/games/reversi/core.ts263
-rw-r--r--packages/client/src/scripts/games/reversi/maps.ts896
-rw-r--r--packages/client/src/scripts/games/reversi/package.json18
-rw-r--r--packages/client/src/scripts/games/reversi/tsconfig.json21
-rw-r--r--packages/client/src/scripts/gen-search-query.ts31
-rw-r--r--packages/client/src/scripts/get-account-from-id.ts7
-rw-r--r--packages/client/src/scripts/get-md5.ts10
-rw-r--r--packages/client/src/scripts/get-note-summary.ts55
-rw-r--r--packages/client/src/scripts/get-static-image-url.ts16
-rw-r--r--packages/client/src/scripts/get-user-menu.ts205
-rw-r--r--packages/client/src/scripts/hotkey.ts88
-rw-r--r--packages/client/src/scripts/hpml/block.ts109
-rw-r--r--packages/client/src/scripts/hpml/evaluator.ts234
-rw-r--r--packages/client/src/scripts/hpml/expr.ts79
-rw-r--r--packages/client/src/scripts/hpml/index.ts103
-rw-r--r--packages/client/src/scripts/hpml/lib.ts246
-rw-r--r--packages/client/src/scripts/hpml/type-checker.ts189
-rw-r--r--packages/client/src/scripts/i18n.ts29
-rw-r--r--packages/client/src/scripts/idb-proxy.ts37
-rw-r--r--packages/client/src/scripts/initialize-sw.ts68
-rw-r--r--packages/client/src/scripts/is-device-darkmode.ts3
-rw-r--r--packages/client/src/scripts/is-device-touch.ts1
-rw-r--r--packages/client/src/scripts/is-mobile.ts2
-rw-r--r--packages/client/src/scripts/keycode.ts33
-rw-r--r--packages/client/src/scripts/loading.ts11
-rw-r--r--packages/client/src/scripts/login-id.ts11
-rw-r--r--packages/client/src/scripts/lookup-user.ts37
-rw-r--r--packages/client/src/scripts/mfm-tags.ts1
-rw-r--r--packages/client/src/scripts/paging.ts246
-rw-r--r--packages/client/src/scripts/physics.ts152
-rw-r--r--packages/client/src/scripts/please-login.ts14
-rw-r--r--packages/client/src/scripts/popout.ts22
-rw-r--r--packages/client/src/scripts/reaction-picker.ts41
-rw-r--r--packages/client/src/scripts/room/furniture.ts21
-rw-r--r--packages/client/src/scripts/room/furnitures.json5407
-rw-r--r--packages/client/src/scripts/room/room.ts775
-rw-r--r--packages/client/src/scripts/scroll.ts80
-rw-r--r--packages/client/src/scripts/search.ts64
-rw-r--r--packages/client/src/scripts/select-file.ts89
-rw-r--r--packages/client/src/scripts/show-suspended-dialog.ts10
-rw-r--r--packages/client/src/scripts/sound.ts34
-rw-r--r--packages/client/src/scripts/sticky-sidebar.ts50
-rw-r--r--packages/client/src/scripts/theme-editor.ts81
-rw-r--r--packages/client/src/scripts/theme.ts127
-rw-r--r--packages/client/src/scripts/time.ts39
-rw-r--r--packages/client/src/scripts/twemoji-base.ts1
-rw-r--r--packages/client/src/scripts/unison-reload.ts15
-rw-r--r--packages/client/src/scripts/url.ts13
63 files changed, 5845 insertions, 0 deletions
diff --git a/packages/client/src/scripts/2fa.ts b/packages/client/src/scripts/2fa.ts
new file mode 100644
index 0000000000..00363cffa6
--- /dev/null
+++ b/packages/client/src/scripts/2fa.ts
@@ -0,0 +1,33 @@
+export function byteify(data: string, encoding: 'ascii' | 'base64' | 'hex') {
+ switch (encoding) {
+ case 'ascii':
+ return Uint8Array.from(data, c => c.charCodeAt(0));
+ case 'base64':
+ return Uint8Array.from(
+ atob(
+ data
+ .replace(/-/g, '+')
+ .replace(/_/g, '/')
+ ),
+ c => c.charCodeAt(0)
+ );
+ case 'hex':
+ return new Uint8Array(
+ data
+ .match(/.{1,2}/g)
+ .map(byte => parseInt(byte, 16))
+ );
+ }
+}
+
+export function hexify(buffer: ArrayBuffer) {
+ return Array.from(new Uint8Array(buffer))
+ .reduce(
+ (str, byte) => str + byte.toString(16).padStart(2, '0'),
+ ''
+ );
+}
+
+export function stringify(buffer: ArrayBuffer) {
+ return String.fromCharCode(... new Uint8Array(buffer));
+}
diff --git a/packages/client/src/scripts/aiscript/api.ts b/packages/client/src/scripts/aiscript/api.ts
new file mode 100644
index 0000000000..20c15d809e
--- /dev/null
+++ b/packages/client/src/scripts/aiscript/api.ts
@@ -0,0 +1,44 @@
+import { utils, values } from '@syuilo/aiscript';
+import * as os from '@/os';
+import { $i } from '@/account';
+
+export function createAiScriptEnv(opts) {
+ let apiRequests = 0;
+ return {
+ USER_ID: $i ? values.STR($i.id) : values.NULL,
+ USER_NAME: $i ? values.STR($i.name) : values.NULL,
+ USER_USERNAME: $i ? values.STR($i.username) : values.NULL,
+ 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
+ await os.dialog({
+ type: type ? type.value : 'info',
+ title: title.value,
+ text: text.value,
+ });
+ }),
+ 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
+ const confirm = await os.dialog({
+ type: type ? type.value : 'question',
+ showCancelButton: true,
+ title: title.value,
+ text: text.value,
+ });
+ return confirm.canceled ? values.FALSE : values.TRUE;
+ }),
+ 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
+ if (token) utils.assertString(token);
+ apiRequests++;
+ if (apiRequests > 16) return values.NULL;
+ const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token || null));
+ return utils.jsToVal(res);
+ }),
+ 'Mk:save': values.FN_NATIVE(([key, value]) => {
+ utils.assertString(key);
+ localStorage.setItem('aiscript:' + opts.storageKey + ':' + key.value, JSON.stringify(utils.valToJs(value)));
+ return values.NULL;
+ }),
+ 'Mk:load': values.FN_NATIVE(([key]) => {
+ utils.assertString(key);
+ return utils.jsToVal(JSON.parse(localStorage.getItem('aiscript:' + opts.storageKey + ':' + key.value)));
+ }),
+ };
+}
diff --git a/packages/client/src/scripts/array.ts b/packages/client/src/scripts/array.ts
new file mode 100644
index 0000000000..d63f0475d0
--- /dev/null
+++ b/packages/client/src/scripts/array.ts
@@ -0,0 +1,138 @@
+import { EndoRelation, Predicate } from './relation';
+
+/**
+ * Count the number of elements that satisfy the predicate
+ */
+
+export function countIf<T>(f: Predicate<T>, xs: T[]): number {
+ return xs.filter(f).length;
+}
+
+/**
+ * Count the number of elements that is equal to the element
+ */
+export function count<T>(a: T, xs: T[]): number {
+ return countIf(x => x === a, xs);
+}
+
+/**
+ * Concatenate an array of arrays
+ */
+export function concat<T>(xss: T[][]): T[] {
+ return ([] as T[]).concat(...xss);
+}
+
+/**
+ * Intersperse the element between the elements of the array
+ * @param sep The element to be interspersed
+ */
+export function intersperse<T>(sep: T, xs: T[]): T[] {
+ return concat(xs.map(x => [sep, x])).slice(1);
+}
+
+/**
+ * Returns the array of elements that is not equal to the element
+ */
+export function erase<T>(a: T, xs: T[]): T[] {
+ return xs.filter(x => x !== a);
+}
+
+/**
+ * Finds the array of all elements in the first array not contained in the second array.
+ * The order of result values are determined by the first array.
+ */
+export function difference<T>(xs: T[], ys: T[]): T[] {
+ return xs.filter(x => !ys.includes(x));
+}
+
+/**
+ * Remove all but the first element from every group of equivalent elements
+ */
+export function unique<T>(xs: T[]): T[] {
+ return [...new Set(xs)];
+}
+
+export function sum(xs: number[]): number {
+ return xs.reduce((a, b) => a + b, 0);
+}
+
+export function maximum(xs: number[]): number {
+ return Math.max(...xs);
+}
+
+/**
+ * Splits an array based on the equivalence relation.
+ * The concatenation of the result is equal to the argument.
+ */
+export function groupBy<T>(f: EndoRelation<T>, xs: T[]): T[][] {
+ const groups = [] as T[][];
+ for (const x of xs) {
+ if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) {
+ groups[groups.length - 1].push(x);
+ } else {
+ groups.push([x]);
+ }
+ }
+ return groups;
+}
+
+/**
+ * Splits an array based on the equivalence relation induced by the function.
+ * The concatenation of the result is equal to the argument.
+ */
+export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] {
+ return groupBy((a, b) => f(a) === f(b), xs);
+}
+
+export function groupByX<T>(collections: T[], keySelector: (x: T) => string) {
+ return collections.reduce((obj: Record<string, T[]>, item: T) => {
+ const key = keySelector(item);
+ if (!obj.hasOwnProperty(key)) {
+ obj[key] = [];
+ }
+
+ obj[key].push(item);
+
+ return obj;
+ }, {});
+}
+
+/**
+ * Compare two arrays by lexicographical order
+ */
+export function lessThan(xs: number[], ys: number[]): boolean {
+ for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
+ if (xs[i] < ys[i]) return true;
+ if (xs[i] > ys[i]) return false;
+ }
+ return xs.length < ys.length;
+}
+
+/**
+ * Returns the longest prefix of elements that satisfy the predicate
+ */
+export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] {
+ const ys = [];
+ for (const x of xs) {
+ if (f(x)) {
+ ys.push(x);
+ } else {
+ break;
+ }
+ }
+ return ys;
+}
+
+export function cumulativeSum(xs: number[]): number[] {
+ const ys = Array.from(xs); // deep copy
+ for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1];
+ return ys;
+}
+
+export function toArray<T>(x: T | T[] | undefined): T[] {
+ return Array.isArray(x) ? x : x != null ? [x] : [];
+}
+
+export function toSingle<T>(x: T | T[] | undefined): T | undefined {
+ return Array.isArray(x) ? x[0] : x;
+}
diff --git a/packages/client/src/scripts/autocomplete.ts b/packages/client/src/scripts/autocomplete.ts
new file mode 100644
index 0000000000..f2d5806484
--- /dev/null
+++ b/packages/client/src/scripts/autocomplete.ts
@@ -0,0 +1,276 @@
+import { Ref, ref } from 'vue';
+import * as getCaretCoordinates from 'textarea-caret';
+import { toASCII } from 'punycode/';
+import { popup } from '@/os';
+
+export class Autocomplete {
+ private suggestion: {
+ x: Ref<number>;
+ y: Ref<number>;
+ q: Ref<string | null>;
+ close: Function;
+ } | null;
+ private textarea: any;
+ private vm: any;
+ private currentType: string;
+ private opts: {
+ model: string;
+ };
+ private opening: boolean;
+
+ private get text(): string {
+ return this.vm[this.opts.model];
+ }
+
+ private set text(text: string) {
+ this.vm[this.opts.model] = text;
+ }
+
+ /**
+ * 対象のテキストエリアを与えてインスタンスを初期化します。
+ */
+ constructor(textarea, vm, opts) {
+ //#region BIND
+ this.onInput = this.onInput.bind(this);
+ this.complete = this.complete.bind(this);
+ this.close = this.close.bind(this);
+ //#endregion
+
+ this.suggestion = null;
+ this.textarea = textarea;
+ this.vm = vm;
+ this.opts = opts;
+ this.opening = false;
+
+ this.attach();
+ }
+
+ /**
+ * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。
+ */
+ public attach() {
+ this.textarea.addEventListener('input', this.onInput);
+ }
+
+ /**
+ * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。
+ */
+ public detach() {
+ this.textarea.removeEventListener('input', this.onInput);
+ this.close();
+ }
+
+ /**
+ * テキスト入力時
+ */
+ private onInput() {
+ const caretPos = this.textarea.selectionStart;
+ const text = this.text.substr(0, caretPos).split('\n').pop()!;
+
+ const mentionIndex = text.lastIndexOf('@');
+ const hashtagIndex = text.lastIndexOf('#');
+ const emojiIndex = text.lastIndexOf(':');
+ const mfmTagIndex = text.lastIndexOf('$');
+
+ const max = Math.max(
+ mentionIndex,
+ hashtagIndex,
+ emojiIndex,
+ mfmTagIndex);
+
+ if (max == -1) {
+ this.close();
+ return;
+ }
+
+ const isMention = mentionIndex != -1;
+ const isHashtag = hashtagIndex != -1;
+ const isMfmTag = mfmTagIndex != -1;
+ const isEmoji = emojiIndex != -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
+
+ let opened = false;
+
+ if (isMention) {
+ const username = text.substr(mentionIndex + 1);
+ if (username != '' && username.match(/^[a-zA-Z0-9_]+$/)) {
+ this.open('user', username);
+ opened = true;
+ } else if (username === '') {
+ this.open('user', null);
+ opened = true;
+ }
+ }
+
+ if (isHashtag && !opened) {
+ const hashtag = text.substr(hashtagIndex + 1);
+ if (!hashtag.includes(' ')) {
+ this.open('hashtag', hashtag);
+ opened = true;
+ }
+ }
+
+ if (isEmoji && !opened) {
+ const emoji = text.substr(emojiIndex + 1);
+ if (!emoji.includes(' ')) {
+ this.open('emoji', emoji);
+ opened = true;
+ }
+ }
+
+ if (isMfmTag && !opened) {
+ const mfmTag = text.substr(mfmTagIndex + 1);
+ if (!mfmTag.includes(' ')) {
+ this.open('mfmTag', mfmTag.replace('[', ''));
+ opened = true;
+ }
+ }
+
+ if (!opened) {
+ this.close();
+ }
+ }
+
+ /**
+ * サジェストを提示します。
+ */
+ private async open(type: string, q: string | null) {
+ if (type != this.currentType) {
+ this.close();
+ }
+ if (this.opening) return;
+ this.opening = true;
+ this.currentType = type;
+
+ //#region サジェストを表示すべき位置を計算
+ const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart);
+
+ const rect = this.textarea.getBoundingClientRect();
+
+ const x = rect.left + caretPosition.left - this.textarea.scrollLeft;
+ const y = rect.top + caretPosition.top - this.textarea.scrollTop;
+ //#endregion
+
+ if (this.suggestion) {
+ this.suggestion.x.value = x;
+ this.suggestion.y.value = y;
+ this.suggestion.q.value = q;
+
+ this.opening = false;
+ } else {
+ const _x = ref(x);
+ const _y = ref(y);
+ const _q = ref(q);
+
+ const { dispose } = await popup(import('@/components/autocomplete.vue'), {
+ textarea: this.textarea,
+ close: this.close,
+ type: type,
+ q: _q,
+ x: _x,
+ y: _y,
+ }, {
+ done: (res) => {
+ this.complete(res);
+ }
+ });
+
+ this.suggestion = {
+ q: _q,
+ x: _x,
+ y: _y,
+ close: () => dispose(),
+ };
+
+ this.opening = false;
+ }
+ }
+
+ /**
+ * サジェストを閉じます。
+ */
+ private close() {
+ if (this.suggestion == null) return;
+
+ this.suggestion.close();
+ this.suggestion = null;
+
+ this.textarea.focus();
+ }
+
+ /**
+ * オートコンプリートする
+ */
+ private complete({ type, value }) {
+ this.close();
+
+ const caret = this.textarea.selectionStart;
+
+ if (type == 'user') {
+ const source = this.text;
+
+ const before = source.substr(0, caret);
+ const trimmedBefore = before.substring(0, before.lastIndexOf('@'));
+ const after = source.substr(caret);
+
+ const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`;
+
+ // 挿入
+ this.text = `${trimmedBefore}@${acct} ${after}`;
+
+ // キャレットを戻す
+ this.vm.$nextTick(() => {
+ this.textarea.focus();
+ const pos = trimmedBefore.length + (acct.length + 2);
+ this.textarea.setSelectionRange(pos, pos);
+ });
+ } else if (type == 'hashtag') {
+ const source = this.text;
+
+ const before = source.substr(0, caret);
+ const trimmedBefore = before.substring(0, before.lastIndexOf('#'));
+ const after = source.substr(caret);
+
+ // 挿入
+ this.text = `${trimmedBefore}#${value} ${after}`;
+
+ // キャレットを戻す
+ this.vm.$nextTick(() => {
+ this.textarea.focus();
+ const pos = trimmedBefore.length + (value.length + 2);
+ this.textarea.setSelectionRange(pos, pos);
+ });
+ } else if (type == 'emoji') {
+ const source = this.text;
+
+ const before = source.substr(0, caret);
+ const trimmedBefore = before.substring(0, before.lastIndexOf(':'));
+ const after = source.substr(caret);
+
+ // 挿入
+ this.text = trimmedBefore + value + after;
+
+ // キャレットを戻す
+ this.vm.$nextTick(() => {
+ this.textarea.focus();
+ const pos = trimmedBefore.length + value.length;
+ this.textarea.setSelectionRange(pos, pos);
+ });
+ } else if (type == 'mfmTag') {
+ const source = this.text;
+
+ const before = source.substr(0, caret);
+ const trimmedBefore = before.substring(0, before.lastIndexOf('$'));
+ const after = source.substr(caret);
+
+ // 挿入
+ this.text = `${trimmedBefore}$[${value} ]${after}`;
+
+ // キャレットを戻す
+ this.vm.$nextTick(() => {
+ this.textarea.focus();
+ const pos = trimmedBefore.length + (value.length + 3);
+ this.textarea.setSelectionRange(pos, pos);
+ });
+ }
+ }
+}
diff --git a/packages/client/src/scripts/check-word-mute.ts b/packages/client/src/scripts/check-word-mute.ts
new file mode 100644
index 0000000000..3b1fa75b1e
--- /dev/null
+++ b/packages/client/src/scripts/check-word-mute.ts
@@ -0,0 +1,26 @@
+export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> {
+ // 自分自身
+ if (me && (note.userId === me.id)) return false;
+
+ const words = mutedWords
+ // Clean up
+ .map(xs => xs.filter(x => x !== ''))
+ .filter(xs => xs.length > 0);
+
+ if (words.length > 0) {
+ if (note.text == null) return false;
+
+ const matched = words.some(and =>
+ and.every(keyword => {
+ const regexp = keyword.match(/^\/(.+)\/(.*)$/);
+ if (regexp) {
+ return new RegExp(regexp[1], regexp[2]).test(note.text!);
+ }
+ return note.text!.includes(keyword);
+ }));
+
+ if (matched) return true;
+ }
+
+ return false;
+}
diff --git a/packages/client/src/scripts/collect-page-vars.ts b/packages/client/src/scripts/collect-page-vars.ts
new file mode 100644
index 0000000000..a4096fb2c2
--- /dev/null
+++ b/packages/client/src/scripts/collect-page-vars.ts
@@ -0,0 +1,48 @@
+export function collectPageVars(content) {
+ const pageVars = [];
+ const collect = (xs: any[]) => {
+ for (const x of xs) {
+ if (x.type === 'textInput') {
+ pageVars.push({
+ name: x.name,
+ type: 'string',
+ value: x.default || ''
+ });
+ } else if (x.type === 'textareaInput') {
+ pageVars.push({
+ name: x.name,
+ type: 'string',
+ value: x.default || ''
+ });
+ } else if (x.type === 'numberInput') {
+ pageVars.push({
+ name: x.name,
+ type: 'number',
+ value: x.default || 0
+ });
+ } else if (x.type === 'switch') {
+ pageVars.push({
+ name: x.name,
+ type: 'boolean',
+ value: x.default || false
+ });
+ } else if (x.type === 'counter') {
+ pageVars.push({
+ name: x.name,
+ type: 'number',
+ value: 0
+ });
+ } else if (x.type === 'radioButton') {
+ pageVars.push({
+ name: x.name,
+ type: 'string',
+ value: x.default || ''
+ });
+ } else if (x.children) {
+ collect(x.children);
+ }
+ }
+ };
+ collect(content);
+ return pageVars;
+}
diff --git a/packages/client/src/scripts/contains.ts b/packages/client/src/scripts/contains.ts
new file mode 100644
index 0000000000..770bda63bb
--- /dev/null
+++ b/packages/client/src/scripts/contains.ts
@@ -0,0 +1,9 @@
+export default (parent, child, checkSame = true) => {
+ if (checkSame && parent === child) return true;
+ let node = child.parentNode;
+ while (node) {
+ if (node == parent) return true;
+ node = node.parentNode;
+ }
+ return false;
+};
diff --git a/packages/client/src/scripts/copy-to-clipboard.ts b/packages/client/src/scripts/copy-to-clipboard.ts
new file mode 100644
index 0000000000..ab13cab970
--- /dev/null
+++ b/packages/client/src/scripts/copy-to-clipboard.ts
@@ -0,0 +1,33 @@
+/**
+ * Clipboardに値をコピー(TODO: 文字列以外も対応)
+ */
+export default val => {
+ // 空div 生成
+ const tmp = document.createElement('div');
+ // 選択用のタグ生成
+ const pre = document.createElement('pre');
+
+ // 親要素のCSSで user-select: none だとコピーできないので書き換える
+ pre.style.webkitUserSelect = 'auto';
+ pre.style.userSelect = 'auto';
+
+ tmp.appendChild(pre).textContent = val;
+
+ // 要素を画面外へ
+ const s = tmp.style;
+ s.position = 'fixed';
+ s.right = '200%';
+
+ // body に追加
+ document.body.appendChild(tmp);
+ // 要素を選択
+ document.getSelection().selectAllChildren(tmp);
+
+ // クリップボードにコピー
+ const result = document.execCommand('copy');
+
+ // 要素削除
+ document.body.removeChild(tmp);
+
+ return result;
+};
diff --git a/packages/client/src/scripts/emojilist.ts b/packages/client/src/scripts/emojilist.ts
new file mode 100644
index 0000000000..de7591f5a0
--- /dev/null
+++ b/packages/client/src/scripts/emojilist.ts
@@ -0,0 +1,7 @@
+// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
+export const emojilist = require('../emojilist.json') as {
+ name: string;
+ keywords: string[];
+ char: string;
+ category: 'people' | 'animals_and_nature' | 'food_and_drink' | 'activity' | 'travel_and_places' | 'objects' | 'symbols' | 'flags';
+}[];
diff --git a/packages/client/src/scripts/extract-avg-color-from-blurhash.ts b/packages/client/src/scripts/extract-avg-color-from-blurhash.ts
new file mode 100644
index 0000000000..123ab7a06d
--- /dev/null
+++ b/packages/client/src/scripts/extract-avg-color-from-blurhash.ts
@@ -0,0 +1,9 @@
+export function extractAvgColorFromBlurhash(hash: string) {
+ return typeof hash == 'string'
+ ? '#' + [...hash.slice(2, 6)]
+ .map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x))
+ .reduce((a, c) => a * 83 + c, 0)
+ .toString(16)
+ .padStart(6, '0')
+ : undefined;
+}
diff --git a/packages/client/src/scripts/extract-mentions.ts b/packages/client/src/scripts/extract-mentions.ts
new file mode 100644
index 0000000000..cc19b161a8
--- /dev/null
+++ b/packages/client/src/scripts/extract-mentions.ts
@@ -0,0 +1,11 @@
+// test is located in test/extract-mentions
+
+import * as mfm from 'mfm-js';
+
+export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
+ // TODO: 重複を削除
+ const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention');
+ const mentions = mentionNodes.map(x => x.props);
+
+ return mentions;
+}
diff --git a/packages/client/src/scripts/extract-url-from-mfm.ts b/packages/client/src/scripts/extract-url-from-mfm.ts
new file mode 100644
index 0000000000..34e3eb6c19
--- /dev/null
+++ b/packages/client/src/scripts/extract-url-from-mfm.ts
@@ -0,0 +1,19 @@
+import * as mfm from 'mfm-js';
+import { unique } from '@/scripts/array';
+
+// unique without hash
+// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ]
+const removeHash = (x: string) => x.replace(/#[^#]*$/, '');
+
+export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] {
+ const urlNodes = mfm.extract(nodes, (node) => {
+ return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent));
+ });
+ const urls: string[] = unique(urlNodes.map(x => x.props.url));
+
+ return urls.reduce((array, url) => {
+ const urlWithoutHash = removeHash(url);
+ if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url);
+ return array;
+ }, [] as string[]);
+}
diff --git a/packages/client/src/scripts/focus.ts b/packages/client/src/scripts/focus.ts
new file mode 100644
index 0000000000..0894877820
--- /dev/null
+++ b/packages/client/src/scripts/focus.ts
@@ -0,0 +1,27 @@
+export function focusPrev(el: Element | null, self = false, scroll = true) {
+ if (el == null) return;
+ if (!self) el = el.previousElementSibling;
+ if (el) {
+ if (el.hasAttribute('tabindex')) {
+ (el as HTMLElement).focus({
+ preventScroll: !scroll
+ });
+ } else {
+ focusPrev(el.previousElementSibling, true);
+ }
+ }
+}
+
+export function focusNext(el: Element | null, self = false, scroll = true) {
+ if (el == null) return;
+ if (!self) el = el.nextElementSibling;
+ if (el) {
+ if (el.hasAttribute('tabindex')) {
+ (el as HTMLElement).focus({
+ preventScroll: !scroll
+ });
+ } else {
+ focusPrev(el.nextElementSibling, true);
+ }
+ }
+}
diff --git a/packages/client/src/scripts/form.ts b/packages/client/src/scripts/form.ts
new file mode 100644
index 0000000000..7bf6cec452
--- /dev/null
+++ b/packages/client/src/scripts/form.ts
@@ -0,0 +1,31 @@
+export type FormItem = {
+ label?: string;
+ type: 'string';
+ default: string | null;
+ hidden?: boolean;
+ multiline?: boolean;
+} | {
+ label?: string;
+ type: 'number';
+ default: number | null;
+ hidden?: boolean;
+ step?: number;
+} | {
+ label?: string;
+ type: 'boolean';
+ default: boolean | null;
+ hidden?: boolean;
+} | {
+ label?: string;
+ type: 'enum';
+ default: string | null;
+ hidden?: boolean;
+ enum: string[];
+} | {
+ label?: string;
+ type: 'array';
+ default: unknown[] | null;
+ hidden?: boolean;
+};
+
+export type Form = Record<string, FormItem>;
diff --git a/packages/client/src/scripts/format-time-string.ts b/packages/client/src/scripts/format-time-string.ts
new file mode 100644
index 0000000000..bfb2c397ae
--- /dev/null
+++ b/packages/client/src/scripts/format-time-string.ts
@@ -0,0 +1,50 @@
+const defaultLocaleStringFormats: {[index: string]: string} = {
+ 'weekday': 'narrow',
+ 'era': 'narrow',
+ 'year': 'numeric',
+ 'month': 'numeric',
+ 'day': 'numeric',
+ 'hour': 'numeric',
+ 'minute': 'numeric',
+ 'second': 'numeric',
+ 'timeZoneName': 'short'
+};
+
+function formatLocaleString(date: Date, format: string): string {
+ return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => {
+ if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) {
+ return date.toLocaleString(window.navigator.language, {[kind]: option ? option : defaultLocaleStringFormats[kind]});
+ } else {
+ return match;
+ }
+ });
+}
+
+export function formatDateTimeString(date: Date, format: string): string {
+ return format
+ .replace(/yyyy/g, date.getFullYear().toString())
+ .replace(/yy/g, date.getFullYear().toString().slice(-2))
+ .replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long'}))
+ .replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short'}))
+ .replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2))
+ .replace(/M/g, (date.getMonth() + 1).toString())
+ .replace(/dd/g, (`0${date.getDate()}`).slice(-2))
+ .replace(/d/g, date.getDate().toString())
+ .replace(/HH/g, (`0${date.getHours()}`).slice(-2))
+ .replace(/H/g, date.getHours().toString())
+ .replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2))
+ .replace(/h/g, ((date.getHours() % 12) || 12).toString())
+ .replace(/mm/g, (`0${date.getMinutes()}`).slice(-2))
+ .replace(/m/g, date.getMinutes().toString())
+ .replace(/ss/g, (`0${date.getSeconds()}`).slice(-2))
+ .replace(/s/g, date.getSeconds().toString())
+ .replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM');
+}
+
+export function formatTimeString(date: Date, format: string): string {
+ return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => {
+ if (localeformat) return formatLocaleString(date, localeformat);
+ if (datetimeformat) return formatDateTimeString(date, datetimeformat);
+ return match;
+ });
+}
diff --git a/packages/client/src/scripts/games/reversi/core.ts b/packages/client/src/scripts/games/reversi/core.ts
new file mode 100644
index 0000000000..0cb8922e19
--- /dev/null
+++ b/packages/client/src/scripts/games/reversi/core.ts
@@ -0,0 +1,263 @@
+import { count, concat } from '@/scripts/array';
+
+// MISSKEY REVERSI ENGINE
+
+/**
+ * true ... 黒
+ * false ... 白
+ */
+export type Color = boolean;
+const BLACK = true;
+const WHITE = false;
+
+export type MapPixel = 'null' | 'empty';
+
+export type Options = {
+ isLlotheo: boolean;
+ canPutEverywhere: boolean;
+ loopedBoard: boolean;
+};
+
+export type Undo = {
+ /**
+ * 色
+ */
+ color: Color;
+
+ /**
+ * どこに打ったか
+ */
+ pos: number;
+
+ /**
+ * 反転した石の位置の配列
+ */
+ effects: number[];
+
+ /**
+ * ターン
+ */
+ turn: Color | null;
+};
+
+/**
+ * リバーシエンジン
+ */
+export default class Reversi {
+ public map: MapPixel[];
+ public mapWidth: number;
+ public mapHeight: number;
+ public board: (Color | null | undefined)[];
+ public turn: Color | null = BLACK;
+ public opts: Options;
+
+ public prevPos = -1;
+ public prevColor: Color | null = null;
+
+ private logs: Undo[] = [];
+
+ /**
+ * ゲームを初期化します
+ */
+ constructor(map: string[], opts: Options) {
+ //#region binds
+ this.put = this.put.bind(this);
+ //#endregion
+
+ //#region Options
+ this.opts = opts;
+ if (this.opts.isLlotheo == null) this.opts.isLlotheo = false;
+ if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false;
+ if (this.opts.loopedBoard == null) this.opts.loopedBoard = false;
+ //#endregion
+
+ //#region Parse map data
+ this.mapWidth = map[0].length;
+ this.mapHeight = map.length;
+ const mapData = map.join('');
+
+ this.board = mapData.split('').map(d => d === '-' ? null : d === 'b' ? BLACK : d === 'w' ? WHITE : undefined);
+
+ this.map = mapData.split('').map(d => d === '-' || d === 'b' || d === 'w' ? 'empty' : 'null');
+ //#endregion
+
+ // ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある
+ if (!this.canPutSomewhere(BLACK))
+ this.turn = this.canPutSomewhere(WHITE) ? WHITE : null;
+ }
+
+ /**
+ * 黒石の数
+ */
+ public get blackCount() {
+ return count(BLACK, this.board);
+ }
+
+ /**
+ * 白石の数
+ */
+ public get whiteCount() {
+ return count(WHITE, this.board);
+ }
+
+ public transformPosToXy(pos: number): number[] {
+ const x = pos % this.mapWidth;
+ const y = Math.floor(pos / this.mapWidth);
+ return [x, y];
+ }
+
+ public transformXyToPos(x: number, y: number): number {
+ return x + (y * this.mapWidth);
+ }
+
+ /**
+ * 指定のマスに石を打ちます
+ * @param color 石の色
+ * @param pos 位置
+ */
+ public put(color: Color, pos: number) {
+ this.prevPos = pos;
+ this.prevColor = color;
+
+ this.board[pos] = color;
+
+ // 反転させられる石を取得
+ const effects = this.effects(color, pos);
+
+ // 反転させる
+ for (const pos of effects) {
+ this.board[pos] = color;
+ }
+
+ const turn = this.turn;
+
+ this.logs.push({
+ color,
+ pos,
+ effects,
+ turn
+ });
+
+ this.calcTurn();
+ }
+
+ private calcTurn() {
+ // ターン計算
+ this.turn =
+ this.canPutSomewhere(!this.prevColor) ? !this.prevColor :
+ this.canPutSomewhere(this.prevColor!) ? this.prevColor :
+ null;
+ }
+
+ public undo() {
+ const undo = this.logs.pop()!;
+ this.prevColor = undo.color;
+ this.prevPos = undo.pos;
+ this.board[undo.pos] = null;
+ for (const pos of undo.effects) {
+ const color = this.board[pos];
+ this.board[pos] = !color;
+ }
+ this.turn = undo.turn;
+ }
+
+ /**
+ * 指定した位置のマップデータのマスを取得します
+ * @param pos 位置
+ */
+ public mapDataGet(pos: number): MapPixel {
+ const [x, y] = this.transformPosToXy(pos);
+ return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? 'null' : this.map[pos];
+ }
+
+ /**
+ * 打つことができる場所を取得します
+ */
+ public puttablePlaces(color: Color): number[] {
+ return Array.from(this.board.keys()).filter(i => this.canPut(color, i));
+ }
+
+ /**
+ * 打つことができる場所があるかどうかを取得します
+ */
+ public canPutSomewhere(color: Color): boolean {
+ return this.puttablePlaces(color).length > 0;
+ }
+
+ /**
+ * 指定のマスに石を打つことができるかどうかを取得します
+ * @param color 自分の色
+ * @param pos 位置
+ */
+ public canPut(color: Color, pos: number): boolean {
+ return (
+ this.board[pos] !== null ? false : // 既に石が置いてある場所には打てない
+ this.opts.canPutEverywhere ? this.mapDataGet(pos) == 'empty' : // 挟んでなくても置けるモード
+ this.effects(color, pos).length !== 0); // 相手の石を1つでも反転させられるか
+ }
+
+ /**
+ * 指定のマスに石を置いた時の、反転させられる石を取得します
+ * @param color 自分の色
+ * @param initPos 位置
+ */
+ public effects(color: Color, initPos: number): number[] {
+ const enemyColor = !color;
+
+ const diffVectors: [number, number][] = [
+ [ 0, -1], // 上
+ [ +1, -1], // 右上
+ [ +1, 0], // 右
+ [ +1, +1], // 右下
+ [ 0, +1], // 下
+ [ -1, +1], // 左下
+ [ -1, 0], // 左
+ [ -1, -1] // 左上
+ ];
+
+ const effectsInLine = ([dx, dy]: [number, number]): number[] => {
+ const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy];
+
+ const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列
+ let [x, y] = this.transformPosToXy(initPos);
+ while (true) {
+ [x, y] = nextPos(x, y);
+
+ // 座標が指し示す位置がボード外に出たとき
+ if (this.opts.loopedBoard && this.transformXyToPos(
+ (x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth),
+ (y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight)) === initPos)
+ // 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ)
+ return found;
+ else if (x === -1 || y === -1 || x === this.mapWidth || y === this.mapHeight)
+ return []; // 挟めないことが確定 (盤面外に到達)
+
+ const pos = this.transformXyToPos(x, y);
+ if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達)
+ const stone = this.board[pos];
+ if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達)
+ if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見)
+ if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見)
+ }
+ };
+
+ return concat(diffVectors.map(effectsInLine));
+ }
+
+ /**
+ * ゲームが終了したか否か
+ */
+ public get isEnded(): boolean {
+ return this.turn === null;
+ }
+
+ /**
+ * ゲームの勝者 (null = 引き分け)
+ */
+ public get winner(): Color | null {
+ return this.isEnded ?
+ this.blackCount == this.whiteCount ? null :
+ this.opts.isLlotheo === this.blackCount > this.whiteCount ? WHITE : BLACK :
+ undefined as never;
+ }
+}
diff --git a/packages/client/src/scripts/games/reversi/maps.ts b/packages/client/src/scripts/games/reversi/maps.ts
new file mode 100644
index 0000000000..dc0d1bf9d0
--- /dev/null
+++ b/packages/client/src/scripts/games/reversi/maps.ts
@@ -0,0 +1,896 @@
+/**
+ * 組み込みマップ定義
+ *
+ * データ値:
+ * (スペース) ... マス無し
+ * - ... マス
+ * b ... 初期配置される黒石
+ * w ... 初期配置される白石
+ */
+
+export type Map = {
+ name?: string;
+ category?: string;
+ author?: string;
+ data: string[];
+};
+
+export const fourfour: Map = {
+ name: '4x4',
+ category: '4x4',
+ data: [
+ '----',
+ '-wb-',
+ '-bw-',
+ '----'
+ ]
+};
+
+export const sixsix: Map = {
+ name: '6x6',
+ category: '6x6',
+ data: [
+ '------',
+ '------',
+ '--wb--',
+ '--bw--',
+ '------',
+ '------'
+ ]
+};
+
+export const roundedSixsix: Map = {
+ name: '6x6 rounded',
+ category: '6x6',
+ author: 'syuilo',
+ data: [
+ ' ---- ',
+ '------',
+ '--wb--',
+ '--bw--',
+ '------',
+ ' ---- '
+ ]
+};
+
+export const roundedSixsix2: Map = {
+ name: '6x6 rounded 2',
+ category: '6x6',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' ---- ',
+ '--wb--',
+ '--bw--',
+ ' ---- ',
+ ' -- '
+ ]
+};
+
+export const eighteight: Map = {
+ name: '8x8',
+ category: '8x8',
+ data: [
+ '--------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const eighteightH1: Map = {
+ name: '8x8 handicap 1',
+ category: '8x8',
+ data: [
+ 'b-------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const eighteightH2: Map = {
+ name: '8x8 handicap 2',
+ category: '8x8',
+ data: [
+ 'b-------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '-------b'
+ ]
+};
+
+export const eighteightH3: Map = {
+ name: '8x8 handicap 3',
+ category: '8x8',
+ data: [
+ 'b------b',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '-------b'
+ ]
+};
+
+export const eighteightH4: Map = {
+ name: '8x8 handicap 4',
+ category: '8x8',
+ data: [
+ 'b------b',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ 'b------b'
+ ]
+};
+
+export const eighteightH28: Map = {
+ name: '8x8 handicap 28',
+ category: '8x8',
+ data: [
+ 'bbbbbbbb',
+ 'b------b',
+ 'b------b',
+ 'b--wb--b',
+ 'b--bw--b',
+ 'b------b',
+ 'b------b',
+ 'bbbbbbbb'
+ ]
+};
+
+export const roundedEighteight: Map = {
+ name: '8x8 rounded',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' ------ ',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ ' ------ '
+ ]
+};
+
+export const roundedEighteight2: Map = {
+ name: '8x8 rounded 2',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' ---- ',
+ ' ------ ',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ ' ------ ',
+ ' ---- '
+ ]
+};
+
+export const roundedEighteight3: Map = {
+ name: '8x8 rounded 3',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' ---- ',
+ ' ------ ',
+ '---wb---',
+ '---bw---',
+ ' ------ ',
+ ' ---- ',
+ ' -- '
+ ]
+};
+
+export const eighteightWithNotch: Map = {
+ name: '8x8 with notch',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--- ---',
+ '--------',
+ '--------',
+ ' --wb-- ',
+ ' --bw-- ',
+ '--------',
+ '--------',
+ '--- ---'
+ ]
+};
+
+export const eighteightWithSomeHoles: Map = {
+ name: '8x8 with some holes',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--- ----',
+ '----- --',
+ '-- -----',
+ '---wb---',
+ '---bw- -',
+ ' -------',
+ '--- ----',
+ '--------'
+ ]
+};
+
+export const circle: Map = {
+ name: 'Circle',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' ------ ',
+ ' ------ ',
+ '---wb---',
+ '---bw---',
+ ' ------ ',
+ ' ------ ',
+ ' -- '
+ ]
+};
+
+export const smile: Map = {
+ name: 'Smile',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' ------ ',
+ '--------',
+ '-- -- --',
+ '---wb---',
+ '-- bw --',
+ '--- ---',
+ '--------',
+ ' ------ '
+ ]
+};
+
+export const window: Map = {
+ name: 'Window',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--------',
+ '- -- -',
+ '- -- -',
+ '---wb---',
+ '---bw---',
+ '- -- -',
+ '- -- -',
+ '--------'
+ ]
+};
+
+export const reserved: Map = {
+ name: 'Reserved',
+ category: '8x8',
+ author: 'Aya',
+ data: [
+ 'w------b',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ 'b------w'
+ ]
+};
+
+export const x: Map = {
+ name: 'X',
+ category: '8x8',
+ author: 'Aya',
+ data: [
+ 'w------b',
+ '-w----b-',
+ '--w--b--',
+ '---wb---',
+ '---bw---',
+ '--b--w--',
+ '-b----w-',
+ 'b------w'
+ ]
+};
+
+export const parallel: Map = {
+ name: 'Parallel',
+ category: '8x8',
+ author: 'Aya',
+ data: [
+ '--------',
+ '--------',
+ '--------',
+ '---bb---',
+ '---ww---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const lackOfBlack: Map = {
+ name: 'Lack of Black',
+ category: '8x8',
+ data: [
+ '--------',
+ '--------',
+ '--------',
+ '---w----',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const squareParty: Map = {
+ name: 'Square Party',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--------',
+ '-wwwbbb-',
+ '-w-wb-b-',
+ '-wwwbbb-',
+ '-bbbwww-',
+ '-b-bw-w-',
+ '-bbbwww-',
+ '--------'
+ ]
+};
+
+export const minesweeper: Map = {
+ name: 'Minesweeper',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ 'b-b--w-w',
+ '-w-wb-b-',
+ 'w-b--w-b',
+ '-b-wb-w-',
+ '-w-bw-b-',
+ 'b-w--b-w',
+ '-b-bw-w-',
+ 'w-w--b-b'
+ ]
+};
+
+export const tenthtenth: Map = {
+ name: '10x10',
+ category: '10x10',
+ data: [
+ '----------',
+ '----------',
+ '----------',
+ '----------',
+ '----wb----',
+ '----bw----',
+ '----------',
+ '----------',
+ '----------',
+ '----------'
+ ]
+};
+
+export const hole: Map = {
+ name: 'The Hole',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '----------',
+ '----------',
+ '--wb--wb--',
+ '--bw--bw--',
+ '---- ----',
+ '---- ----',
+ '--wb--wb--',
+ '--bw--bw--',
+ '----------',
+ '----------'
+ ]
+};
+
+export const grid: Map = {
+ name: 'Grid',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '----------',
+ '- - -- - -',
+ '----------',
+ '- - -- - -',
+ '----wb----',
+ '----bw----',
+ '- - -- - -',
+ '----------',
+ '- - -- - -',
+ '----------'
+ ]
+};
+
+export const cross: Map = {
+ name: 'Cross',
+ category: '10x10',
+ author: 'Aya',
+ data: [
+ ' ---- ',
+ ' ---- ',
+ ' ---- ',
+ '----------',
+ '----wb----',
+ '----bw----',
+ '----------',
+ ' ---- ',
+ ' ---- ',
+ ' ---- '
+ ]
+};
+
+export const charX: Map = {
+ name: 'Char X',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '--- ---',
+ '---- ----',
+ '----------',
+ ' -------- ',
+ ' --wb-- ',
+ ' --bw-- ',
+ ' -------- ',
+ '----------',
+ '---- ----',
+ '--- ---'
+ ]
+};
+
+export const charY: Map = {
+ name: 'Char Y',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '--- ---',
+ '---- ----',
+ '----------',
+ ' -------- ',
+ ' --wb-- ',
+ ' --bw-- ',
+ ' ------ ',
+ ' ------ ',
+ ' ------ ',
+ ' ------ '
+ ]
+};
+
+export const walls: Map = {
+ name: 'Walls',
+ category: '10x10',
+ author: 'Aya',
+ data: [
+ ' bbbbbbbb ',
+ 'w--------w',
+ 'w--------w',
+ 'w--------w',
+ 'w---wb---w',
+ 'w---bw---w',
+ 'w--------w',
+ 'w--------w',
+ 'w--------w',
+ ' bbbbbbbb '
+ ]
+};
+
+export const cpu: Map = {
+ name: 'CPU',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ ' b b b b ',
+ 'w--------w',
+ ' -------- ',
+ 'w--------w',
+ ' ---wb--- ',
+ ' ---bw--- ',
+ 'w--------w',
+ ' -------- ',
+ 'w--------w',
+ ' b b b b '
+ ]
+};
+
+export const checker: Map = {
+ name: 'Checker',
+ category: '10x10',
+ author: 'Aya',
+ data: [
+ '----------',
+ '----------',
+ '----------',
+ '---wbwb---',
+ '---bwbw---',
+ '---wbwb---',
+ '---bwbw---',
+ '----------',
+ '----------',
+ '----------'
+ ]
+};
+
+export const japaneseCurry: Map = {
+ name: 'Japanese curry',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ 'w-b-b-b-b-',
+ '-w-b-b-b-b',
+ 'w-w-b-b-b-',
+ '-w-w-b-b-b',
+ 'w-w-wwb-b-',
+ '-w-wbb-b-b',
+ 'w-w-w-b-b-',
+ '-w-w-w-b-b',
+ 'w-w-w-w-b-',
+ '-w-w-w-w-b'
+ ]
+};
+
+export const mosaic: Map = {
+ name: 'Mosaic',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '- - - - - ',
+ ' - - - - -',
+ '- - - - - ',
+ ' - w w - -',
+ '- - b b - ',
+ ' - w w - -',
+ '- - b b - ',
+ ' - - - - -',
+ '- - - - - ',
+ ' - - - - -',
+ ]
+};
+
+export const arena: Map = {
+ name: 'Arena',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '- - -- - -',
+ ' - - - - ',
+ '- ------ -',
+ ' -------- ',
+ '- --wb-- -',
+ '- --bw-- -',
+ ' -------- ',
+ '- ------ -',
+ ' - - - - ',
+ '- - -- - -'
+ ]
+};
+
+export const reactor: Map = {
+ name: 'Reactor',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '-w------b-',
+ 'b- - - -w',
+ '- --wb-- -',
+ '---b w---',
+ '- b wb w -',
+ '- w bw b -',
+ '---w b---',
+ '- --bw-- -',
+ 'w- - - -b',
+ '-b------w-'
+ ]
+};
+
+export const sixeight: Map = {
+ name: '6x8',
+ category: 'Special',
+ data: [
+ '------',
+ '------',
+ '------',
+ '--wb--',
+ '--bw--',
+ '------',
+ '------',
+ '------'
+ ]
+};
+
+export const spark: Map = {
+ name: 'Spark',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' - - ',
+ '----------',
+ ' -------- ',
+ ' -------- ',
+ ' ---wb--- ',
+ ' ---bw--- ',
+ ' -------- ',
+ ' -------- ',
+ '----------',
+ ' - - '
+ ]
+};
+
+export const islands: Map = {
+ name: 'Islands',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ '-------- ',
+ '---wb--- ',
+ '---bw--- ',
+ '-------- ',
+ ' - - ',
+ ' - - ',
+ ' --------',
+ ' --------',
+ ' --------',
+ ' --------'
+ ]
+};
+
+export const galaxy: Map = {
+ name: 'Galaxy',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' ------ ',
+ ' --www--- ',
+ ' ------w--- ',
+ '---bbb--w---',
+ '--b---b-w-b-',
+ '-b--wwb-w-b-',
+ '-b-w-bww--b-',
+ '-b-w-b---b--',
+ '---w--bbb---',
+ ' ---w------ ',
+ ' ---www-- ',
+ ' ------ '
+ ]
+};
+
+export const triangle: Map = {
+ name: 'Triangle',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' -- ',
+ ' ---- ',
+ ' ---- ',
+ ' --wb-- ',
+ ' --bw-- ',
+ ' -------- ',
+ ' -------- ',
+ '----------',
+ '----------'
+ ]
+};
+
+export const iphonex: Map = {
+ name: 'iPhone X',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' -- -- ',
+ '--------',
+ '--------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------',
+ '--------',
+ ' ------ '
+ ]
+};
+
+export const dealWithIt: Map = {
+ name: 'Deal with it!',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ '------------',
+ '--w-b-------',
+ ' --b-w------',
+ ' --w-b---- ',
+ ' ------- '
+ ]
+};
+
+export const experiment: Map = {
+ name: 'Let\'s experiment',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' ------------ ',
+ '------wb------',
+ '------bw------',
+ '--------------',
+ ' - - ',
+ '------ ------',
+ 'bbbbbb wwwwww',
+ 'bbbbbb wwwwww',
+ 'bbbbbb wwwwww',
+ 'bbbbbb wwwwww',
+ 'wwwwww bbbbbb'
+ ]
+};
+
+export const bigBoard: Map = {
+ name: 'Big board',
+ category: 'Special',
+ data: [
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '-------wb-------',
+ '-------bw-------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------'
+ ]
+};
+
+export const twoBoard: Map = {
+ name: 'Two board',
+ category: 'Special',
+ author: 'Aya',
+ data: [
+ '-------- --------',
+ '-------- --------',
+ '-------- --------',
+ '---wb--- ---wb---',
+ '---bw--- ---bw---',
+ '-------- --------',
+ '-------- --------',
+ '-------- --------'
+ ]
+};
+
+export const test1: Map = {
+ name: 'Test1',
+ category: 'Test',
+ data: [
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------'
+ ]
+};
+
+export const test2: Map = {
+ name: 'Test2',
+ category: 'Test',
+ data: [
+ '------',
+ '------',
+ '-b--w-',
+ '-w--b-',
+ '-w--b-'
+ ]
+};
+
+export const test3: Map = {
+ name: 'Test3',
+ category: 'Test',
+ data: [
+ '-w-',
+ '--w',
+ 'w--',
+ '-w-',
+ '--w',
+ 'w--',
+ '-w-',
+ '--w',
+ 'w--',
+ '-w-',
+ '---',
+ 'b--',
+ ]
+};
+
+export const test4: Map = {
+ name: 'Test4',
+ category: 'Test',
+ data: [
+ '-w--b-',
+ '-w--b-',
+ '------',
+ '-w--b-',
+ '-w--b-'
+ ]
+};
+
+// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)A1に打ってしまう
+export const test6: Map = {
+ name: 'Test6',
+ category: 'Test',
+ data: [
+ '--wwwww-',
+ 'wwwwwwww',
+ 'wbbbwbwb',
+ 'wbbbbwbb',
+ 'wbwbbwbb',
+ 'wwbwbbbb',
+ '--wbbbbb',
+ '-wwwww--',
+ ]
+};
+
+// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)G7に打ってしまう
+export const test7: Map = {
+ name: 'Test7',
+ category: 'Test',
+ data: [
+ 'b--w----',
+ 'b-wwww--',
+ 'bwbwwwbb',
+ 'wbwwwwb-',
+ 'wwwwwww-',
+ '-wwbbwwb',
+ '--wwww--',
+ '--wwww--',
+ ]
+};
+
+// 検証用: この盤面で藍(lv5)が黒で始めると何故か(?)A1に打ってしまう
+export const test8: Map = {
+ name: 'Test8',
+ category: 'Test',
+ data: [
+ '--------',
+ '-----w--',
+ 'w--www--',
+ 'wwwwww--',
+ 'bbbbwww-',
+ 'wwwwww--',
+ '--www---',
+ '--ww----',
+ ]
+};
diff --git a/packages/client/src/scripts/games/reversi/package.json b/packages/client/src/scripts/games/reversi/package.json
new file mode 100644
index 0000000000..a4415ad141
--- /dev/null
+++ b/packages/client/src/scripts/games/reversi/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "misskey-reversi",
+ "version": "0.0.5",
+ "description": "Misskey reversi engine",
+ "keywords": [
+ "misskey"
+ ],
+ "author": "syuilo <i@syuilo.com>",
+ "license": "MIT",
+ "repository": "https://github.com/misskey-dev/misskey.git",
+ "bugs": "https://github.com/misskey-dev/misskey/issues",
+ "main": "./built/core.js",
+ "types": "./built/core.d.ts",
+ "scripts": {
+ "build": "tsc"
+ },
+ "dependencies": {}
+}
diff --git a/packages/client/src/scripts/games/reversi/tsconfig.json b/packages/client/src/scripts/games/reversi/tsconfig.json
new file mode 100644
index 0000000000..851fb6b7e4
--- /dev/null
+++ b/packages/client/src/scripts/games/reversi/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "noEmitOnError": false,
+ "noImplicitAny": false,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "experimentalDecorators": true,
+ "declaration": true,
+ "sourceMap": false,
+ "target": "es2017",
+ "module": "commonjs",
+ "removeComments": false,
+ "noLib": false,
+ "outDir": "./built",
+ "rootDir": "./"
+ },
+ "compileOnSave": false,
+ "include": [
+ "./core.ts"
+ ]
+}
diff --git a/packages/client/src/scripts/gen-search-query.ts b/packages/client/src/scripts/gen-search-query.ts
new file mode 100644
index 0000000000..57a06c280c
--- /dev/null
+++ b/packages/client/src/scripts/gen-search-query.ts
@@ -0,0 +1,31 @@
+import * as Acct from 'misskey-js/built/acct';
+import { host as localHost } from '@/config';
+
+export async function genSearchQuery(v: any, q: string) {
+ let host: string;
+ let userId: string;
+ if (q.split(' ').some(x => x.startsWith('@'))) {
+ for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) {
+ if (at.includes('.')) {
+ if (at === localHost || at === '.') {
+ host = null;
+ } else {
+ host = at;
+ }
+ } else {
+ const user = await v.os.api('users/show', Acct.parse(at)).catch(x => null);
+ if (user) {
+ userId = user.id;
+ } else {
+ // todo: show error
+ }
+ }
+ }
+
+ }
+ return {
+ query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '),
+ host: host,
+ userId: userId
+ };
+}
diff --git a/packages/client/src/scripts/get-account-from-id.ts b/packages/client/src/scripts/get-account-from-id.ts
new file mode 100644
index 0000000000..ba3adceecc
--- /dev/null
+++ b/packages/client/src/scripts/get-account-from-id.ts
@@ -0,0 +1,7 @@
+import { get } from '@/scripts/idb-proxy';
+
+export async function getAccountFromId(id: string) {
+ const accounts = await get('accounts') as { token: string; id: string; }[];
+ if (!accounts) console.log('Accounts are not recorded');
+ return accounts.find(e => e.id === id);
+}
diff --git a/packages/client/src/scripts/get-md5.ts b/packages/client/src/scripts/get-md5.ts
new file mode 100644
index 0000000000..b002d762b1
--- /dev/null
+++ b/packages/client/src/scripts/get-md5.ts
@@ -0,0 +1,10 @@
+// スクリプトサイズがデカい
+//import * as crypto from 'crypto';
+
+export default (data: ArrayBuffer) => {
+ //const buf = new Buffer(data);
+ //const hash = crypto.createHash('md5');
+ //hash.update(buf);
+ //return hash.digest('hex');
+ return '';
+};
diff --git a/packages/client/src/scripts/get-note-summary.ts b/packages/client/src/scripts/get-note-summary.ts
new file mode 100644
index 0000000000..bd394279cb
--- /dev/null
+++ b/packages/client/src/scripts/get-note-summary.ts
@@ -0,0 +1,55 @@
+import * as misskey from 'misskey-js';
+import { i18n } from '@/i18n';
+
+/**
+ * 投稿を表す文字列を取得します。
+ * @param {*} note (packされた)投稿
+ */
+export const getNoteSummary = (note: misskey.entities.Note): string => {
+ if (note.deletedAt) {
+ return `(${i18n.locale.deletedNote})`;
+ }
+
+ if (note.isHidden) {
+ return `(${i18n.locale.invisibleNote})`;
+ }
+
+ let summary = '';
+
+ // 本文
+ if (note.cw != null) {
+ summary += note.cw;
+ } else {
+ summary += note.text ? note.text : '';
+ }
+
+ // ファイルが添付されているとき
+ if ((note.files || []).length != 0) {
+ summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`;
+ }
+
+ // 投票が添付されているとき
+ if (note.poll) {
+ summary += ` (${i18n.locale.poll})`;
+ }
+
+ // 返信のとき
+ if (note.replyId) {
+ if (note.reply) {
+ summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
+ } else {
+ summary += '\n\nRE: ...';
+ }
+ }
+
+ // Renoteのとき
+ if (note.renoteId) {
+ if (note.renote) {
+ summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
+ } else {
+ summary += '\n\nRN: ...';
+ }
+ }
+
+ return summary.trim();
+};
diff --git a/packages/client/src/scripts/get-static-image-url.ts b/packages/client/src/scripts/get-static-image-url.ts
new file mode 100644
index 0000000000..e9a3e87cc8
--- /dev/null
+++ b/packages/client/src/scripts/get-static-image-url.ts
@@ -0,0 +1,16 @@
+import { url as instanceUrl } from '@/config';
+import * as url from '@/scripts/url';
+
+export function getStaticImageUrl(baseUrl: string): string {
+ const u = new URL(baseUrl);
+ if (u.href.startsWith(`${instanceUrl}/proxy/`)) {
+ // もう既にproxyっぽそうだったらsearchParams付けるだけ
+ u.searchParams.set('static', '1');
+ return u.href;
+ }
+ const dummy = `${u.host}${u.pathname}`; // 拡張子がないとキャッシュしてくれないCDNがあるので
+ return `${instanceUrl}/proxy/${dummy}?${url.query({
+ url: u.href,
+ static: '1'
+ })}`;
+}
diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts
new file mode 100644
index 0000000000..8d767afa25
--- /dev/null
+++ b/packages/client/src/scripts/get-user-menu.ts
@@ -0,0 +1,205 @@
+import { i18n } from '@/i18n';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { host } from '@/config';
+import * as Acct from 'misskey-js/built/acct';
+import * as os from '@/os';
+import { userActions } from '@/store';
+import { router } from '@/router';
+import { $i } from '@/account';
+
+export function getUserMenu(user) {
+ const meId = $i ? $i.id : null;
+
+ async function pushList() {
+ const t = i18n.locale.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく
+ const lists = await os.api('users/lists/list');
+ if (lists.length === 0) {
+ os.dialog({
+ type: 'error',
+ text: i18n.locale.youHaveNoLists
+ });
+ return;
+ }
+ const { canceled, result: listId } = await os.dialog({
+ type: null,
+ title: t,
+ select: {
+ items: lists.map(list => ({
+ value: list.id, text: list.name
+ }))
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ os.apiWithDialog('users/lists/push', {
+ listId: listId,
+ userId: user.id
+ });
+ }
+
+ async function inviteGroup() {
+ const groups = await os.api('users/groups/owned');
+ if (groups.length === 0) {
+ os.dialog({
+ type: 'error',
+ text: i18n.locale.youHaveNoGroups
+ });
+ return;
+ }
+ const { canceled, result: groupId } = await os.dialog({
+ type: null,
+ title: i18n.locale.group,
+ select: {
+ items: groups.map(group => ({
+ value: group.id, text: group.name
+ }))
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ os.apiWithDialog('users/groups/invite', {
+ groupId: groupId,
+ userId: user.id
+ });
+ }
+
+ async function toggleMute() {
+ os.apiWithDialog(user.isMuted ? 'mute/delete' : 'mute/create', {
+ userId: user.id
+ }).then(() => {
+ user.isMuted = !user.isMuted;
+ });
+ }
+
+ async function toggleBlock() {
+ if (!await getConfirmed(user.isBlocking ? i18n.locale.unblockConfirm : i18n.locale.blockConfirm)) return;
+
+ os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', {
+ userId: user.id
+ }).then(() => {
+ user.isBlocking = !user.isBlocking;
+ });
+ }
+
+ async function toggleSilence() {
+ if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return;
+
+ os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', {
+ userId: user.id
+ }).then(() => {
+ user.isSilenced = !user.isSilenced;
+ });
+ }
+
+ async function toggleSuspend() {
+ if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return;
+
+ os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', {
+ userId: user.id
+ }).then(() => {
+ user.isSuspended = !user.isSuspended;
+ });
+ }
+
+ function reportAbuse() {
+ os.popup(import('@/components/abuse-report-window.vue'), {
+ user: user,
+ }, {}, 'closed');
+ }
+
+ async function getConfirmed(text: string): Promise<boolean> {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ title: 'confirm',
+ text,
+ });
+
+ return !confirm.canceled;
+ }
+
+ let menu = [{
+ icon: 'fas fa-at',
+ text: i18n.locale.copyUsername,
+ action: () => {
+ copyToClipboard(`@${user.username}@${user.host || host}`);
+ }
+ }, {
+ icon: 'fas fa-info-circle',
+ text: i18n.locale.info,
+ action: () => {
+ os.pageWindow(`/user-info/${user.id}`);
+ }
+ }, {
+ icon: 'fas fa-envelope',
+ text: i18n.locale.sendMessage,
+ action: () => {
+ os.post({ specified: user });
+ }
+ }, meId != user.id ? {
+ type: 'link',
+ icon: 'fas fa-comments',
+ text: i18n.locale.startMessaging,
+ to: '/my/messaging/' + Acct.toString(user),
+ } : undefined, null, {
+ icon: 'fas fa-list-ul',
+ text: i18n.locale.addToList,
+ action: pushList
+ }, meId != user.id ? {
+ icon: 'fas fa-users',
+ text: i18n.locale.inviteToGroup,
+ action: inviteGroup
+ } : undefined] as any;
+
+ if ($i && meId != user.id) {
+ menu = menu.concat([null, {
+ icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash',
+ text: user.isMuted ? i18n.locale.unmute : i18n.locale.mute,
+ action: toggleMute
+ }, {
+ icon: 'fas fa-ban',
+ text: user.isBlocking ? i18n.locale.unblock : i18n.locale.block,
+ action: toggleBlock
+ }]);
+
+ menu = menu.concat([null, {
+ icon: 'fas fa-exclamation-circle',
+ text: i18n.locale.reportAbuse,
+ action: reportAbuse
+ }]);
+
+ if ($i && ($i.isAdmin || $i.isModerator)) {
+ menu = menu.concat([null, {
+ icon: 'fas fa-microphone-slash',
+ text: user.isSilenced ? i18n.locale.unsilence : i18n.locale.silence,
+ action: toggleSilence
+ }, {
+ icon: 'fas fa-snowflake',
+ text: user.isSuspended ? i18n.locale.unsuspend : i18n.locale.suspend,
+ action: toggleSuspend
+ }]);
+ }
+ }
+
+ if ($i && meId === user.id) {
+ menu = menu.concat([null, {
+ icon: 'fas fa-pencil-alt',
+ text: i18n.locale.editProfile,
+ action: () => {
+ router.push('/settings/profile');
+ }
+ }]);
+ }
+
+ if (userActions.length > 0) {
+ menu = menu.concat([null, ...userActions.map(action => ({
+ icon: 'fas fa-plug',
+ text: action.title,
+ action: () => {
+ action.handler(user);
+ }
+ }))]);
+ }
+
+ return menu;
+}
diff --git a/packages/client/src/scripts/hotkey.ts b/packages/client/src/scripts/hotkey.ts
new file mode 100644
index 0000000000..2b3f491fd8
--- /dev/null
+++ b/packages/client/src/scripts/hotkey.ts
@@ -0,0 +1,88 @@
+import keyCode from './keycode';
+
+type Keymap = Record<string, Function>;
+
+type Pattern = {
+ which: string[];
+ ctrl?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+};
+
+type Action = {
+ patterns: Pattern[];
+ callback: Function;
+ allowRepeat: boolean;
+};
+
+const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => {
+ const result = {
+ patterns: [],
+ callback: callback,
+ allowRepeat: true
+ } as Action;
+
+ if (patterns.match(/^\(.*\)$/) !== null) {
+ result.allowRepeat = false;
+ patterns = patterns.slice(1, -1);
+ }
+
+ result.patterns = patterns.split('|').map(part => {
+ const pattern = {
+ which: [],
+ ctrl: false,
+ alt: false,
+ shift: false
+ } as Pattern;
+
+ const keys = part.trim().split('+').map(x => x.trim().toLowerCase());
+ for (const key of keys) {
+ switch (key) {
+ case 'ctrl': pattern.ctrl = true; break;
+ case 'alt': pattern.alt = true; break;
+ case 'shift': pattern.shift = true; break;
+ default: pattern.which = keyCode(key).map(k => k.toLowerCase());
+ }
+ }
+
+ return pattern;
+ });
+
+ return result;
+});
+
+const ignoreElemens = ['input', 'textarea'];
+
+function match(e: KeyboardEvent, patterns: Action['patterns']): boolean {
+ const key = e.code.toLowerCase();
+ return patterns.some(pattern => pattern.which.includes(key) &&
+ pattern.ctrl === e.ctrlKey &&
+ pattern.shift === e.shiftKey &&
+ pattern.alt === e.altKey &&
+ !e.metaKey
+ );
+}
+
+export const makeHotkey = (keymap: Keymap) => {
+ const actions = parseKeymap(keymap);
+
+ return (e: KeyboardEvent) => {
+ if (document.activeElement) {
+ if (ignoreElemens.some(el => document.activeElement!.matches(el))) return;
+ if (document.activeElement.attributes['contenteditable']) return;
+ }
+
+ for (const action of actions) {
+ const matched = match(e, action.patterns);
+
+ if (matched) {
+ if (!action.allowRepeat && e.repeat) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+ action.callback(e);
+ break;
+ }
+ }
+ };
+};
diff --git a/packages/client/src/scripts/hpml/block.ts b/packages/client/src/scripts/hpml/block.ts
new file mode 100644
index 0000000000..804c5c1124
--- /dev/null
+++ b/packages/client/src/scripts/hpml/block.ts
@@ -0,0 +1,109 @@
+// blocks
+
+export type BlockBase = {
+ id: string;
+ type: string;
+};
+
+export type TextBlock = BlockBase & {
+ type: 'text';
+ text: string;
+};
+
+export type SectionBlock = BlockBase & {
+ type: 'section';
+ title: string;
+ children: (Block | VarBlock)[];
+};
+
+export type ImageBlock = BlockBase & {
+ type: 'image';
+ fileId: string | null;
+};
+
+export type ButtonBlock = BlockBase & {
+ type: 'button';
+ text: any;
+ primary: boolean;
+ action: string;
+ content: string;
+ event: string;
+ message: string;
+ var: string;
+ fn: string;
+};
+
+export type IfBlock = BlockBase & {
+ type: 'if';
+ var: string;
+ children: Block[];
+};
+
+export type TextareaBlock = BlockBase & {
+ type: 'textarea';
+ text: string;
+};
+
+export type PostBlock = BlockBase & {
+ type: 'post';
+ text: string;
+ attachCanvasImage: boolean;
+ canvasId: string;
+};
+
+export type CanvasBlock = BlockBase & {
+ type: 'canvas';
+ name: string; // canvas id
+ width: number;
+ height: number;
+};
+
+export type NoteBlock = BlockBase & {
+ type: 'note';
+ detailed: boolean;
+ note: string | null;
+};
+
+export type Block =
+ TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock;
+
+// variable blocks
+
+export type VarBlockBase = BlockBase & {
+ name: string;
+};
+
+export type NumberInputVarBlock = VarBlockBase & {
+ type: 'numberInput';
+ text: string;
+};
+
+export type TextInputVarBlock = VarBlockBase & {
+ type: 'textInput';
+ text: string;
+};
+
+export type SwitchVarBlock = VarBlockBase & {
+ type: 'switch';
+ text: string;
+};
+
+export type RadioButtonVarBlock = VarBlockBase & {
+ type: 'radioButton';
+ title: string;
+ values: string[];
+};
+
+export type CounterVarBlock = VarBlockBase & {
+ type: 'counter';
+ text: string;
+ inc: number;
+};
+
+export type VarBlock =
+ NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock;
+
+const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter'];
+export function isVarBlock(block: Block): block is VarBlock {
+ return varBlock.includes(block.type);
+}
diff --git a/packages/client/src/scripts/hpml/evaluator.ts b/packages/client/src/scripts/hpml/evaluator.ts
new file mode 100644
index 0000000000..20261d333d
--- /dev/null
+++ b/packages/client/src/scripts/hpml/evaluator.ts
@@ -0,0 +1,234 @@
+import autobind from 'autobind-decorator';
+import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.';
+import { version } from '@/config';
+import { AiScript, utils, values } from '@syuilo/aiscript';
+import { createAiScriptEnv } from '../aiscript/api';
+import { collectPageVars } from '../collect-page-vars';
+import { initHpmlLib, initAiLib } from './lib';
+import * as os from '@/os';
+import { markRaw, ref, Ref, unref } from 'vue';
+import { Expr, isLiteralValue, Variable } from './expr';
+
+/**
+ * Hpml evaluator
+ */
+export class Hpml {
+ private variables: Variable[];
+ private pageVars: PageVar[];
+ private envVars: Record<keyof typeof envVarsDef, any>;
+ public aiscript?: AiScript;
+ public pageVarUpdatedCallback?: values.VFn;
+ public canvases: Record<string, HTMLCanvasElement> = {};
+ public vars: Ref<Record<string, any>> = ref({});
+ public page: Record<string, any>;
+
+ private opts: {
+ randomSeed: string; visitor?: any; url?: string;
+ enableAiScript: boolean;
+ };
+
+ constructor(page: Hpml['page'], opts: Hpml['opts']) {
+ this.page = page;
+ this.variables = this.page.variables;
+ this.pageVars = collectPageVars(this.page.content);
+ this.opts = opts;
+
+ if (this.opts.enableAiScript) {
+ this.aiscript = markRaw(new AiScript({ ...createAiScriptEnv({
+ storageKey: 'pages:' + this.page.id
+ }), ...initAiLib(this)}, {
+ in: (q) => {
+ return new Promise(ok => {
+ os.dialog({
+ title: q,
+ input: {}
+ }).then(({ canceled, result: a }) => {
+ ok(a);
+ });
+ });
+ },
+ out: (value) => {
+ console.log(value);
+ },
+ log: (type, params) => {
+ },
+ }));
+
+ this.aiscript.scope.opts.onUpdated = (name, value) => {
+ this.eval();
+ };
+ }
+
+ const date = new Date();
+
+ this.envVars = {
+ AI: 'kawaii',
+ VERSION: version,
+ URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '',
+ LOGIN: opts.visitor != null,
+ NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '',
+ USERNAME: opts.visitor ? opts.visitor.username : '',
+ USERID: opts.visitor ? opts.visitor.id : '',
+ NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0,
+ FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0,
+ FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0,
+ IS_CAT: opts.visitor ? opts.visitor.isCat : false,
+ SEED: opts.randomSeed ? opts.randomSeed : '',
+ YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`,
+ AISCRIPT_DISABLED: !this.opts.enableAiScript,
+ NULL: null
+ };
+
+ this.eval();
+ }
+
+ @autobind
+ public eval() {
+ try {
+ this.vars.value = this.evaluateVars();
+ } catch (e) {
+ //this.onError(e);
+ }
+ }
+
+ @autobind
+ public interpolate(str: string) {
+ if (str == null) return null;
+ return str.replace(/{(.+?)}/g, match => {
+ const v = unref(this.vars)[match.slice(1, -1).trim()];
+ return v == null ? 'NULL' : v.toString();
+ });
+ }
+
+ @autobind
+ public callAiScript(fn: string) {
+ try {
+ if (this.aiscript) this.aiscript.execFn(this.aiscript.scope.get(fn), []);
+ } catch (e) {}
+ }
+
+ @autobind
+ public registerCanvas(id: string, canvas: any) {
+ this.canvases[id] = canvas;
+ }
+
+ @autobind
+ public updatePageVar(name: string, value: any) {
+ const pageVar = this.pageVars.find(v => v.name === name);
+ if (pageVar !== undefined) {
+ pageVar.value = value;
+ if (this.pageVarUpdatedCallback) {
+ if (this.aiscript) this.aiscript.execFn(this.pageVarUpdatedCallback, [values.STR(name), utils.jsToVal(value)]);
+ }
+ } else {
+ throw new HpmlError(`No such page var '${name}'`);
+ }
+ }
+
+ @autobind
+ public updateRandomSeed(seed: string) {
+ this.opts.randomSeed = seed;
+ this.envVars.SEED = seed;
+ }
+
+ @autobind
+ private _interpolateScope(str: string, scope: HpmlScope) {
+ return str.replace(/{(.+?)}/g, match => {
+ const v = scope.getState(match.slice(1, -1).trim());
+ return v == null ? 'NULL' : v.toString();
+ });
+ }
+
+ @autobind
+ public evaluateVars(): Record<string, any> {
+ const values: Record<string, any> = {};
+
+ for (const [k, v] of Object.entries(this.envVars)) {
+ values[k] = v;
+ }
+
+ for (const v of this.pageVars) {
+ values[v.name] = v.value;
+ }
+
+ for (const v of this.variables) {
+ values[v.name] = this.evaluate(v, new HpmlScope([values]));
+ }
+
+ return values;
+ }
+
+ @autobind
+ private evaluate(expr: Expr, scope: HpmlScope): any {
+
+ if (isLiteralValue(expr)) {
+ if (expr.type === null) {
+ return null;
+ }
+
+ if (expr.type === 'number') {
+ return parseInt((expr.value as any), 10);
+ }
+
+ if (expr.type === 'text' || expr.type === 'multiLineText') {
+ return this._interpolateScope(expr.value || '', scope);
+ }
+
+ if (expr.type === 'textList') {
+ return this._interpolateScope(expr.value || '', scope).trim().split('\n');
+ }
+
+ if (expr.type === 'ref') {
+ return scope.getState(expr.value);
+ }
+
+ if (expr.type === 'aiScriptVar') {
+ if (this.aiscript) {
+ try {
+ return utils.valToJs(this.aiscript.scope.get(expr.value));
+ } catch (e) {
+ return null;
+ }
+ } else {
+ return null;
+ }
+ }
+
+ // Define user function
+ if (expr.type == 'fn') {
+ return {
+ slots: expr.value.slots.map(x => x.name),
+ exec: (slotArg: Record<string, any>) => {
+ return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id));
+ }
+ } as Fn;
+ }
+ return;
+ }
+
+ // Call user function
+ if (expr.type.startsWith('fn:')) {
+ const fnName = expr.type.split(':')[1];
+ const fn = scope.getState(fnName);
+ const args = {} as Record<string, any>;
+ for (let i = 0; i < fn.slots.length; i++) {
+ const name = fn.slots[i];
+ args[name] = this.evaluate(expr.args[i], scope);
+ }
+ return fn.exec(args);
+ }
+
+ if (expr.args === undefined) return null;
+
+ const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor);
+
+ // Call function
+ const fnName = expr.type;
+ const fn = (funcs as any)[fnName];
+ if (fn == null) {
+ throw new HpmlError(`No such function '${fnName}'`);
+ } else {
+ return fn(...expr.args.map(x => this.evaluate(x, scope)));
+ }
+ }
+}
diff --git a/packages/client/src/scripts/hpml/expr.ts b/packages/client/src/scripts/hpml/expr.ts
new file mode 100644
index 0000000000..00e3ed118b
--- /dev/null
+++ b/packages/client/src/scripts/hpml/expr.ts
@@ -0,0 +1,79 @@
+import { literalDefs, Type } from '.';
+
+export type ExprBase = {
+ id: string;
+};
+
+// value
+
+export type EmptyValue = ExprBase & {
+ type: null;
+ value: null;
+};
+
+export type TextValue = ExprBase & {
+ type: 'text';
+ value: string;
+};
+
+export type MultiLineTextValue = ExprBase & {
+ type: 'multiLineText';
+ value: string;
+};
+
+export type TextListValue = ExprBase & {
+ type: 'textList';
+ value: string;
+};
+
+export type NumberValue = ExprBase & {
+ type: 'number';
+ value: number;
+};
+
+export type RefValue = ExprBase & {
+ type: 'ref';
+ value: string; // value is variable name
+};
+
+export type AiScriptRefValue = ExprBase & {
+ type: 'aiScriptVar';
+ value: string; // value is variable name
+};
+
+export type UserFnValue = ExprBase & {
+ type: 'fn';
+ value: UserFnInnerValue;
+};
+type UserFnInnerValue = {
+ slots: {
+ name: string;
+ type: Type;
+ }[];
+ expression: Expr;
+};
+
+export type Value =
+ EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue;
+
+export function isLiteralValue(expr: Expr): expr is Value {
+ if (expr.type == null) return true;
+ if (literalDefs[expr.type]) return true;
+ return false;
+}
+
+// call function
+
+export type CallFn = ExprBase & { // "fn:hoge" or string
+ type: string;
+ args: Expr[];
+ value: null;
+};
+
+// variable
+export type Variable = (Value | CallFn) & {
+ name: string;
+};
+
+// expression
+export type Expr = Variable | Value | CallFn;
diff --git a/packages/client/src/scripts/hpml/index.ts b/packages/client/src/scripts/hpml/index.ts
new file mode 100644
index 0000000000..ac81eac2d9
--- /dev/null
+++ b/packages/client/src/scripts/hpml/index.ts
@@ -0,0 +1,103 @@
+/**
+ * Hpml
+ */
+
+import autobind from 'autobind-decorator';
+import { Hpml } from './evaluator';
+import { funcDefs } from './lib';
+
+export type Fn = {
+ slots: string[];
+ exec: (args: Record<string, any>) => ReturnType<Hpml['evaluate']>;
+};
+
+export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null;
+
+export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = {
+ text: { out: 'string', category: 'value', icon: 'fas fa-quote-right', },
+ multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left', },
+ textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list', },
+ number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up', },
+ ref: { out: null, category: 'value', icon: 'fas fa-magic', },
+ aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic', },
+ fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt', },
+};
+
+export const blockDefs = [
+ ...Object.entries(literalDefs).map(([k, v]) => ({
+ type: k, out: v.out, category: v.category, icon: v.icon
+ })),
+ ...Object.entries(funcDefs).map(([k, v]) => ({
+ type: k, out: v.out, category: v.category, icon: v.icon
+ }))
+];
+
+export type PageVar = { name: string; value: any; type: Type; };
+
+export const envVarsDef: Record<string, Type> = {
+ AI: 'string',
+ URL: 'string',
+ VERSION: 'string',
+ LOGIN: 'boolean',
+ NAME: 'string',
+ USERNAME: 'string',
+ USERID: 'string',
+ NOTES_COUNT: 'number',
+ FOLLOWERS_COUNT: 'number',
+ FOLLOWING_COUNT: 'number',
+ IS_CAT: 'boolean',
+ SEED: null,
+ YMD: 'string',
+ AISCRIPT_DISABLED: 'boolean',
+ NULL: null,
+};
+
+export class HpmlScope {
+ private layerdStates: Record<string, any>[];
+ public name: string;
+
+ constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) {
+ this.layerdStates = layerdStates;
+ this.name = name || 'anonymous';
+ }
+
+ @autobind
+ public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope {
+ const layer = [states, ...this.layerdStates];
+ return new HpmlScope(layer, name);
+ }
+
+ /**
+ * 指定した名前の変数の値を取得します
+ * @param name 変数名
+ */
+ @autobind
+ public getState(name: string): any {
+ for (const later of this.layerdStates) {
+ const state = later[name];
+ if (state !== undefined) {
+ return state;
+ }
+ }
+
+ throw new HpmlError(
+ `No such variable '${name}' in scope '${this.name}'`, {
+ scope: this.layerdStates
+ });
+ }
+}
+
+export class HpmlError extends Error {
+ public info?: any;
+
+ constructor(message: string, info?: any) {
+ super(message);
+
+ this.info = info;
+
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, HpmlError);
+ }
+ }
+}
diff --git a/packages/client/src/scripts/hpml/lib.ts b/packages/client/src/scripts/hpml/lib.ts
new file mode 100644
index 0000000000..2a1ac73a40
--- /dev/null
+++ b/packages/client/src/scripts/hpml/lib.ts
@@ -0,0 +1,246 @@
+import * as tinycolor from 'tinycolor2';
+import { Hpml } from './evaluator';
+import { values, utils } from '@syuilo/aiscript';
+import { Fn, HpmlScope } from '.';
+import { Expr } from './expr';
+import * as seedrandom from 'seedrandom';
+
+/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color
+// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs
+Chart.pluginService.register({
+ beforeDraw: (chart, easing) => {
+ if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) {
+ const ctx = chart.chart.ctx;
+ ctx.save();
+ ctx.fillStyle = chart.config.options.chartArea.backgroundColor;
+ ctx.fillRect(0, 0, chart.chart.width, chart.chart.height);
+ ctx.restore();
+ }
+ }
+});
+*/
+
+export function initAiLib(hpml: Hpml) {
+ return {
+ 'MkPages:updated': values.FN_NATIVE(([callback]) => {
+ hpml.pageVarUpdatedCallback = (callback as values.VFn);
+ }),
+ 'MkPages:get_canvas': values.FN_NATIVE(([id]) => {
+ utils.assertString(id);
+ const canvas = hpml.canvases[id.value];
+ const ctx = canvas.getContext('2d');
+ return values.OBJ(new Map([
+ ['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })],
+ ['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })],
+ ['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })],
+ ['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })],
+ ['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })],
+ ['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })],
+ ['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })],
+ ['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })],
+ ['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })],
+ ['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })],
+ ['close_path', values.FN_NATIVE(() => { ctx.closePath(); })],
+ ['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })],
+ ['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })],
+ ['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })],
+ ['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })],
+ ['fill', values.FN_NATIVE(() => { ctx.fill(); })],
+ ['stroke', values.FN_NATIVE(() => { ctx.stroke(); })],
+ ]));
+ }),
+ 'MkPages:chart': values.FN_NATIVE(([id, opts]) => {
+ /* TODO
+ utils.assertString(id);
+ utils.assertObject(opts);
+ const canvas = hpml.canvases[id.value];
+ const color = getComputedStyle(document.documentElement).getPropertyValue('--accent');
+ Chart.defaults.color = '#555';
+ const chart = new Chart(canvas, {
+ type: opts.value.get('type').value,
+ data: {
+ labels: opts.value.get('labels').value.map(x => x.value),
+ datasets: opts.value.get('datasets').value.map(x => ({
+ label: x.value.has('label') ? x.value.get('label').value : '',
+ data: x.value.get('data').value.map(x => x.value),
+ pointRadius: 0,
+ lineTension: 0,
+ borderWidth: 2,
+ borderColor: x.value.has('color') ? x.value.get('color') : color,
+ backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(),
+ }))
+ },
+ options: {
+ responsive: false,
+ devicePixelRatio: 1.5,
+ title: {
+ display: opts.value.has('title'),
+ text: opts.value.has('title') ? opts.value.get('title').value : '',
+ fontSize: 14,
+ },
+ layout: {
+ padding: {
+ left: 32,
+ right: 32,
+ top: opts.value.has('title') ? 16 : 32,
+ bottom: 16
+ }
+ },
+ legend: {
+ display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true,
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ tooltips: {
+ enabled: false,
+ },
+ chartArea: {
+ backgroundColor: '#fff'
+ },
+ ...(opts.value.get('type').value === 'radar' ? {
+ scale: {
+ ticks: {
+ display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false,
+ min: opts.value.has('min') ? opts.value.get('min').value : undefined,
+ max: opts.value.has('max') ? opts.value.get('max').value : undefined,
+ maxTicksLimit: 8,
+ },
+ pointLabels: {
+ fontSize: 12
+ }
+ }
+ } : {
+ scales: {
+ yAxes: [{
+ ticks: {
+ display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true,
+ min: opts.value.has('min') ? opts.value.get('min').value : undefined,
+ max: opts.value.has('max') ? opts.value.get('max').value : undefined,
+ }
+ }]
+ }
+ })
+ }
+ });
+ */
+ })
+ };
+}
+
+export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = {
+ if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'fas fa-share-alt', },
+ for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle', },
+ not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', },
+ or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', },
+ and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', },
+ add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-plus', },
+ subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-minus', },
+ multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-times', },
+ divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide', },
+ mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide', },
+ round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator', },
+ eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals', },
+ notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal', },
+ gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than', },
+ lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than', },
+ gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal', },
+ ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal', },
+ strLen: { in: ['string'], out: 'number', category: 'text', icon: 'fas fa-quote-right', },
+ strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
+ strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
+ strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
+ join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
+ stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt', },
+ numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt', },
+ splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt', },
+ pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent', },
+ listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent', },
+ rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', },
+ dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', },
+ seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', },
+ random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', },
+ dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', },
+ seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', },
+ randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice', },
+ dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice', },
+ seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice', },
+ DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice', }, // dailyRandomPickWithProbabilityMapping
+};
+
+export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) {
+
+ const date = new Date();
+ const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
+
+ const funcs: Record<string, Function> = {
+ not: (a: boolean) => !a,
+ or: (a: boolean, b: boolean) => a || b,
+ and: (a: boolean, b: boolean) => a && b,
+ eq: (a: any, b: any) => a === b,
+ notEq: (a: any, b: any) => a !== b,
+ gt: (a: number, b: number) => a > b,
+ lt: (a: number, b: number) => a < b,
+ gtEq: (a: number, b: number) => a >= b,
+ ltEq: (a: number, b: number) => a <= b,
+ if: (bool: boolean, a: any, b: any) => bool ? a : b,
+ for: (times: number, fn: Fn) => {
+ const result: any[] = [];
+ for (let i = 0; i < times; i++) {
+ result.push(fn.exec({
+ [fn.slots[0]]: i + 1
+ }));
+ }
+ return result;
+ },
+ add: (a: number, b: number) => a + b,
+ subtract: (a: number, b: number) => a - b,
+ multiply: (a: number, b: number) => a * b,
+ divide: (a: number, b: number) => a / b,
+ mod: (a: number, b: number) => a % b,
+ round: (a: number) => Math.round(a),
+ strLen: (a: string) => a.length,
+ strPick: (a: string, b: number) => a[b - 1],
+ strReplace: (a: string, b: string, c: string) => a.split(b).join(c),
+ strReverse: (a: string) => a.split('').reverse().join(''),
+ join: (texts: string[], separator: string) => texts.join(separator || ''),
+ stringToNumber: (a: string) => parseInt(a),
+ numberToString: (a: number) => a.toString(),
+ splitStrByLine: (a: string) => a.split('\n'),
+ pick: (list: any[], i: number) => list[i - 1],
+ listLen: (list: any[]) => list.length,
+ random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability,
+ rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)),
+ randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)],
+ dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability,
+ dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)),
+ dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)],
+ seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability,
+ seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)),
+ seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)],
+ DRPWPM: (list: string[]) => {
+ const xs: any[] = [];
+ let totalFactor = 0;
+ for (const x of list) {
+ const parts = x.split(' ');
+ const factor = parseInt(parts.pop()!, 10);
+ const text = parts.join(' ');
+ totalFactor += factor;
+ xs.push({ factor, text });
+ }
+ const r = seedrandom(`${day}:${expr.id}`)() * totalFactor;
+ let stackedFactor = 0;
+ for (const x of xs) {
+ if (r >= stackedFactor && r <= stackedFactor + x.factor) {
+ return x.text;
+ } else {
+ stackedFactor += x.factor;
+ }
+ }
+ return xs[0].text;
+ },
+ };
+
+ return funcs;
+}
diff --git a/packages/client/src/scripts/hpml/type-checker.ts b/packages/client/src/scripts/hpml/type-checker.ts
new file mode 100644
index 0000000000..9633b3cd01
--- /dev/null
+++ b/packages/client/src/scripts/hpml/type-checker.ts
@@ -0,0 +1,189 @@
+import autobind from 'autobind-decorator';
+import { Type, envVarsDef, PageVar } from '.';
+import { Expr, isLiteralValue, Variable } from './expr';
+import { funcDefs } from './lib';
+
+type TypeError = {
+ arg: number;
+ expect: Type;
+ actual: Type;
+};
+
+/**
+ * Hpml type checker
+ */
+export class HpmlTypeChecker {
+ public variables: Variable[];
+ public pageVars: PageVar[];
+
+ constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) {
+ this.variables = variables;
+ this.pageVars = pageVars;
+ }
+
+ @autobind
+ public typeCheck(v: Expr): TypeError | null {
+ if (isLiteralValue(v)) return null;
+
+ const def = funcDefs[v.type || ''];
+ if (def == null) {
+ throw new Error('Unknown type: ' + v.type);
+ }
+
+ const generic: Type[] = [];
+
+ for (let i = 0; i < def.in.length; i++) {
+ const arg = def.in[i];
+ const type = this.infer(v.args[i]);
+ if (type === null) continue;
+
+ if (typeof arg === 'number') {
+ if (generic[arg] === undefined) {
+ generic[arg] = type;
+ } else if (type !== generic[arg]) {
+ return {
+ arg: i,
+ expect: generic[arg],
+ actual: type
+ };
+ }
+ } else if (type !== arg) {
+ return {
+ arg: i,
+ expect: arg,
+ actual: type
+ };
+ }
+ }
+
+ return null;
+ }
+
+ @autobind
+ public getExpectedType(v: Expr, slot: number): Type {
+ const def = funcDefs[v.type || ''];
+ if (def == null) {
+ throw new Error('Unknown type: ' + v.type);
+ }
+
+ const generic: Type[] = [];
+
+ for (let i = 0; i < def.in.length; i++) {
+ const arg = def.in[i];
+ const type = this.infer(v.args[i]);
+ if (type === null) continue;
+
+ if (typeof arg === 'number') {
+ if (generic[arg] === undefined) {
+ generic[arg] = type;
+ }
+ }
+ }
+
+ if (typeof def.in[slot] === 'number') {
+ return generic[def.in[slot]] || null;
+ } else {
+ return def.in[slot];
+ }
+ }
+
+ @autobind
+ public infer(v: Expr): Type {
+ if (v.type === null) return null;
+ if (v.type === 'text') return 'string';
+ if (v.type === 'multiLineText') return 'string';
+ if (v.type === 'textList') return 'stringArray';
+ if (v.type === 'number') return 'number';
+ if (v.type === 'ref') {
+ const variable = this.variables.find(va => va.name === v.value);
+ if (variable) {
+ return this.infer(variable);
+ }
+
+ const pageVar = this.pageVars.find(va => va.name === v.value);
+ if (pageVar) {
+ return pageVar.type;
+ }
+
+ const envVar = envVarsDef[v.value || ''];
+ if (envVar !== undefined) {
+ return envVar;
+ }
+
+ return null;
+ }
+ if (v.type === 'aiScriptVar') return null;
+ if (v.type === 'fn') return null; // todo
+ if (v.type.startsWith('fn:')) return null; // todo
+
+ const generic: Type[] = [];
+
+ const def = funcDefs[v.type];
+
+ for (let i = 0; i < def.in.length; i++) {
+ const arg = def.in[i];
+ if (typeof arg === 'number') {
+ const type = this.infer(v.args[i]);
+
+ if (generic[arg] === undefined) {
+ generic[arg] = type;
+ } else {
+ if (type !== generic[arg]) {
+ generic[arg] = null;
+ }
+ }
+ }
+ }
+
+ if (typeof def.out === 'number') {
+ return generic[def.out];
+ } else {
+ return def.out;
+ }
+ }
+
+ @autobind
+ public getVarByName(name: string): Variable {
+ const v = this.variables.find(x => x.name === name);
+ if (v !== undefined) {
+ return v;
+ } else {
+ throw new Error(`No such variable '${name}'`);
+ }
+ }
+
+ @autobind
+ public getVarsByType(type: Type): Variable[] {
+ if (type == null) return this.variables;
+ return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type));
+ }
+
+ @autobind
+ public getEnvVarsByType(type: Type): string[] {
+ if (type == null) return Object.keys(envVarsDef);
+ return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k);
+ }
+
+ @autobind
+ public getPageVarsByType(type: Type): string[] {
+ if (type == null) return this.pageVars.map(v => v.name);
+ return this.pageVars.filter(v => type === v.type).map(v => v.name);
+ }
+
+ @autobind
+ public isUsedName(name: string) {
+ if (this.variables.some(v => v.name === name)) {
+ return true;
+ }
+
+ if (this.pageVars.some(v => v.name === name)) {
+ return true;
+ }
+
+ if (envVarsDef[name]) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/packages/client/src/scripts/i18n.ts b/packages/client/src/scripts/i18n.ts
new file mode 100644
index 0000000000..4fa398763a
--- /dev/null
+++ b/packages/client/src/scripts/i18n.ts
@@ -0,0 +1,29 @@
+export class I18n<T extends Record<string, any>> {
+ public locale: T;
+
+ constructor(locale: T) {
+ this.locale = locale;
+
+ //#region BIND
+ this.t = this.t.bind(this);
+ //#endregion
+ }
+
+ // string にしているのは、ドット区切りでのパス指定を許可するため
+ // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
+ public t(key: string, args?: Record<string, any>): string {
+ try {
+ let str = key.split('.').reduce((o, i) => o[i], this.locale) as string;
+
+ if (args) {
+ for (const [k, v] of Object.entries(args)) {
+ str = str.replace(`{${k}}`, v);
+ }
+ }
+ return str;
+ } catch (e) {
+ console.warn(`missing localization '${key}'`);
+ return key;
+ }
+ }
+}
diff --git a/packages/client/src/scripts/idb-proxy.ts b/packages/client/src/scripts/idb-proxy.ts
new file mode 100644
index 0000000000..5f76ae30bb
--- /dev/null
+++ b/packages/client/src/scripts/idb-proxy.ts
@@ -0,0 +1,37 @@
+// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、
+// indexedDBが使えない環境ではlocalStorageを使う
+import {
+ get as iget,
+ set as iset,
+ del as idel,
+} from 'idb-keyval';
+
+const fallbackName = (key: string) => `idbfallback::${key}`;
+
+let idbAvailable = typeof window !== 'undefined' ? !!window.indexedDB : true;
+
+if (idbAvailable) {
+ try {
+ await iset('idb-test', 'test');
+ } catch (e) {
+ console.error('idb error', e);
+ idbAvailable = false;
+ }
+}
+
+if (!idbAvailable) console.error('indexedDB is unavailable. It will use localStorage.');
+
+export async function get(key: string) {
+ if (idbAvailable) return iget(key);
+ return JSON.parse(localStorage.getItem(fallbackName(key)));
+}
+
+export async function set(key: string, val: any) {
+ if (idbAvailable) return iset(key, val);
+ return localStorage.setItem(fallbackName(key), JSON.stringify(val));
+}
+
+export async function del(key: string) {
+ if (idbAvailable) return idel(key);
+ return localStorage.removeItem(fallbackName(key));
+}
diff --git a/packages/client/src/scripts/initialize-sw.ts b/packages/client/src/scripts/initialize-sw.ts
new file mode 100644
index 0000000000..d6dbd5dbd4
--- /dev/null
+++ b/packages/client/src/scripts/initialize-sw.ts
@@ -0,0 +1,68 @@
+import { instance } from '@/instance';
+import { $i } from '@/account';
+import { api } from '@/os';
+import { lang } from '@/config';
+
+export async function initializeSw() {
+ if (instance.swPublickey &&
+ ('serviceWorker' in navigator) &&
+ ('PushManager' in window) &&
+ $i && $i.token) {
+ navigator.serviceWorker.register(`/sw.js`);
+
+ navigator.serviceWorker.ready.then(registration => {
+ registration.active?.postMessage({
+ msg: 'initialize',
+ lang,
+ });
+ // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters
+ registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(instance.swPublickey)
+ }).then(subscription => {
+ function encode(buffer: ArrayBuffer | null) {
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
+ }
+
+ // Register
+ api('sw/register', {
+ endpoint: subscription.endpoint,
+ auth: encode(subscription.getKey('auth')),
+ publickey: encode(subscription.getKey('p256dh'))
+ });
+ })
+ // When subscribe failed
+ .catch(async (err: Error) => {
+ // 通知が許可されていなかったとき
+ if (err.name === 'NotAllowedError') {
+ return;
+ }
+
+ // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが
+ // 既に存在していることが原因でエラーになった可能性があるので、
+ // そのサブスクリプションを解除しておく
+ const subscription = await registration.pushManager.getSubscription();
+ if (subscription) subscription.unsubscribe();
+ });
+ });
+ }
+}
+
+/**
+ * Convert the URL safe base64 string to a Uint8Array
+ * @param base64String base64 string
+ */
+function urlBase64ToUint8Array(base64String: string): Uint8Array {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding)
+ .replace(/-/g, '+')
+ .replace(/_/g, '/');
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
diff --git a/packages/client/src/scripts/is-device-darkmode.ts b/packages/client/src/scripts/is-device-darkmode.ts
new file mode 100644
index 0000000000..854f38e517
--- /dev/null
+++ b/packages/client/src/scripts/is-device-darkmode.ts
@@ -0,0 +1,3 @@
+export function isDeviceDarkmode() {
+ return window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
diff --git a/packages/client/src/scripts/is-device-touch.ts b/packages/client/src/scripts/is-device-touch.ts
new file mode 100644
index 0000000000..3f0bfefed2
--- /dev/null
+++ b/packages/client/src/scripts/is-device-touch.ts
@@ -0,0 +1 @@
+export const isDeviceTouch = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
diff --git a/packages/client/src/scripts/is-mobile.ts b/packages/client/src/scripts/is-mobile.ts
new file mode 100644
index 0000000000..60cb59f91e
--- /dev/null
+++ b/packages/client/src/scripts/is-mobile.ts
@@ -0,0 +1,2 @@
+const ua = navigator.userAgent.toLowerCase();
+export const isMobile = /mobile|iphone|ipad|android/.test(ua);
diff --git a/packages/client/src/scripts/keycode.ts b/packages/client/src/scripts/keycode.ts
new file mode 100644
index 0000000000..c127d54bb2
--- /dev/null
+++ b/packages/client/src/scripts/keycode.ts
@@ -0,0 +1,33 @@
+export default (input: string): string[] => {
+ if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) {
+ const codes = aliases[input];
+ return Array.isArray(codes) ? codes : [codes];
+ } else {
+ return [input];
+ }
+};
+
+export const aliases = {
+ 'esc': 'Escape',
+ 'enter': ['Enter', 'NumpadEnter'],
+ 'up': 'ArrowUp',
+ 'down': 'ArrowDown',
+ 'left': 'ArrowLeft',
+ 'right': 'ArrowRight',
+ 'plus': ['NumpadAdd', 'Semicolon'],
+};
+
+/*!
+* Programatically add the following
+*/
+
+// lower case chars
+for (let i = 97; i < 123; i++) {
+ const char = String.fromCharCode(i);
+ aliases[char] = `Key${char.toUpperCase()}`;
+}
+
+// numbers
+for (let i = 0; i < 10; i++) {
+ aliases[i] = [`Numpad${i}`, `Digit${i}`];
+}
diff --git a/packages/client/src/scripts/loading.ts b/packages/client/src/scripts/loading.ts
new file mode 100644
index 0000000000..4b0a560e34
--- /dev/null
+++ b/packages/client/src/scripts/loading.ts
@@ -0,0 +1,11 @@
+export default {
+ start: () => {
+ // TODO
+ },
+ done: () => {
+ // TODO
+ },
+ set: val => {
+ // TODO
+ }
+};
diff --git a/packages/client/src/scripts/login-id.ts b/packages/client/src/scripts/login-id.ts
new file mode 100644
index 0000000000..0f9c6be4a9
--- /dev/null
+++ b/packages/client/src/scripts/login-id.ts
@@ -0,0 +1,11 @@
+export function getUrlWithLoginId(url: string, loginId: string) {
+ const u = new URL(url, origin);
+ u.searchParams.append('loginId', loginId);
+ return u.toString();
+}
+
+export function getUrlWithoutLoginId(url: string) {
+ const u = new URL(url);
+ u.searchParams.delete('loginId');
+ return u.toString();
+}
diff --git a/packages/client/src/scripts/lookup-user.ts b/packages/client/src/scripts/lookup-user.ts
new file mode 100644
index 0000000000..174fa9f879
--- /dev/null
+++ b/packages/client/src/scripts/lookup-user.ts
@@ -0,0 +1,37 @@
+import * as Acct from 'misskey-js/built/acct';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+
+export async function lookupUser() {
+ const { canceled, result } = await os.dialog({
+ title: i18n.locale.usernameOrUserId,
+ input: true
+ });
+ if (canceled) return;
+
+ const show = (user) => {
+ os.pageWindow(`/user-info/${user.id}`);
+ };
+
+ const usernamePromise = os.api('users/show', Acct.parse(result));
+ const idPromise = os.api('users/show', { userId: result });
+ let _notFound = false;
+ const notFound = () => {
+ if (_notFound) {
+ os.dialog({
+ type: 'error',
+ text: i18n.locale.noSuchUser
+ });
+ } else {
+ _notFound = true;
+ }
+ };
+ usernamePromise.then(show).catch(e => {
+ if (e.code === 'NO_SUCH_USER') {
+ notFound();
+ }
+ });
+ idPromise.then(show).catch(e => {
+ notFound();
+ });
+}
diff --git a/packages/client/src/scripts/mfm-tags.ts b/packages/client/src/scripts/mfm-tags.ts
new file mode 100644
index 0000000000..1b18210aa9
--- /dev/null
+++ b/packages/client/src/scripts/mfm-tags.ts
@@ -0,0 +1 @@
+export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle'];
diff --git a/packages/client/src/scripts/paging.ts b/packages/client/src/scripts/paging.ts
new file mode 100644
index 0000000000..ef63ecc450
--- /dev/null
+++ b/packages/client/src/scripts/paging.ts
@@ -0,0 +1,246 @@
+import { markRaw } from 'vue';
+import * as os from '@/os';
+import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll';
+
+const SECOND_FETCH_LIMIT = 30;
+
+// reversed: items 配列の中身を逆順にする(新しい方が最後)
+
+export default (opts) => ({
+ emits: ['queue'],
+
+ data() {
+ return {
+ items: [],
+ queue: [],
+ offset: 0,
+ fetching: true,
+ moreFetching: false,
+ inited: false,
+ more: false,
+ backed: false, // 遡り中か否か
+ isBackTop: false,
+ };
+ },
+
+ computed: {
+ empty(): boolean {
+ return this.items.length === 0 && !this.fetching && this.inited;
+ },
+
+ error(): boolean {
+ return !this.fetching && !this.inited;
+ },
+ },
+
+ watch: {
+ pagination: {
+ handler() {
+ this.init();
+ },
+ deep: true
+ },
+
+ queue: {
+ handler(a, b) {
+ if (a.length === 0 && b.length === 0) return;
+ this.$emit('queue', this.queue.length);
+ },
+ deep: true
+ }
+ },
+
+ created() {
+ opts.displayLimit = opts.displayLimit || 30;
+ this.init();
+ },
+
+ activated() {
+ this.isBackTop = false;
+ },
+
+ deactivated() {
+ this.isBackTop = window.scrollY === 0;
+ },
+
+ methods: {
+ reload() {
+ this.items = [];
+ this.init();
+ },
+
+ replaceItem(finder, data) {
+ const i = this.items.findIndex(finder);
+ this.items[i] = data;
+ },
+
+ removeItem(finder) {
+ const i = this.items.findIndex(finder);
+ this.items.splice(i, 1);
+ },
+
+ async init() {
+ this.queue = [];
+ this.fetching = true;
+ if (opts.before) opts.before(this);
+ let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params;
+ if (params && params.then) params = await params;
+ if (params === null) return;
+ const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
+ await os.api(endpoint, {
+ ...params,
+ limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
+ }).then(items => {
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ markRaw(item);
+ if (this.pagination.reversed) {
+ if (i === items.length - 2) item._shouldInsertAd_ = true;
+ } else {
+ if (i === 3) item._shouldInsertAd_ = true;
+ }
+ }
+ if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) {
+ items.pop();
+ this.items = this.pagination.reversed ? [...items].reverse() : items;
+ this.more = true;
+ } else {
+ this.items = this.pagination.reversed ? [...items].reverse() : items;
+ this.more = false;
+ }
+ this.offset = items.length;
+ this.inited = true;
+ this.fetching = false;
+ if (opts.after) opts.after(this, null);
+ }, e => {
+ this.fetching = false;
+ if (opts.after) opts.after(this, e);
+ });
+ },
+
+ async fetchMore() {
+ if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
+ this.moreFetching = true;
+ this.backed = true;
+ let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
+ if (params && params.then) params = await params;
+ const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
+ await os.api(endpoint, {
+ ...params,
+ limit: SECOND_FETCH_LIMIT + 1,
+ ...(this.pagination.offsetMode ? {
+ offset: this.offset,
+ } : {
+ untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
+ }),
+ }).then(items => {
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ markRaw(item);
+ if (this.pagination.reversed) {
+ if (i === items.length - 9) item._shouldInsertAd_ = true;
+ } else {
+ if (i === 10) item._shouldInsertAd_ = true;
+ }
+ }
+ if (items.length > SECOND_FETCH_LIMIT) {
+ items.pop();
+ this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+ this.more = true;
+ } else {
+ this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+ this.more = false;
+ }
+ this.offset += items.length;
+ this.moreFetching = false;
+ }, e => {
+ this.moreFetching = false;
+ });
+ },
+
+ async fetchMoreFeature() {
+ if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
+ this.moreFetching = true;
+ let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
+ if (params && params.then) params = await params;
+ const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
+ await os.api(endpoint, {
+ ...params,
+ limit: SECOND_FETCH_LIMIT + 1,
+ ...(this.pagination.offsetMode ? {
+ offset: this.offset,
+ } : {
+ sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
+ }),
+ }).then(items => {
+ for (const item of items) {
+ markRaw(item);
+ }
+ if (items.length > SECOND_FETCH_LIMIT) {
+ items.pop();
+ this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+ this.more = true;
+ } else {
+ this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+ this.more = false;
+ }
+ this.offset += items.length;
+ this.moreFetching = false;
+ }, e => {
+ this.moreFetching = false;
+ });
+ },
+
+ prepend(item) {
+ if (this.pagination.reversed) {
+ const container = getScrollContainer(this.$el);
+ const pos = getScrollPosition(this.$el);
+ const viewHeight = container.clientHeight;
+ const height = container.scrollHeight;
+ const isBottom = (pos + viewHeight > height - 32);
+ if (isBottom) {
+ // オーバーフローしたら古いアイテムは捨てる
+ if (this.items.length >= opts.displayLimit) {
+ // このやり方だとVue 3.2以降アニメーションが動かなくなる
+ //this.items = this.items.slice(-opts.displayLimit);
+ while (this.items.length >= opts.displayLimit) {
+ this.items.shift();
+ }
+ this.more = true;
+ }
+ }
+ this.items.push(item);
+ // TODO
+ } else {
+ const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
+
+ if (isTop) {
+ // Prepend the item
+ this.items.unshift(item);
+
+ // オーバーフローしたら古いアイテムは捨てる
+ if (this.items.length >= opts.displayLimit) {
+ // このやり方だとVue 3.2以降アニメーションが動かなくなる
+ //this.items = this.items.slice(0, opts.displayLimit);
+ while (this.items.length >= opts.displayLimit) {
+ this.items.pop();
+ }
+ this.more = true;
+ }
+ } else {
+ this.queue.push(item);
+ onScrollTop(this.$el, () => {
+ for (const item of this.queue) {
+ this.prepend(item);
+ }
+ this.queue = [];
+ });
+ }
+ }
+ },
+
+ append(item) {
+ this.items.push(item);
+ },
+ }
+});
diff --git a/packages/client/src/scripts/physics.ts b/packages/client/src/scripts/physics.ts
new file mode 100644
index 0000000000..445b6296eb
--- /dev/null
+++ b/packages/client/src/scripts/physics.ts
@@ -0,0 +1,152 @@
+import * as Matter from 'matter-js';
+
+export function physics(container: HTMLElement) {
+ const containerWidth = container.offsetWidth;
+ const containerHeight = container.offsetHeight;
+ const containerCenterX = containerWidth / 2;
+
+ // サイズ固定化(要らないかも?)
+ container.style.position = 'relative';
+ container.style.boxSizing = 'border-box';
+ container.style.width = `${containerWidth}px`;
+ container.style.height = `${containerHeight}px`;
+
+ // create engine
+ const engine = Matter.Engine.create({
+ constraintIterations: 4,
+ positionIterations: 8,
+ velocityIterations: 8,
+ });
+
+ const world = engine.world;
+
+ // create renderer
+ const render = Matter.Render.create({
+ engine: engine,
+ //element: document.getElementById('debug'),
+ options: {
+ width: containerWidth,
+ height: containerHeight,
+ background: 'transparent', // transparent to hide
+ wireframeBackground: 'transparent', // transparent to hide
+ }
+ });
+
+ // Disable to hide debug
+ Matter.Render.run(render);
+
+ // create runner
+ const runner = Matter.Runner.create();
+ Matter.Runner.run(runner, engine);
+
+ const groundThickness = 1024;
+ const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, {
+ isStatic: true,
+ restitution: 0.1,
+ friction: 2
+ });
+
+ //const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts);
+ //const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts);
+
+ Matter.World.add(world, [
+ ground,
+ //wallRight,
+ //wallLeft,
+ ]);
+
+ const objEls = Array.from(container.children);
+ const objs = [];
+ for (const objEl of objEls) {
+ const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft;
+ const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop;
+
+ let obj;
+ if (objEl.classList.contains('_physics_circle_')) {
+ obj = Matter.Bodies.circle(
+ left + (objEl.offsetWidth / 2),
+ top + (objEl.offsetHeight / 2),
+ Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2,
+ {
+ restitution: 0.5
+ }
+ );
+ } else {
+ const style = window.getComputedStyle(objEl);
+ obj = Matter.Bodies.rectangle(
+ left + (objEl.offsetWidth / 2),
+ top + (objEl.offsetHeight / 2),
+ objEl.offsetWidth,
+ objEl.offsetHeight,
+ {
+ chamfer: { radius: parseInt(style.borderRadius || '0', 10) },
+ restitution: 0.5
+ }
+ );
+ }
+ objEl.id = obj.id;
+ objs.push(obj);
+ }
+
+ Matter.World.add(engine.world, objs);
+
+ // Add mouse control
+
+ const mouse = Matter.Mouse.create(container);
+ const mouseConstraint = Matter.MouseConstraint.create(engine, {
+ mouse: mouse,
+ constraint: {
+ stiffness: 0.1,
+ render: {
+ visible: false
+ }
+ }
+ });
+
+ Matter.World.add(engine.world, mouseConstraint);
+
+ // keep the mouse in sync with rendering
+ render.mouse = mouse;
+
+ for (const objEl of objEls) {
+ objEl.style.position = `absolute`;
+ objEl.style.top = 0;
+ objEl.style.left = 0;
+ objEl.style.margin = 0;
+ }
+
+ window.requestAnimationFrame(update);
+
+ let stop = false;
+
+ function update() {
+ for (const objEl of objEls) {
+ const obj = objs.find(obj => obj.id.toString() === objEl.id.toString());
+ if (obj == null) continue;
+
+ const x = (obj.position.x - objEl.offsetWidth / 2);
+ const y = (obj.position.y - objEl.offsetHeight / 2);
+ const angle = obj.angle;
+ objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`;
+ }
+
+ if (!stop) {
+ window.requestAnimationFrame(update);
+ }
+ }
+
+ // 奈落に落ちたオブジェクトは消す
+ const intervalId = setInterval(() => {
+ for (const obj of objs) {
+ if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj);
+ }
+ }, 1000 * 10);
+
+ return {
+ stop: () => {
+ stop = true;
+ Matter.Runner.stop(runner);
+ clearInterval(intervalId);
+ }
+ };
+}
diff --git a/packages/client/src/scripts/please-login.ts b/packages/client/src/scripts/please-login.ts
new file mode 100644
index 0000000000..928f6ec0f4
--- /dev/null
+++ b/packages/client/src/scripts/please-login.ts
@@ -0,0 +1,14 @@
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { dialog } from '@/os';
+
+export function pleaseLogin() {
+ if ($i) return;
+
+ dialog({
+ title: i18n.locale.signinRequired,
+ text: null
+ });
+
+ throw new Error('signin required');
+}
diff --git a/packages/client/src/scripts/popout.ts b/packages/client/src/scripts/popout.ts
new file mode 100644
index 0000000000..51b8d72868
--- /dev/null
+++ b/packages/client/src/scripts/popout.ts
@@ -0,0 +1,22 @@
+import * as config from '@/config';
+
+export function popout(path: string, w?: HTMLElement) {
+ let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
+ url += '?zen'; // TODO: ちゃんとURLパースしてクエリ付ける
+ if (w) {
+ const position = w.getBoundingClientRect();
+ const width = parseInt(getComputedStyle(w, '').width, 10);
+ const height = parseInt(getComputedStyle(w, '').height, 10);
+ const x = window.screenX + position.left;
+ const y = window.screenY + position.top;
+ window.open(url, url,
+ `width=${width}, height=${height}, top=${y}, left=${x}`);
+ } else {
+ const width = 400;
+ const height = 500;
+ const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2);
+ const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2);
+ window.open(url, url,
+ `width=${width}, height=${height}, top=${x}, left=${y}`);
+ }
+}
diff --git a/packages/client/src/scripts/reaction-picker.ts b/packages/client/src/scripts/reaction-picker.ts
new file mode 100644
index 0000000000..e923326ece
--- /dev/null
+++ b/packages/client/src/scripts/reaction-picker.ts
@@ -0,0 +1,41 @@
+import { Ref, ref } from 'vue';
+import { popup } from '@/os';
+
+class ReactionPicker {
+ private src: Ref<HTMLElement | null> = ref(null);
+ private manualShowing = ref(false);
+ private onChosen?: Function;
+ private onClosed?: Function;
+
+ constructor() {
+ // nop
+ }
+
+ public async init() {
+ await popup(import('@/components/emoji-picker-dialog.vue'), {
+ src: this.src,
+ asReactionPicker: true,
+ manualShowing: this.manualShowing
+ }, {
+ done: reaction => {
+ this.onChosen!(reaction);
+ },
+ close: () => {
+ this.manualShowing.value = false;
+ },
+ closed: () => {
+ this.src.value = null;
+ this.onClosed!();
+ }
+ });
+ }
+
+ public show(src: HTMLElement, onChosen: Function, onClosed: Function) {
+ this.src.value = src;
+ this.manualShowing.value = true;
+ this.onChosen = onChosen;
+ this.onClosed = onClosed;
+ }
+}
+
+export const reactionPicker = new ReactionPicker();
diff --git a/packages/client/src/scripts/room/furniture.ts b/packages/client/src/scripts/room/furniture.ts
new file mode 100644
index 0000000000..7734e32668
--- /dev/null
+++ b/packages/client/src/scripts/room/furniture.ts
@@ -0,0 +1,21 @@
+export type RoomInfo = {
+ roomType: string;
+ carpetColor: string;
+ furnitures: Furniture[];
+};
+
+export type Furniture = {
+ id: string; // 同じ家具が複数ある場合にそれぞれを識別するためのIDであり、家具IDではない
+ type: string; // こっちが家具ID(chairとか)
+ position: {
+ x: number;
+ y: number;
+ z: number;
+ };
+ rotation: {
+ x: number;
+ y: number;
+ z: number;
+ };
+ props?: Record<string, any>;
+};
diff --git a/packages/client/src/scripts/room/furnitures.json5 b/packages/client/src/scripts/room/furnitures.json5
new file mode 100644
index 0000000000..4a40994107
--- /dev/null
+++ b/packages/client/src/scripts/room/furnitures.json5
@@ -0,0 +1,407 @@
+// 家具メタデータ
+
+// 家具IDはglbファイル及びそのディレクトリ名と一致する必要があります
+
+// 家具にはユーザーが設定できるプロパティを設定可能です:
+//
+// props: {
+// <propname>: <proptype>
+// }
+//
+// proptype一覧:
+// * image ... 画像選択ダイアログを出し、その画像のURLが格納されます
+// * color ... 色選択コントロールを出し、選択された色が格納されます
+
+// 家具にカスタムテクスチャを適用できるようにするには、textureプロパティに以下の追加の情報を含めます:
+// 便宜上そのUVのどの部分にカスタムテクスチャを貼り合わせるかのエリアをテクスチャエリアと呼びます。
+// UVは1024*1024だと仮定します。
+//
+// <key>: {
+// prop: <プロパティ名>,
+// uv: {
+// x: <テクスチャエリアX座標>,
+// y: <テクスチャエリアY座標>,
+// width: <テクスチャエリアの幅>,
+// height: <テクスチャエリアの高さ>,
+// },
+// }
+//
+// <key>には、カスタムテクスチャを適用したいメッシュ名を指定します
+// <プロパティ名>には、カスタムテクスチャとして使用する画像を格納するプロパティ(前述)名を指定します
+
+// 家具にカスタムカラーを適用できるようにするには、colorプロパティに以下の追加の情報を含めます:
+//
+// <key>: <プロパティ名>
+//
+// <key>には、カスタムカラーを適用したいマテリアル名を指定します
+// <プロパティ名>には、カスタムカラーとして使用する色を格納するプロパティ(前述)名を指定します
+
+[
+ {
+ id: "milk",
+ place: "floor"
+ },
+ {
+ id: "bed",
+ place: "floor"
+ },
+ {
+ id: "low-table",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Table: 'color'
+ }
+ },
+ {
+ id: "desk",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Board: 'color'
+ }
+ },
+ {
+ id: "chair",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Chair: 'color'
+ }
+ },
+ {
+ id: "chair2",
+ place: "floor",
+ props: {
+ color1: 'color',
+ color2: 'color'
+ },
+ color: {
+ Cushion: 'color1',
+ Leg: 'color2'
+ }
+ },
+ {
+ id: "fan",
+ place: "wall"
+ },
+ {
+ id: "pc",
+ place: "floor"
+ },
+ {
+ id: "plant",
+ place: "floor"
+ },
+ {
+ id: "plant2",
+ place: "floor"
+ },
+ {
+ id: "eraser",
+ place: "floor"
+ },
+ {
+ id: "pencil",
+ place: "floor"
+ },
+ {
+ id: "pudding",
+ place: "floor"
+ },
+ {
+ id: "cardboard-box",
+ place: "floor"
+ },
+ {
+ id: "cardboard-box2",
+ place: "floor"
+ },
+ {
+ id: "cardboard-box3",
+ place: "floor"
+ },
+ {
+ id: "book",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Cover: 'color'
+ }
+ },
+ {
+ id: "book2",
+ place: "floor"
+ },
+ {
+ id: "piano",
+ place: "floor"
+ },
+ {
+ id: "facial-tissue",
+ place: "floor"
+ },
+ {
+ id: "server",
+ place: "floor"
+ },
+ {
+ id: "moon",
+ place: "floor"
+ },
+ {
+ id: "corkboard",
+ place: "wall"
+ },
+ {
+ id: "mousepad",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Pad: 'color'
+ }
+ },
+ {
+ id: "monitor",
+ place: "floor",
+ props: {
+ screen: 'image'
+ },
+ texture: {
+ Screen: {
+ prop: 'screen',
+ uv: {
+ x: 0,
+ y: 434,
+ width: 1024,
+ height: 588,
+ },
+ },
+ },
+ },
+ {
+ id: "tv",
+ place: "floor",
+ props: {
+ screen: 'image'
+ },
+ texture: {
+ Screen: {
+ prop: 'screen',
+ uv: {
+ x: 0,
+ y: 434,
+ width: 1024,
+ height: 588,
+ },
+ },
+ },
+ },
+ {
+ id: "keyboard",
+ place: "floor"
+ },
+ {
+ id: "carpet-stripe",
+ place: "floor",
+ props: {
+ color1: 'color',
+ color2: 'color'
+ },
+ color: {
+ CarpetAreaA: 'color1',
+ CarpetAreaB: 'color2'
+ },
+ },
+ {
+ id: "mat",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Mat: 'color'
+ }
+ },
+ {
+ id: "color-box",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ main: 'color'
+ }
+ },
+ {
+ id: "wall-clock",
+ place: "wall"
+ },
+ {
+ id: "cube",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Cube: 'color'
+ }
+ },
+ {
+ id: "photoframe",
+ place: "wall",
+ props: {
+ photo: 'image',
+ color: 'color'
+ },
+ texture: {
+ Photo: {
+ prop: 'photo',
+ uv: {
+ x: 0,
+ y: 342,
+ width: 1024,
+ height: 683,
+ },
+ },
+ },
+ color: {
+ Frame: 'color'
+ }
+ },
+ {
+ id: "pinguin",
+ place: "floor",
+ props: {
+ body: 'color',
+ belly: 'color'
+ },
+ color: {
+ Body: 'body',
+ Belly: 'belly',
+ }
+ },
+ {
+ id: "rubik-cube",
+ place: "floor",
+ },
+ {
+ id: "poster-h",
+ place: "wall",
+ props: {
+ picture: 'image'
+ },
+ texture: {
+ Poster: {
+ prop: 'picture',
+ uv: {
+ x: 0,
+ y: 277,
+ width: 1024,
+ height: 745,
+ },
+ },
+ },
+ },
+ {
+ id: "poster-v",
+ place: "wall",
+ props: {
+ picture: 'image'
+ },
+ texture: {
+ Poster: {
+ prop: 'picture',
+ uv: {
+ x: 0,
+ y: 0,
+ width: 745,
+ height: 1024,
+ },
+ },
+ },
+ },
+ {
+ id: "sofa",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Sofa: 'color'
+ }
+ },
+ {
+ id: "spiral",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Step: 'color'
+ }
+ },
+ {
+ id: "bin",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Bin: 'color'
+ }
+ },
+ {
+ id: "cup-noodle",
+ place: "floor"
+ },
+ {
+ id: "holo-display",
+ place: "floor",
+ props: {
+ image: 'image'
+ },
+ texture: {
+ Image_Front: {
+ prop: 'image',
+ uv: {
+ x: 0,
+ y: 0,
+ width: 1024,
+ height: 1024,
+ },
+ },
+ Image_Back: {
+ prop: 'image',
+ uv: {
+ x: 0,
+ y: 0,
+ width: 1024,
+ height: 1024,
+ },
+ },
+ },
+ },
+ {
+ id: 'energy-drink',
+ place: "floor",
+ },
+ {
+ id: 'doll-ai',
+ place: "floor",
+ },
+ {
+ id: 'banknote',
+ place: "floor",
+ },
+]
diff --git a/packages/client/src/scripts/room/room.ts b/packages/client/src/scripts/room/room.ts
new file mode 100644
index 0000000000..7e04bec646
--- /dev/null
+++ b/packages/client/src/scripts/room/room.ts
@@ -0,0 +1,775 @@
+import autobind from 'autobind-decorator';
+import { v4 as uuid } from 'uuid';
+import * as THREE from 'three';
+import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
+import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
+import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass.js';
+import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';
+import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
+import { Furniture, RoomInfo } from './furniture';
+import { query as urlQuery } from '@/scripts/url';
+const furnitureDefs = require('./furnitures.json5');
+
+THREE.ImageUtils.crossOrigin = '';
+
+type Options = {
+ graphicsQuality: Room['graphicsQuality'];
+ onChangeSelect: Room['onChangeSelect'];
+ useOrthographicCamera: boolean;
+};
+
+/**
+ * MisskeyRoom Core Engine
+ */
+export class Room {
+ private clock: THREE.Clock;
+ private scene: THREE.Scene;
+ private renderer: THREE.WebGLRenderer;
+ private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
+ private controls: OrbitControls;
+ private composer: EffectComposer;
+ private mixers: THREE.AnimationMixer[] = [];
+ private furnitureControl: TransformControls;
+ private roomInfo: RoomInfo;
+ private graphicsQuality: 'cheep' | 'low' | 'medium' | 'high' | 'ultra';
+ private roomObj: THREE.Object3D;
+ private objects: THREE.Object3D[] = [];
+ private selectedObject: THREE.Object3D = null;
+ private onChangeSelect: Function;
+ private isTransformMode = false;
+ private renderFrameRequestId: number;
+
+ private get canvas(): HTMLCanvasElement {
+ return this.renderer.domElement;
+ }
+
+ private get furnitures(): Furniture[] {
+ return this.roomInfo.furnitures;
+ }
+
+ private set furnitures(furnitures: Furniture[]) {
+ this.roomInfo.furnitures = furnitures;
+ }
+
+ private get enableShadow() {
+ return this.graphicsQuality != 'cheep';
+ }
+
+ private get usePostFXs() {
+ return this.graphicsQuality !== 'cheep' && this.graphicsQuality !== 'low';
+ }
+
+ private get shadowQuality() {
+ return (
+ this.graphicsQuality === 'ultra' ? 16384 :
+ this.graphicsQuality === 'high' ? 8192 :
+ this.graphicsQuality === 'medium' ? 4096 :
+ this.graphicsQuality === 'low' ? 1024 :
+ 0); // cheep
+ }
+
+ constructor(user, isMyRoom, roomInfo: RoomInfo, container: Element, options: Options) {
+ this.roomInfo = roomInfo;
+ this.graphicsQuality = options.graphicsQuality;
+ this.onChangeSelect = options.onChangeSelect;
+
+ this.clock = new THREE.Clock(true);
+
+ //#region Init a scene
+ this.scene = new THREE.Scene();
+
+ const width = container.clientWidth;
+ const height = container.clientHeight;
+
+ //#region Init a renderer
+ this.renderer = new THREE.WebGLRenderer({
+ antialias: false,
+ stencil: false,
+ alpha: false,
+ powerPreference:
+ this.graphicsQuality === 'ultra' ? 'high-performance' :
+ this.graphicsQuality === 'high' ? 'high-performance' :
+ this.graphicsQuality === 'medium' ? 'default' :
+ this.graphicsQuality === 'low' ? 'low-power' :
+ 'low-power' // cheep
+ });
+
+ this.renderer.setPixelRatio(window.devicePixelRatio);
+ this.renderer.setSize(width, height);
+ this.renderer.autoClear = false;
+ this.renderer.setClearColor(new THREE.Color(0x051f2d));
+ this.renderer.shadowMap.enabled = this.enableShadow;
+ this.renderer.shadowMap.type =
+ this.graphicsQuality === 'ultra' ? THREE.PCFSoftShadowMap :
+ this.graphicsQuality === 'high' ? THREE.PCFSoftShadowMap :
+ this.graphicsQuality === 'medium' ? THREE.PCFShadowMap :
+ this.graphicsQuality === 'low' ? THREE.BasicShadowMap :
+ THREE.BasicShadowMap; // cheep
+
+ container.insertBefore(this.canvas, container.firstChild);
+ //#endregion
+
+ //#region Init a camera
+ this.camera = options.useOrthographicCamera
+ ? new THREE.OrthographicCamera(
+ width / - 2, width / 2, height / 2, height / - 2, -10, 10)
+ : new THREE.PerspectiveCamera(45, width / height);
+
+ if (options.useOrthographicCamera) {
+ this.camera.position.x = 2;
+ this.camera.position.y = 2;
+ this.camera.position.z = 2;
+ this.camera.zoom = 100;
+ this.camera.updateProjectionMatrix();
+ } else {
+ this.camera.position.x = 5;
+ this.camera.position.y = 2;
+ this.camera.position.z = 5;
+ }
+
+ this.scene.add(this.camera);
+ //#endregion
+
+ //#region AmbientLight
+ const ambientLight = new THREE.AmbientLight(0xffffff, 1);
+ this.scene.add(ambientLight);
+ //#endregion
+
+ if (this.graphicsQuality !== 'cheep') {
+ //#region Room light
+ const roomLight = new THREE.SpotLight(0xffffff, 0.1);
+
+ roomLight.position.set(0, 8, 0);
+ roomLight.castShadow = this.enableShadow;
+ roomLight.shadow.bias = -0.0001;
+ roomLight.shadow.mapSize.width = this.shadowQuality;
+ roomLight.shadow.mapSize.height = this.shadowQuality;
+ roomLight.shadow.camera.near = 0.1;
+ roomLight.shadow.camera.far = 9;
+ roomLight.shadow.camera.fov = 45;
+
+ this.scene.add(roomLight);
+ //#endregion
+ }
+
+ //#region Out light
+ const outLight1 = new THREE.SpotLight(0xffffff, 0.4);
+ outLight1.position.set(9, 3, -2);
+ outLight1.castShadow = this.enableShadow;
+ outLight1.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
+ outLight1.shadow.mapSize.width = this.shadowQuality;
+ outLight1.shadow.mapSize.height = this.shadowQuality;
+ outLight1.shadow.camera.near = 6;
+ outLight1.shadow.camera.far = 15;
+ outLight1.shadow.camera.fov = 45;
+ this.scene.add(outLight1);
+
+ const outLight2 = new THREE.SpotLight(0xffffff, 0.2);
+ outLight2.position.set(-2, 3, 9);
+ outLight2.castShadow = false;
+ outLight2.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
+ outLight2.shadow.camera.near = 6;
+ outLight2.shadow.camera.far = 15;
+ outLight2.shadow.camera.fov = 45;
+ this.scene.add(outLight2);
+ //#endregion
+
+ //#region Init a controller
+ this.controls = new OrbitControls(this.camera, this.canvas);
+
+ this.controls.target.set(0, 1, 0);
+ this.controls.enableZoom = true;
+ this.controls.enablePan = isMyRoom;
+ this.controls.minPolarAngle = 0;
+ this.controls.maxPolarAngle = Math.PI / 2;
+ this.controls.minAzimuthAngle = 0;
+ this.controls.maxAzimuthAngle = Math.PI / 2;
+ this.controls.enableDamping = true;
+ this.controls.dampingFactor = 0.2;
+ //#endregion
+
+ //#region POST FXs
+ if (!this.usePostFXs) {
+ this.composer = null;
+ } else {
+ const renderTarget = new THREE.WebGLRenderTarget(width, height, {
+ minFilter: THREE.LinearFilter,
+ magFilter: THREE.LinearFilter,
+ format: THREE.RGBFormat,
+ stencilBuffer: false,
+ });
+
+ const fxaa = new ShaderPass(FXAAShader);
+ fxaa.uniforms['resolution'].value = new THREE.Vector2(1 / width, 1 / height);
+ fxaa.renderToScreen = true;
+
+ this.composer = new EffectComposer(this.renderer, renderTarget);
+ this.composer.addPass(new RenderPass(this.scene, this.camera));
+ if (this.graphicsQuality === 'ultra') {
+ this.composer.addPass(new BloomPass(0.25, 30, 128.0, 512));
+ }
+ this.composer.addPass(fxaa);
+ }
+ //#endregion
+ //#endregion
+
+ //#region Label
+ //#region Avatar
+ const avatarUrl = `/proxy/?${urlQuery({ url: user.avatarUrl })}`;
+
+ const textureLoader = new THREE.TextureLoader();
+ textureLoader.crossOrigin = 'anonymous';
+
+ const iconTexture = textureLoader.load(avatarUrl);
+ iconTexture.wrapS = THREE.RepeatWrapping;
+ iconTexture.wrapT = THREE.RepeatWrapping;
+ iconTexture.anisotropy = 16;
+
+ const avatarMaterial = new THREE.MeshBasicMaterial({
+ map: iconTexture,
+ side: THREE.DoubleSide,
+ alphaTest: 0.5
+ });
+
+ const iconGeometry = new THREE.PlaneGeometry(1, 1);
+
+ const avatarObject = new THREE.Mesh(iconGeometry, avatarMaterial);
+ avatarObject.position.set(-3, 2.5, 2);
+ avatarObject.rotation.y = Math.PI / 2;
+ avatarObject.castShadow = false;
+
+ this.scene.add(avatarObject);
+ //#endregion
+
+ //#region Username
+ const name = user.username;
+
+ new THREE.FontLoader().load('/assets/fonts/helvetiker_regular.typeface.json', font => {
+ const nameGeometry = new THREE.TextGeometry(name, {
+ size: 0.5,
+ height: 0,
+ curveSegments: 8,
+ font: font,
+ bevelThickness: 0,
+ bevelSize: 0,
+ bevelEnabled: false
+ });
+
+ const nameMaterial = new THREE.MeshLambertMaterial({
+ color: 0xffffff
+ });
+
+ const nameObject = new THREE.Mesh(nameGeometry, nameMaterial);
+ nameObject.position.set(-3, 2.25, 1.25);
+ nameObject.rotation.y = Math.PI / 2;
+ nameObject.castShadow = false;
+
+ this.scene.add(nameObject);
+ });
+ //#endregion
+ //#endregion
+
+ //#region Interaction
+ if (isMyRoom) {
+ this.furnitureControl = new TransformControls(this.camera, this.canvas);
+ this.scene.add(this.furnitureControl);
+
+ // Hover highlight
+ this.canvas.onmousemove = this.onmousemove;
+
+ // Click
+ this.canvas.onmousedown = this.onmousedown;
+ }
+ //#endregion
+
+ //#region Init room
+ this.loadRoom();
+ //#endregion
+
+ //#region Load furnitures
+ for (const furniture of this.furnitures) {
+ this.loadFurniture(furniture).then(obj => {
+ this.scene.add(obj.scene);
+ this.objects.push(obj.scene);
+ });
+ }
+ //#endregion
+
+ // Start render
+ if (this.usePostFXs) {
+ this.renderWithPostFXs();
+ } else {
+ this.renderWithoutPostFXs();
+ }
+ }
+
+ @autobind
+ private renderWithoutPostFXs() {
+ this.renderFrameRequestId =
+ window.requestAnimationFrame(this.renderWithoutPostFXs);
+
+ // Update animations
+ const clock = this.clock.getDelta();
+ for (const mixer of this.mixers) {
+ mixer.update(clock);
+ }
+
+ this.controls.update();
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ @autobind
+ private renderWithPostFXs() {
+ this.renderFrameRequestId =
+ window.requestAnimationFrame(this.renderWithPostFXs);
+
+ // Update animations
+ const clock = this.clock.getDelta();
+ for (const mixer of this.mixers) {
+ mixer.update(clock);
+ }
+
+ this.controls.update();
+ this.renderer.clear();
+ this.composer.render();
+ }
+
+ @autobind
+ private loadRoom() {
+ const type = this.roomInfo.roomType;
+ new GLTFLoader().load(`/client-assets/room/rooms/${type}/${type}.glb`, gltf => {
+ gltf.scene.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+
+ child.receiveShadow = this.enableShadow;
+
+ child.material = new THREE.MeshLambertMaterial({
+ color: (child.material as THREE.MeshStandardMaterial).color,
+ map: (child.material as THREE.MeshStandardMaterial).map,
+ name: (child.material as THREE.MeshStandardMaterial).name,
+ });
+
+ // 異方性フィルタリング
+ if ((child.material as THREE.MeshLambertMaterial).map && this.graphicsQuality !== 'cheep') {
+ (child.material as THREE.MeshLambertMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
+ (child.material as THREE.MeshLambertMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
+ (child.material as THREE.MeshLambertMaterial).map.anisotropy = 8;
+ }
+ });
+
+ gltf.scene.position.set(0, 0, 0);
+
+ this.scene.add(gltf.scene);
+ this.roomObj = gltf.scene;
+ if (this.roomInfo.roomType === 'default') {
+ this.applyCarpetColor();
+ }
+ });
+ }
+
+ @autobind
+ private loadFurniture(furniture: Furniture) {
+ const def = furnitureDefs.find(d => d.id === furniture.type);
+ return new Promise<GLTF>((res, rej) => {
+ const loader = new GLTFLoader();
+ loader.load(`/client-assets/room/furnitures/${furniture.type}/${furniture.type}.glb`, gltf => {
+ const model = gltf.scene;
+
+ // Load animation
+ if (gltf.animations.length > 0) {
+ const mixer = new THREE.AnimationMixer(model);
+ this.mixers.push(mixer);
+ for (const clip of gltf.animations) {
+ mixer.clipAction(clip).play();
+ }
+ }
+
+ model.name = furniture.id;
+ model.position.x = furniture.position.x;
+ model.position.y = furniture.position.y;
+ model.position.z = furniture.position.z;
+ model.rotation.x = furniture.rotation.x;
+ model.rotation.y = furniture.rotation.y;
+ model.rotation.z = furniture.rotation.z;
+
+ model.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+ child.castShadow = this.enableShadow;
+ child.receiveShadow = this.enableShadow;
+ (child.material as THREE.MeshStandardMaterial).metalness = 0;
+
+ // 異方性フィルタリング
+ if ((child.material as THREE.MeshStandardMaterial).map && this.graphicsQuality !== 'cheep') {
+ (child.material as THREE.MeshStandardMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
+ (child.material as THREE.MeshStandardMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
+ (child.material as THREE.MeshStandardMaterial).map.anisotropy = 8;
+ }
+ });
+
+ if (def.color) { // カスタムカラー
+ this.applyCustomColor(model);
+ }
+
+ if (def.texture) { // カスタムテクスチャ
+ this.applyCustomTexture(model);
+ }
+
+ res(gltf);
+ }, null, rej);
+ });
+ }
+
+ @autobind
+ private applyCarpetColor() {
+ this.roomObj.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+ if (child.material &&
+ (child.material as THREE.MeshStandardMaterial).name &&
+ (child.material as THREE.MeshStandardMaterial).name === 'Carpet'
+ ) {
+ const colorHex = parseInt(this.roomInfo.carpetColor.substr(1), 16);
+ (child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
+ }
+ });
+ }
+
+ @autobind
+ private applyCustomColor(model: THREE.Object3D) {
+ const furniture = this.furnitures.find(furniture => furniture.id === model.name);
+ const def = furnitureDefs.find(d => d.id === furniture.type);
+ if (def.color == null) return;
+ model.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+ for (const t of Object.keys(def.color)) {
+ if (!child.material ||
+ !(child.material as THREE.MeshStandardMaterial).name ||
+ (child.material as THREE.MeshStandardMaterial).name !== t
+ ) continue;
+
+ const prop = def.color[t];
+ const val = furniture.props ? furniture.props[prop] : undefined;
+
+ if (val == null) continue;
+
+ const colorHex = parseInt(val.substr(1), 16);
+ (child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
+ }
+ });
+ }
+
+ @autobind
+ private applyCustomTexture(model: THREE.Object3D) {
+ const furniture = this.furnitures.find(furniture => furniture.id === model.name);
+ const def = furnitureDefs.find(d => d.id === furniture.type);
+ if (def.texture == null) return;
+
+ model.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+ for (const t of Object.keys(def.texture)) {
+ if (child.name !== t) continue;
+
+ const prop = def.texture[t].prop;
+ const val = furniture.props ? furniture.props[prop] : undefined;
+
+ if (val == null) continue;
+
+ const canvas = document.createElement('canvas');
+ canvas.height = 1024;
+ canvas.width = 1024;
+
+ child.material = new THREE.MeshLambertMaterial({
+ emissive: 0x111111,
+ side: THREE.DoubleSide,
+ alphaTest: 0.5,
+ });
+
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ const uvInfo = def.texture[t].uv;
+
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img,
+ 0, 0, img.width, img.height,
+ uvInfo.x, uvInfo.y, uvInfo.width, uvInfo.height);
+
+ const texture = new THREE.Texture(canvas);
+ texture.wrapS = THREE.RepeatWrapping;
+ texture.wrapT = THREE.RepeatWrapping;
+ texture.anisotropy = 16;
+ texture.flipY = false;
+
+ (child.material as THREE.MeshLambertMaterial).map = texture;
+ (child.material as THREE.MeshLambertMaterial).needsUpdate = true;
+ (child.material as THREE.MeshLambertMaterial).map.needsUpdate = true;
+ };
+ img.src = val;
+ }
+ });
+ }
+
+ @autobind
+ private onmousemove(ev: MouseEvent) {
+ if (this.isTransformMode) return;
+
+ const rect = (ev.target as HTMLElement).getBoundingClientRect();
+ const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
+ const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
+ const pos = new THREE.Vector2(x, y);
+
+ this.camera.updateMatrixWorld();
+
+ const raycaster = new THREE.Raycaster();
+ raycaster.setFromCamera(pos, this.camera);
+
+ const intersects = raycaster.intersectObjects(this.objects, true);
+
+ for (const object of this.objects) {
+ if (this.isSelectedObject(object)) continue;
+ object.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
+ }
+ });
+ }
+
+ if (intersects.length > 0) {
+ const intersected = this.getRoot(intersects[0].object);
+ if (this.isSelectedObject(intersected)) return;
+ intersected.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x191919);
+ }
+ });
+ }
+ }
+
+ @autobind
+ private onmousedown(ev: MouseEvent) {
+ if (this.isTransformMode) return;
+ if (ev.target !== this.canvas || ev.button !== 0) return;
+
+ const rect = (ev.target as HTMLElement).getBoundingClientRect();
+ const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
+ const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
+ const pos = new THREE.Vector2(x, y);
+
+ this.camera.updateMatrixWorld();
+
+ const raycaster = new THREE.Raycaster();
+ raycaster.setFromCamera(pos, this.camera);
+
+ const intersects = raycaster.intersectObjects(this.objects, true);
+
+ for (const object of this.objects) {
+ object.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
+ }
+ });
+ }
+
+ if (intersects.length > 0) {
+ const selectedObj = this.getRoot(intersects[0].object);
+ this.selectFurniture(selectedObj);
+ } else {
+ this.selectedObject = null;
+ this.onChangeSelect(null);
+ }
+ }
+
+ @autobind
+ private getRoot(obj: THREE.Object3D): THREE.Object3D {
+ let found = false;
+ let x = obj.parent;
+ while (!found) {
+ if (x.parent.parent == null) {
+ found = true;
+ } else {
+ x = x.parent;
+ }
+ }
+ return x;
+ }
+
+ @autobind
+ private isSelectedObject(obj: THREE.Object3D): boolean {
+ if (this.selectedObject == null) {
+ return false;
+ } else {
+ return obj.name === this.selectedObject.name;
+ }
+ }
+
+ @autobind
+ private selectFurniture(obj: THREE.Object3D) {
+ this.selectedObject = obj;
+ this.onChangeSelect(obj);
+ obj.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ (child.material as THREE.MeshStandardMaterial).emissive.setHex(0xff0000);
+ }
+ });
+ }
+
+ /**
+ * 家具の移動/回転モードにします
+ * @param type 移動か回転か
+ */
+ @autobind
+ public enterTransformMode(type: 'translate' | 'rotate') {
+ this.isTransformMode = true;
+ this.furnitureControl.setMode(type);
+ this.furnitureControl.attach(this.selectedObject);
+ this.controls.enableRotate = false;
+ }
+
+ /**
+ * 家具の移動/回転モードを終了します
+ */
+ @autobind
+ public exitTransformMode() {
+ this.isTransformMode = false;
+ this.furnitureControl.detach();
+ this.controls.enableRotate = true;
+ }
+
+ /**
+ * 家具プロパティを更新します
+ * @param key プロパティ名
+ * @param value 値
+ */
+ @autobind
+ public updateProp(key: string, value: any) {
+ const furniture = this.furnitures.find(furniture => furniture.id === this.selectedObject.name);
+ if (furniture.props == null) furniture.props = {};
+ furniture.props[key] = value;
+ this.applyCustomColor(this.selectedObject);
+ this.applyCustomTexture(this.selectedObject);
+ }
+
+ /**
+ * 部屋に家具を追加します
+ * @param type 家具の種類
+ */
+ @autobind
+ public addFurniture(type: string) {
+ const furniture = {
+ id: uuid(),
+ type: type,
+ position: {
+ x: 0,
+ y: 0,
+ z: 0,
+ },
+ rotation: {
+ x: 0,
+ y: 0,
+ z: 0,
+ },
+ };
+
+ this.furnitures.push(furniture);
+
+ this.loadFurniture(furniture).then(obj => {
+ this.scene.add(obj.scene);
+ this.objects.push(obj.scene);
+ });
+ }
+
+ /**
+ * 現在選択されている家具を部屋から削除します
+ */
+ @autobind
+ public removeFurniture() {
+ this.exitTransformMode();
+ const obj = this.selectedObject;
+ this.scene.remove(obj);
+ this.objects = this.objects.filter(object => object.name !== obj.name);
+ this.furnitures = this.furnitures.filter(furniture => furniture.id !== obj.name);
+ this.selectedObject = null;
+ this.onChangeSelect(null);
+ }
+
+ /**
+ * 全ての家具を部屋から削除します
+ */
+ @autobind
+ public removeAllFurnitures() {
+ this.exitTransformMode();
+ for (const obj of this.objects) {
+ this.scene.remove(obj);
+ }
+ this.objects = [];
+ this.furnitures = [];
+ this.selectedObject = null;
+ this.onChangeSelect(null);
+ }
+
+ /**
+ * 部屋の床の色を変更します
+ * @param color 色
+ */
+ @autobind
+ public updateCarpetColor(color: string) {
+ this.roomInfo.carpetColor = color;
+ this.applyCarpetColor();
+ }
+
+ /**
+ * 部屋の種類を変更します
+ * @param type 種類
+ */
+ @autobind
+ public changeRoomType(type: string) {
+ this.roomInfo.roomType = type;
+ this.scene.remove(this.roomObj);
+ this.loadRoom();
+ }
+
+ /**
+ * 部屋データを取得します
+ */
+ @autobind
+ public getRoomInfo() {
+ for (const obj of this.objects) {
+ const furniture = this.furnitures.find(f => f.id === obj.name);
+ furniture.position.x = obj.position.x;
+ furniture.position.y = obj.position.y;
+ furniture.position.z = obj.position.z;
+ furniture.rotation.x = obj.rotation.x;
+ furniture.rotation.y = obj.rotation.y;
+ furniture.rotation.z = obj.rotation.z;
+ }
+
+ return this.roomInfo;
+ }
+
+ /**
+ * 選択されている家具を取得します
+ */
+ @autobind
+ public getSelectedObject() {
+ return this.selectedObject;
+ }
+
+ @autobind
+ public findFurnitureById(id: string) {
+ return this.furnitures.find(furniture => furniture.id === id);
+ }
+
+ /**
+ * レンダリングを終了します
+ */
+ @autobind
+ public destroy() {
+ // Stop render loop
+ window.cancelAnimationFrame(this.renderFrameRequestId);
+
+ this.controls.dispose();
+ this.scene.dispose();
+ }
+}
diff --git a/packages/client/src/scripts/scroll.ts b/packages/client/src/scripts/scroll.ts
new file mode 100644
index 0000000000..621fe88105
--- /dev/null
+++ b/packages/client/src/scripts/scroll.ts
@@ -0,0 +1,80 @@
+type ScrollBehavior = 'auto' | 'smooth' | 'instant';
+
+export function getScrollContainer(el: Element | null): Element | null {
+ if (el == null || el.tagName === 'BODY') return null;
+ const overflow = window.getComputedStyle(el).getPropertyValue('overflow');
+ if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる
+ return el;
+ } else {
+ return getScrollContainer(el.parentElement);
+ }
+}
+
+export function getScrollPosition(el: Element | null): number {
+ const container = getScrollContainer(el);
+ return container == null ? window.scrollY : container.scrollTop;
+}
+
+export function isTopVisible(el: Element | null): boolean {
+ const scrollTop = getScrollPosition(el);
+ const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる
+
+ return scrollTop <= topPosition;
+}
+
+export function onScrollTop(el: Element, cb) {
+ const container = getScrollContainer(el) || window;
+ const onScroll = ev => {
+ if (!document.body.contains(el)) return;
+ if (isTopVisible(el)) {
+ cb();
+ container.removeEventListener('scroll', onScroll);
+ }
+ };
+ container.addEventListener('scroll', onScroll, { passive: true });
+}
+
+export function onScrollBottom(el: Element, cb) {
+ const container = getScrollContainer(el) || window;
+ const onScroll = ev => {
+ if (!document.body.contains(el)) return;
+ const pos = getScrollPosition(el);
+ if (pos + el.clientHeight > el.scrollHeight - 1) {
+ cb();
+ container.removeEventListener('scroll', onScroll);
+ }
+ };
+ container.addEventListener('scroll', onScroll, { passive: true });
+}
+
+export function scroll(el: Element, options: {
+ top?: number;
+ left?: number;
+ behavior?: ScrollBehavior;
+}) {
+ const container = getScrollContainer(el);
+ if (container == null) {
+ window.scroll(options);
+ } else {
+ container.scroll(options);
+ }
+}
+
+export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
+ scroll(el, { top: 0, ...options });
+}
+
+export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) {
+ scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する
+}
+
+export function isBottom(el: Element, asobi = 0) {
+ const container = getScrollContainer(el);
+ const current = container
+ ? el.scrollTop + el.offsetHeight
+ : window.scrollY + window.innerHeight;
+ const max = container
+ ? el.scrollHeight
+ : document.body.offsetHeight;
+ return current >= (max - asobi);
+}
diff --git a/packages/client/src/scripts/search.ts b/packages/client/src/scripts/search.ts
new file mode 100644
index 0000000000..b28cccfab7
--- /dev/null
+++ b/packages/client/src/scripts/search.ts
@@ -0,0 +1,64 @@
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { router } from '@/router';
+
+export async function search() {
+ const { canceled, result: query } = await os.dialog({
+ title: i18n.locale.search,
+ input: true
+ });
+ if (canceled || query == null || query === '') return;
+
+ const q = query.trim();
+
+ if (q.startsWith('@') && !q.includes(' ')) {
+ router.push(`/${q}`);
+ return;
+ }
+
+ if (q.startsWith('#')) {
+ router.push(`/tags/${encodeURIComponent(q.substr(1))}`);
+ return;
+ }
+
+ // like 2018/03/12
+ if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) {
+ const date = new Date(q.replace(/-/g, '/'));
+
+ // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは
+ // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので
+ // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の
+ // 結果になってしまい、2018/03/12 のコンテンツは含まれない)
+ if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) {
+ date.setHours(23, 59, 59, 999);
+ }
+
+ // TODO
+ //v.$root.$emit('warp', date);
+ os.dialog({
+ icon: 'fas fa-history',
+ iconOnly: true, autoClose: true
+ });
+ return;
+ }
+
+ if (q.startsWith('https://')) {
+ const promise = os.api('ap/show', {
+ uri: q
+ });
+
+ os.promiseDialog(promise, null, null, i18n.locale.fetchingAsApObject);
+
+ const res = await promise;
+
+ if (res.type === 'User') {
+ router.push(`/@${res.object.username}@${res.object.host}`);
+ } else if (res.type === 'Note') {
+ router.push(`/notes/${res.object.id}`);
+ }
+
+ return;
+ }
+
+ router.push(`/search?q=${encodeURIComponent(q)}`);
+}
diff --git a/packages/client/src/scripts/select-file.ts b/packages/client/src/scripts/select-file.ts
new file mode 100644
index 0000000000..5fbc545b26
--- /dev/null
+++ b/packages/client/src/scripts/select-file.ts
@@ -0,0 +1,89 @@
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+
+export function selectFile(src: any, label: string | null, multiple = false) {
+ return new Promise((res, rej) => {
+ const chooseFileFromPc = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = multiple;
+ input.onchange = () => {
+ const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder));
+
+ Promise.all(promises).then(driveFiles => {
+ res(multiple ? driveFiles : driveFiles[0]);
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+
+ // 一応廃棄
+ (window as any).__misskey_input_ref__ = null;
+ };
+
+ // https://qiita.com/fukasawah/items/b9dc732d95d99551013d
+ // iOS Safari で正常に動かす為のおまじない
+ (window as any).__misskey_input_ref__ = input;
+
+ input.click();
+ };
+
+ const chooseFileFromDrive = () => {
+ os.selectDriveFile(multiple).then(files => {
+ res(files);
+ });
+ };
+
+ const chooseFileFromUrl = () => {
+ os.dialog({
+ title: i18n.locale.uploadFromUrl,
+ input: {
+ placeholder: i18n.locale.uploadFromUrlDescription
+ }
+ }).then(({ canceled, result: url }) => {
+ if (canceled) return;
+
+ const marker = Math.random().toString(); // TODO: UUIDとか使う
+
+ const connection = os.stream.useChannel('main');
+ connection.on('urlUploadFinished', data => {
+ if (data.marker === marker) {
+ res(multiple ? [data.file] : data.file);
+ connection.dispose();
+ }
+ });
+
+ os.api('drive/files/upload-from-url', {
+ url: url,
+ folderId: defaultStore.state.uploadFolder,
+ marker
+ });
+
+ os.dialog({
+ title: i18n.locale.uploadFromUrlRequested,
+ text: i18n.locale.uploadFromUrlMayTakeTime
+ });
+ });
+ };
+
+ os.popupMenu([label ? {
+ text: label,
+ type: 'label'
+ } : undefined, {
+ text: i18n.locale.upload,
+ icon: 'fas fa-upload',
+ action: chooseFileFromPc
+ }, {
+ text: i18n.locale.fromDrive,
+ icon: 'fas fa-cloud',
+ action: chooseFileFromDrive
+ }, {
+ text: i18n.locale.fromUrl,
+ icon: 'fas fa-link',
+ action: chooseFileFromUrl
+ }], src);
+ });
+}
diff --git a/packages/client/src/scripts/show-suspended-dialog.ts b/packages/client/src/scripts/show-suspended-dialog.ts
new file mode 100644
index 0000000000..3bc4800030
--- /dev/null
+++ b/packages/client/src/scripts/show-suspended-dialog.ts
@@ -0,0 +1,10 @@
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+export function showSuspendedDialog() {
+ return os.dialog({
+ type: 'error',
+ title: i18n.locale.yourAccountSuspendedTitle,
+ text: i18n.locale.yourAccountSuspendedDescription
+ });
+}
diff --git a/packages/client/src/scripts/sound.ts b/packages/client/src/scripts/sound.ts
new file mode 100644
index 0000000000..2b8279b3df
--- /dev/null
+++ b/packages/client/src/scripts/sound.ts
@@ -0,0 +1,34 @@
+import { ColdDeviceStorage } from '@/store';
+
+const cache = new Map<string, HTMLAudioElement>();
+
+export function getAudio(file: string, useCache = true): HTMLAudioElement {
+ let audio: HTMLAudioElement;
+ if (useCache && cache.has(file)) {
+ audio = cache.get(file);
+ } else {
+ audio = new Audio(`/client-assets/sounds/${file}.mp3`);
+ if (useCache) cache.set(file, audio);
+ }
+ return audio;
+}
+
+export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement {
+ const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
+ audio.volume = masterVolume - ((1 - volume) * masterVolume);
+ return audio;
+}
+
+export function play(type: string) {
+ const sound = ColdDeviceStorage.get('sound_' + type as any);
+ if (sound.type == null) return;
+ playFile(sound.type, sound.volume);
+}
+
+export function playFile(file: string, volume: number) {
+ const masterVolume = ColdDeviceStorage.get('sound_masterVolume');
+ if (masterVolume === 0) return;
+
+ const audio = setVolume(getAudio(file), volume);
+ audio.play();
+}
diff --git a/packages/client/src/scripts/sticky-sidebar.ts b/packages/client/src/scripts/sticky-sidebar.ts
new file mode 100644
index 0000000000..c67b8f37ac
--- /dev/null
+++ b/packages/client/src/scripts/sticky-sidebar.ts
@@ -0,0 +1,50 @@
+export class StickySidebar {
+ private lastScrollTop = 0;
+ private container: HTMLElement;
+ private el: HTMLElement;
+ private spacer: HTMLElement;
+ private marginTop: number;
+ private isTop = false;
+ private isBottom = false;
+ private offsetTop: number;
+ private globalHeaderHeight: number = 59;
+
+ constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) {
+ this.container = container;
+ this.el = this.container.children[0] as HTMLElement;
+ this.el.style.position = 'sticky';
+ this.spacer = document.createElement('div');
+ this.container.prepend(this.spacer);
+ this.marginTop = marginTop;
+ this.offsetTop = this.container.getBoundingClientRect().top;
+ this.globalHeaderHeight = globalHeaderHeight;
+ }
+
+ public calc(scrollTop: number) {
+ if (scrollTop > this.lastScrollTop) { // downscroll
+ const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight);
+ this.el.style.bottom = null;
+ this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`;
+
+ this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight);
+
+ if (this.isTop) {
+ this.isTop = false;
+ this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`;
+ }
+ } else { // upscroll
+ const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight;
+ this.el.style.top = null;
+ this.el.style.bottom = `${-overflow}px`;
+
+ this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop;
+
+ if (this.isBottom) {
+ this.isBottom = false;
+ this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`;
+ }
+ }
+
+ this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop;
+ }
+}
diff --git a/packages/client/src/scripts/theme-editor.ts b/packages/client/src/scripts/theme-editor.ts
new file mode 100644
index 0000000000..3d69d2836a
--- /dev/null
+++ b/packages/client/src/scripts/theme-editor.ts
@@ -0,0 +1,81 @@
+import { v4 as uuid} from 'uuid';
+
+import { themeProps, Theme } from './theme';
+
+export type Default = null;
+export type Color = string;
+export type FuncName = 'alpha' | 'darken' | 'lighten';
+export type Func = { type: 'func'; name: FuncName; arg: number; value: string; };
+export type RefProp = { type: 'refProp'; key: string; };
+export type RefConst = { type: 'refConst'; key: string; };
+export type Css = { type: 'css'; value: string; };
+
+export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default;
+
+export type ThemeViewModel = [ string, ThemeValue ][];
+
+export const fromThemeString = (str?: string) : ThemeValue => {
+ if (!str) return null;
+ if (str.startsWith(':')) {
+ const parts = str.slice(1).split('<');
+ const name = parts[0] as FuncName;
+ const arg = parseFloat(parts[1]);
+ const value = parts[2].startsWith('@') ? parts[2].slice(1) : '';
+ return { type: 'func', name, arg, value };
+ } else if (str.startsWith('@')) {
+ return {
+ type: 'refProp',
+ key: str.slice(1),
+ };
+ } else if (str.startsWith('$')) {
+ return {
+ type: 'refConst',
+ key: str.slice(1),
+ };
+ } else if (str.startsWith('"')) {
+ return {
+ type: 'css',
+ value: str.substr(1).trim(),
+ };
+ } else {
+ return str;
+ }
+};
+
+export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => {
+ if (typeof value === 'string') return value;
+ switch (value.type) {
+ case 'func': return `:${value.name}<${value.arg}<@${value.value}`;
+ case 'refProp': return `@${value.key}`;
+ case 'refConst': return `$${value.key}`;
+ case 'css': return `" ${value.value}`;
+ }
+};
+
+export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => {
+ const props = { } as { [key: string]: string };
+ for (const [ key, value ] of vm) {
+ if (value === null) continue;
+ props[key] = toThemeString(value);
+ }
+
+ return {
+ id: uuid(),
+ name, desc, author, props, base
+ };
+};
+
+export const convertToViewModel = (theme: Theme): ThemeViewModel => {
+ const vm: ThemeViewModel = [];
+ // プロパティの登録
+ vm.push(...themeProps.map(key => [ key, fromThemeString(theme.props[key])] as [ string, ThemeValue ]));
+
+ // 定数の登録
+ const consts = Object
+ .keys(theme.props)
+ .filter(k => k.startsWith('$'))
+ .map(k => [ k, fromThemeString(theme.props[k]) ] as [ string, ThemeValue ]);
+
+ vm.push(...consts);
+ return vm;
+};
diff --git a/packages/client/src/scripts/theme.ts b/packages/client/src/scripts/theme.ts
new file mode 100644
index 0000000000..3b7f003d0f
--- /dev/null
+++ b/packages/client/src/scripts/theme.ts
@@ -0,0 +1,127 @@
+import { globalEvents } from '@/events';
+import * as tinycolor from 'tinycolor2';
+
+export type Theme = {
+ id: string;
+ name: string;
+ author: string;
+ desc?: string;
+ base?: 'dark' | 'light';
+ props: Record<string, string>;
+};
+
+export const lightTheme: Theme = require('@/themes/_light.json5');
+export const darkTheme: Theme = require('@/themes/_dark.json5');
+
+export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
+
+export const builtinThemes = [
+ require('@/themes/l-light.json5'),
+ require('@/themes/l-apricot.json5'),
+ require('@/themes/l-rainy.json5'),
+ require('@/themes/l-vivid.json5'),
+ require('@/themes/l-sushi.json5'),
+
+ require('@/themes/d-dark.json5'),
+ require('@/themes/d-persimmon.json5'),
+ require('@/themes/d-astro.json5'),
+ require('@/themes/d-future.json5'),
+ require('@/themes/d-botanical.json5'),
+ require('@/themes/d-pumpkin.json5'),
+ require('@/themes/d-black.json5'),
+] as Theme[];
+
+let timeout = null;
+
+export function applyTheme(theme: Theme, persist = true) {
+ if (timeout) clearTimeout(timeout);
+
+ document.documentElement.classList.add('_themeChanging_');
+
+ timeout = setTimeout(() => {
+ document.documentElement.classList.remove('_themeChanging_');
+ }, 1000);
+
+ // Deep copy
+ const _theme = JSON.parse(JSON.stringify(theme));
+
+ if (_theme.base) {
+ const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
+ _theme.props = Object.assign({}, base.props, _theme.props);
+ }
+
+ const props = compile(_theme);
+
+ for (const tag of document.head.children) {
+ if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
+ tag.setAttribute('content', props['html']);
+ break;
+ }
+ }
+
+ for (const [k, v] of Object.entries(props)) {
+ document.documentElement.style.setProperty(`--${k}`, v.toString());
+ }
+
+ if (persist) {
+ localStorage.setItem('theme', JSON.stringify(props));
+ }
+
+ // 色計算など再度行えるようにクライアント全体に通知
+ globalEvents.emit('themeChanged');
+}
+
+function compile(theme: Theme): Record<string, string> {
+ function getColor(val: string): tinycolor.Instance {
+ // ref (prop)
+ if (val[0] === '@') {
+ return getColor(theme.props[val.substr(1)]);
+ }
+
+ // ref (const)
+ else if (val[0] === '$') {
+ return getColor(theme.props[val]);
+ }
+
+ // func
+ else if (val[0] === ':') {
+ const parts = val.split('<');
+ const func = parts.shift().substr(1);
+ const arg = parseFloat(parts.shift());
+ const color = getColor(parts.join('<'));
+
+ switch (func) {
+ case 'darken': return color.darken(arg);
+ case 'lighten': return color.lighten(arg);
+ case 'alpha': return color.setAlpha(arg);
+ case 'hue': return color.spin(arg);
+ case 'saturate': return color.saturate(arg);
+ }
+ }
+
+ // other case
+ return tinycolor(val);
+ }
+
+ const props = {};
+
+ for (const [k, v] of Object.entries(theme.props)) {
+ if (k.startsWith('$')) continue; // ignore const
+
+ props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v));
+ }
+
+ return props;
+}
+
+function genValue(c: tinycolor.Instance): string {
+ return c.toRgbString();
+}
+
+export function validateTheme(theme: Record<string, any>): boolean {
+ if (theme.id == null || typeof theme.id !== 'string') return false;
+ if (theme.name == null || typeof theme.name !== 'string') return false;
+ if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
+ if (theme.props == null || typeof theme.props !== 'object') return false;
+ return true;
+}
diff --git a/packages/client/src/scripts/time.ts b/packages/client/src/scripts/time.ts
new file mode 100644
index 0000000000..34e8b6b17c
--- /dev/null
+++ b/packages/client/src/scripts/time.ts
@@ -0,0 +1,39 @@
+const dateTimeIntervals = {
+ 'day': 86400000,
+ 'hour': 3600000,
+ 'ms': 1,
+};
+
+export function dateUTC(time: number[]): Date {
+ const d = time.length === 2 ? Date.UTC(time[0], time[1])
+ : time.length === 3 ? Date.UTC(time[0], time[1], time[2])
+ : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3])
+ : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4])
+ : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5])
+ : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6])
+ : null;
+
+ if (!d) throw 'wrong number of arguments';
+
+ return new Date(d);
+}
+
+export function isTimeSame(a: Date, b: Date): boolean {
+ return a.getTime() === b.getTime();
+}
+
+export function isTimeBefore(a: Date, b: Date): boolean {
+ return (a.getTime() - b.getTime()) < 0;
+}
+
+export function isTimeAfter(a: Date, b: Date): boolean {
+ return (a.getTime() - b.getTime()) > 0;
+}
+
+export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
+ return new Date(x.getTime() + (value * dateTimeIntervals[span]));
+}
+
+export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date {
+ return new Date(x.getTime() - (value * dateTimeIntervals[span]));
+}
diff --git a/packages/client/src/scripts/twemoji-base.ts b/packages/client/src/scripts/twemoji-base.ts
new file mode 100644
index 0000000000..cd50311b15
--- /dev/null
+++ b/packages/client/src/scripts/twemoji-base.ts
@@ -0,0 +1 @@
+export const twemojiSvgBase = '/twemoji';
diff --git a/packages/client/src/scripts/unison-reload.ts b/packages/client/src/scripts/unison-reload.ts
new file mode 100644
index 0000000000..59af584c1b
--- /dev/null
+++ b/packages/client/src/scripts/unison-reload.ts
@@ -0,0 +1,15 @@
+// SafariがBroadcastChannel未実装なのでライブラリを使う
+import { BroadcastChannel } from 'broadcast-channel';
+
+export const reloadChannel = new BroadcastChannel<string | null>('reload');
+
+// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。
+export function unisonReload(path?: string) {
+ if (path !== undefined) {
+ reloadChannel.postMessage(path);
+ location.href = path;
+ } else {
+ reloadChannel.postMessage(null);
+ location.reload();
+ }
+}
diff --git a/packages/client/src/scripts/url.ts b/packages/client/src/scripts/url.ts
new file mode 100644
index 0000000000..c7f2b7c1e7
--- /dev/null
+++ b/packages/client/src/scripts/url.ts
@@ -0,0 +1,13 @@
+export function query(obj: {}): string {
+ const params = Object.entries(obj)
+ .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)
+ .reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>);
+
+ return Object.entries(params)
+ .map((e) => `${e[0]}=${encodeURIComponent(e[1])}`)
+ .join('&');
+}
+
+export function appendQuery(url: string, query: string): string {
+ return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
+}