rename: client -> frontend

This commit is contained in:
syuilo
2022-12-27 14:36:33 +09:00
parent db6fff6f26
commit 9384f5399d
592 changed files with 111 additions and 111 deletions

View File

@ -0,0 +1,33 @@
export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') {
switch (encoding) {
case 'ascii':
return Uint8Array.from(string, c => c.charCodeAt(0));
case 'base64':
return Uint8Array.from(
atob(
string
.replace(/-/g, '+')
.replace(/_/g, '/'),
),
c => c.charCodeAt(0),
);
case 'hex':
return new Uint8Array(
string
.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));
}

View File

@ -0,0 +1,43 @@
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.alert({
type: type ? type.value : 'info',
title: title.value,
text: text.value,
});
}),
'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
const confirm = await os.confirm({
type: type ? type.value : 'question',
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)));
}),
};
}

View File

@ -0,0 +1,149 @@
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 uniqueBy<TValue, TKey>(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] {
const map = new Map<TKey, TValue>();
for (const value of values) {
const key = keySelector(value);
if (!map.has(key)) map.set(key, value);
}
return [...map.values()];
}
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 (typeof obj[key] === 'undefined') {
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: T[] = [];
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;
}

View File

@ -0,0 +1,276 @@
import { nextTick, Ref, ref, defineAsyncComponent } from 'vue';
import 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: () => void;
} | null;
private textarea: HTMLInputElement | HTMLTextAreaElement;
private currentType: string;
private textRef: Ref<string>;
private opening: boolean;
private get text(): string {
// Use raw .value to get the latest value
// (Because v-model does not update while composition)
return this.textarea.value;
}
private set text(text: string) {
// Use ref value to notify other watchers
// (Because .value setter never fires input/change events)
this.textRef.value = text;
}
/**
* 対象のテキストエリアを与えてインスタンスを初期化します。
*/
constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
//#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.textRef = textRef;
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(defineAsyncComponent(() => import('@/components/MkAutocomplete.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}`;
// キャレットを戻す
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}`;
// キャレットを戻す
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;
// キャレットを戻す
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}`;
// キャレットを戻す
nextTick(() => {
this.textarea.focus();
const pos = trimmedBefore.length + (value.length + 3);
this.textarea.setSelectionRange(pos, pos);
});
}
}
}

View File

@ -0,0 +1,21 @@
export const chartVLine = (vLineColor: string) => ({
id: 'vLine',
beforeDraw(chart, args, options) {
if (chart.tooltip?._active?.length) {
const activePoint = chart.tooltip._active[0];
const ctx = chart.ctx;
const x = activePoint.element.x;
const topY = chart.scales.y.top;
const bottomY = chart.scales.y.bottom;
ctx.save();
ctx.beginPath();
ctx.moveTo(x, bottomY);
ctx.lineTo(x, topY);
ctx.lineWidth = 1;
ctx.strokeStyle = vLineColor;
ctx.stroke();
ctx.restore();
}
},
});

View File

@ -0,0 +1,37 @@
export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: Array<string | string[]>): boolean {
// 自分自身
if (me && (note.userId === me.id)) return false;
if (mutedWords.length > 0) {
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim();
if (text === '') return false;
const matched = mutedWords.some(filter => {
if (Array.isArray(filter)) {
// Clean up
const filteredFilter = filter.filter(keyword => keyword !== '');
if (filteredFilter.length === 0) return false;
return filteredFilter.every(keyword => text.includes(keyword));
} else {
// represents RegExp
const regexp = filter.match(/^\/(.+)\/(.*)$/);
// This should never happen due to input sanitisation.
if (!regexp) return false;
try {
return new RegExp(regexp[1], regexp[2]).test(text);
} catch (err) {
// This should never happen due to input sanitisation.
return false;
}
}
});
if (matched) return true;
}
return false;
}

View File

@ -0,0 +1,18 @@
// structredCloneが遅いため
// SEE: http://var.blog.jp/archives/86038606.html
type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[];
export function deepClone<T extends Cloneable>(x: T): T {
if (typeof x === 'object') {
if (x === null) return x;
if (Array.isArray(x)) return x.map(deepClone) as T;
const obj = {} as Record<string, Cloneable>;
for (const [k, v] of Object.entries(x)) {
obj[k] = deepClone(v);
}
return obj as T;
} else {
return x;
}
}

View File

@ -0,0 +1,68 @@
interface StringPageVar {
name: string,
type: 'string',
value: string
}
interface NumberPageVar {
name: string,
type: 'number',
value: number
}
interface BooleanPageVar {
name: string,
type: 'boolean',
value: boolean
}
type PageVar = StringPageVar | NumberPageVar | BooleanPageVar;
export function collectPageVars(content): PageVar[] {
const pageVars: PageVar[] = [];
const collect = (xs: any[]): void => {
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;
}

View File

@ -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;
};

View File

@ -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;
};

View File

@ -0,0 +1,10 @@
import { defaultStore } from '@/store';
const ua = navigator.userAgent.toLowerCase();
const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700);
const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua);
export const deviceKind = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind
: isSmartphone ? 'smartphone'
: isTablet ? 'tablet'
: 'desktop';

View File

@ -0,0 +1,20 @@
const twemojiSvgBase = '/twemoji';
const fluentEmojiPngBase = '/fluent-emoji';
export function char2twemojiFilePath(char: string): string {
let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
codes = codes.filter(x => x && x.length);
const fileName = codes.join('-');
return `${twemojiSvgBase}/${fileName}.svg`;
}
export function char2fluentEmojiFilePath(char: string): string {
let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
// Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25
if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char);
if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f');
codes = codes.filter(x => x && x.length);
const fileName = codes.map(x => x!.padStart(4, '0')).join('-');
return `${fluentEmojiPngBase}/${fileName}.png`;
}

View File

@ -0,0 +1,17 @@
export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'] as const;
export type UnicodeEmojiDef = {
name: string;
keywords: string[];
char: string;
category: typeof unicodeEmojiCategories[number];
}
// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
import _emojilist from '../emojilist.json';
export const emojilist = _emojilist as UnicodeEmojiDef[];
export function getEmojiName(char: string): string | undefined {
return emojilist.find(emo => emo.char === char)?.name;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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[]);
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,59 @@
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: 'radio';
default: unknown | null;
hidden?: boolean;
options: {
label: string;
value: unknown;
}[];
} | {
label?: string;
type: 'object';
default: Record<string, unknown> | null;
hidden: true;
} | {
label?: string;
type: 'array';
default: unknown[] | null;
hidden: true;
};
export type Form = Record<string, FormItem>;
type GetItemType<Item extends FormItem> =
Item['type'] extends 'string' ? string :
Item['type'] extends 'number' ? number :
Item['type'] extends 'boolean' ? boolean :
Item['type'] extends 'radio' ? unknown :
Item['type'] extends 'enum' ? string :
Item['type'] extends 'array' ? unknown[] :
Item['type'] extends 'object' ? Record<string, unknown>
: never;
export type GetFormResultType<F extends Form> = {
[P in keyof F]: GetItemType<F[P]>;
};

View File

@ -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;
});
}

View File

@ -0,0 +1,30 @@
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,
};
}

View File

@ -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(account => account.id === id);
}

View File

@ -0,0 +1,341 @@
import { defineAsyncComponent, Ref, inject } from 'vue';
import * as misskey from 'misskey-js';
import { pleaseLogin } from './please-login';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import * as os from '@/os';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { url } from '@/config';
import { noteActions } from '@/store';
import { notePage } from '@/filters/note';
export function getNoteMenu(props: {
note: misskey.entities.Note;
menuButton: Ref<HTMLElement>;
translation: Ref<any>;
translating: Ref<boolean>;
isDeleted: Ref<boolean>;
currentClipPage?: Ref<misskey.entities.Clip>;
}) {
const isRenote = (
props.note.renote != null &&
props.note.text == null &&
props.note.fileIds.length === 0 &&
props.note.poll == null
);
const appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note;
function del(): void {
os.confirm({
type: 'warning',
text: i18n.ts.noteDeleteConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: appearNote.id,
});
});
}
function delEdit(): void {
os.confirm({
type: 'warning',
text: i18n.ts.deleteAndEditConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: appearNote.id,
});
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel });
});
}
function toggleFavorite(favorite: boolean): void {
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
noteId: appearNote.id,
});
}
function toggleThreadMute(mute: boolean): void {
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
noteId: appearNote.id,
});
}
function copyContent(): void {
copyToClipboard(appearNote.text);
os.success();
}
function copyLink(): void {
copyToClipboard(`${url}/notes/${appearNote.id}`);
os.success();
}
function togglePin(pin: boolean): void {
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
noteId: appearNote.id,
}, undefined, null, res => {
if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
os.alert({
type: 'error',
text: i18n.ts.pinLimitExceeded,
});
}
});
}
async function clip(): Promise<void> {
const clips = await os.api('clips/list');
os.popupMenu([{
icon: 'ti ti-plus',
text: i18n.ts.createNew,
action: async () => {
const { canceled, result } = await os.form(i18n.ts.createNewClip, {
name: {
type: 'string',
label: i18n.ts.name,
},
description: {
type: 'string',
required: false,
multiline: true,
label: i18n.ts.description,
},
isPublic: {
type: 'boolean',
label: i18n.ts.public,
default: false,
},
});
if (canceled) return;
const clip = await os.apiWithDialog('clips/create', result);
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id });
},
}, null, ...clips.map(clip => ({
text: clip.name,
action: () => {
os.promiseDialog(
os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }),
null,
async (err) => {
if (err.id === '734806c4-542c-463a-9311-15c512803965') {
const confirm = await os.confirm({
type: 'warning',
text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }),
});
if (!confirm.canceled) {
os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id });
if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true;
}
} else {
os.alert({
type: 'error',
text: err.message + '\n' + err.id,
});
}
},
);
},
}))], props.menuButton.value, {
}).then(focus);
}
async function unclip(): Promise<void> {
os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id });
props.isDeleted.value = true;
}
async function promote(): Promise<void> {
const { canceled, result: days } = await os.inputNumber({
title: i18n.ts.numberOfDays,
});
if (canceled) return;
os.apiWithDialog('admin/promo/create', {
noteId: appearNote.id,
expiresAt: Date.now() + (86400000 * days),
});
}
function share(): void {
navigator.share({
title: i18n.t('noteOf', { user: appearNote.user.name }),
text: appearNote.text,
url: `${url}/notes/${appearNote.id}`,
});
}
function notedetails(): void {
os.pageWindow(`/notes/${appearNote.id}`);
}
async function translate(): Promise<void> {
if (props.translation.value != null) return;
props.translating.value = true;
const res = await os.api('notes/translate', {
noteId: appearNote.id,
targetLang: localStorage.getItem('lang') || navigator.language,
});
props.translating.value = false;
props.translation.value = res;
}
let menu;
if ($i) {
const statePromise = os.api('notes/state', {
noteId: appearNote.id,
});
menu = [
...(
props.currentClipPage?.value.userId === $i.id ? [{
icon: 'ti ti-backspace',
text: i18n.ts.unclip,
danger: true,
action: unclip,
}, null] : []
), {
icon: 'ti ti-external-link',
text: i18n.ts.details,
action: notedetails,
}, {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,
action: copyContent,
}, {
icon: 'ti ti-link',
text: i18n.ts.copyLink,
action: copyLink,
}, (appearNote.url || appearNote.uri) ? {
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(appearNote.url || appearNote.uri, '_blank');
},
} : undefined,
{
icon: 'ti ti-share',
text: i18n.ts.share,
action: share,
},
instance.translatorAvailable ? {
icon: 'ti ti-language-hiragana',
text: i18n.ts.translate,
action: translate,
} : undefined,
null,
statePromise.then(state => state.isFavorited ? {
icon: 'ti ti-star-off',
text: i18n.ts.unfavorite,
action: () => toggleFavorite(false),
} : {
icon: 'ti ti-star',
text: i18n.ts.favorite,
action: () => toggleFavorite(true),
}),
{
icon: 'ti ti-paperclip',
text: i18n.ts.clip,
action: () => clip(),
},
statePromise.then(state => state.isMutedThread ? {
icon: 'ti ti-message-off',
text: i18n.ts.unmuteThread,
action: () => toggleThreadMute(false),
} : {
icon: 'ti ti-message-off',
text: i18n.ts.muteThread,
action: () => toggleThreadMute(true),
}),
appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? {
icon: 'ti ti-pinned-off',
text: i18n.ts.unpin,
action: () => togglePin(false),
} : {
icon: 'ti ti-pin',
text: i18n.ts.pin,
action: () => togglePin(true),
} : undefined,
/*
...($i.isModerator || $i.isAdmin ? [
null,
{
icon: 'fas fa-bullhorn',
text: i18n.ts.promote,
action: promote
}]
: []
),*/
...(appearNote.userId !== $i.id ? [
null,
{
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: () => {
const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`;
os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: appearNote.user,
initialComment: `Note: ${u}\n-----\n`,
}, {}, 'closed');
},
}]
: []
),
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
null,
appearNote.userId === $i.id ? {
icon: 'ti ti-edit',
text: i18n.ts.deleteAndEdit,
action: delEdit,
} : undefined,
{
icon: 'ti ti-trash',
text: i18n.ts.delete,
danger: true,
action: del,
}]
: []
)]
.filter(x => x !== undefined);
} else {
menu = [{
icon: 'ti ti-external-link',
text: i18n.ts.detailed,
action: openDetail,
}, {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,
action: copyContent,
}, {
icon: 'ti ti-link',
text: i18n.ts.copyLink,
action: copyLink,
}, (appearNote.url || appearNote.uri) ? {
icon: 'ti ti-external-link',
text: i18n.ts.showOnRemote,
action: () => {
window.open(appearNote.url || appearNote.uri, '_blank');
},
} : undefined]
.filter(x => x !== undefined);
}
if (noteActions.length > 0) {
menu = menu.concat([null, ...noteActions.map(action => ({
icon: 'ti ti-plug',
text: action.title,
action: () => {
action.handler(appearNote);
},
}))]);
}
return menu;
}

View File

@ -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.ts.deletedNote})`;
}
if (note.isHidden) {
return `(${i18n.ts.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.ts.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();
};

View File

@ -0,0 +1,19 @@
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;
}
// 拡張子がないとキャッシュしてくれないCDNがあるのでダミーの名前を指定する
const dummy = `${encodeURIComponent(`${u.host}${u.pathname}`)}.webp`;
return `${instanceUrl}/proxy/${dummy}?${url.query({
url: u.href,
static: '1',
})}`;
}

View File

@ -0,0 +1,253 @@
import * as Acct from 'misskey-js/built/acct';
import { defineAsyncComponent } from 'vue';
import { i18n } from '@/i18n';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { host } from '@/config';
import * as os from '@/os';
import { userActions } from '@/store';
import { $i, iAmModerator } from '@/account';
import { mainRouter } from '@/router';
import { Router } from '@/nirax';
export function getUserMenu(user, router: Router = mainRouter) {
const meId = $i ? $i.id : null;
async function pushList() {
const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく
const lists = await os.api('users/lists/list');
if (lists.length === 0) {
os.alert({
type: 'error',
text: i18n.ts.youHaveNoLists,
});
return;
}
const { canceled, result: listId } = await os.select({
title: t,
items: lists.map(list => ({
value: list.id, text: list.name,
})),
});
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.alert({
type: 'error',
text: i18n.ts.youHaveNoGroups,
});
return;
}
const { canceled, result: groupId } = await os.select({
title: i18n.ts.group,
items: groups.map(group => ({
value: group.id, text: group.name,
})),
});
if (canceled) return;
os.apiWithDialog('users/groups/invite', {
groupId: groupId,
userId: user.id,
});
}
async function toggleMute() {
if (user.isMuted) {
os.apiWithDialog('mute/delete', {
userId: user.id,
}).then(() => {
user.isMuted = false;
});
} else {
const { canceled, result: period } = await os.select({
title: i18n.ts.mutePeriod,
items: [{
value: 'indefinitely', text: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', text: i18n.ts.tenMinutes,
}, {
value: 'oneHour', text: i18n.ts.oneHour,
}, {
value: 'oneDay', text: i18n.ts.oneDay,
}, {
value: 'oneWeek', text: i18n.ts.oneWeek,
}],
default: 'indefinitely',
});
if (canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: null;
os.apiWithDialog('mute/create', {
userId: user.id,
expiresAt,
}).then(() => {
user.isMuted = true;
});
}
}
async function toggleBlock() {
if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.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(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: user,
}, {}, 'closed');
}
async function getConfirmed(text: string): Promise<boolean> {
const confirm = await os.confirm({
type: 'warning',
title: 'confirm',
text,
});
return !confirm.canceled;
}
async function invalidateFollow() {
os.apiWithDialog('following/invalidate', {
userId: user.id,
}).then(() => {
user.isFollowed = !user.isFollowed;
});
}
let menu = [{
icon: 'ti ti-at',
text: i18n.ts.copyUsername,
action: () => {
copyToClipboard(`@${user.username}@${user.host || host}`);
},
}, {
icon: 'ti ti-rss',
text: i18n.ts.copyRSS,
action: () => {
copyToClipboard(`${user.host || host}/@${user.username}.atom`);
}
}, {
icon: 'ti ti-info-circle',
text: i18n.ts.info,
action: () => {
router.push(`/user-info/${user.id}`);
},
}, {
icon: 'ti ti-mail',
text: i18n.ts.sendMessage,
action: () => {
os.post({ specified: user });
},
}, meId !== user.id ? {
type: 'link',
icon: 'ti ti-messages',
text: i18n.ts.startMessaging,
to: '/my/messaging/' + Acct.toString(user),
} : undefined, null, {
icon: 'ti ti-list',
text: i18n.ts.addToList,
action: pushList,
}, meId !== user.id ? {
icon: 'ti ti-users',
text: i18n.ts.inviteToGroup,
action: inviteGroup,
} : undefined] as any;
if ($i && meId !== user.id) {
menu = menu.concat([null, {
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
action: toggleMute,
}, {
icon: 'ti ti-ban',
text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block,
action: toggleBlock,
}]);
if (user.isFollowed) {
menu = menu.concat([{
icon: 'ti ti-link-off',
text: i18n.ts.breakFollow,
action: invalidateFollow,
}]);
}
menu = menu.concat([null, {
icon: 'ti ti-exclamation-circle',
text: i18n.ts.reportAbuse,
action: reportAbuse,
}]);
if (iAmModerator) {
menu = menu.concat([null, {
icon: 'ti ti-microphone-2-off',
text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence,
action: toggleSilence,
}, {
icon: 'ti ti-snowflake',
text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend,
action: toggleSuspend,
}]);
}
}
if ($i && meId === user.id) {
menu = menu.concat([null, {
icon: 'ti ti-pencil',
text: i18n.ts.editProfile,
action: () => {
router.push('/settings/profile');
},
}]);
}
if (userActions.length > 0) {
menu = menu.concat([null, ...userActions.map(action => ({
icon: 'ti ti-plug',
text: action.title,
action: () => {
action.handler(user);
},
}))]);
}
return menu;
}

View File

@ -0,0 +1,3 @@
export default function(user: { name?: string | null, username: string }): string {
return user.name || user.username;
}

View File

@ -0,0 +1,90 @@
import keyCode from './keycode';
type Callback = (ev: KeyboardEvent) => void;
type Keymap = Record<string, Callback>;
type Pattern = {
which: string[];
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
};
type Action = {
patterns: Pattern[];
callback: Callback;
allowRepeat: boolean;
};
const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => {
const result = {
patterns: [],
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(ev: KeyboardEvent, patterns: Action['patterns']): boolean {
const key = ev.code.toLowerCase();
return patterns.some(pattern => pattern.which.includes(key) &&
pattern.ctrl === ev.ctrlKey &&
pattern.shift === ev.shiftKey &&
pattern.alt === ev.altKey &&
!ev.metaKey,
);
}
export const makeHotkey = (keymap: Keymap) => {
const actions = parseKeymap(keymap);
return (ev: 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(ev, action.patterns);
if (matched) {
if (!action.allowRepeat && ev.repeat) return;
ev.preventDefault();
ev.stopPropagation();
action.callback(ev);
break;
}
}
};
};

View File

@ -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);
}

View File

@ -0,0 +1,232 @@
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.inputText({
title: q,
}).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 (err) {
//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 (err) {}
}
@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 (err) {
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)));
}
}
}

View File

@ -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;

View File

@ -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: 'ti ti-quote' },
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);
}
}
}

View File

@ -0,0 +1,247 @@
import tinycolor from 'tinycolor2';
import { Hpml } from './evaluator';
import { values, utils } from '@syuilo/aiscript';
import { Fn, HpmlScope } from '.';
import { Expr } from './expr';
import 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: 'ti ti-share' },
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: 'ti ti-plus' },
subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' },
multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' },
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: 'ti ti-quote' },
strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' },
strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' },
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()}`;
// SHOULD be fine to ignore since it's intended + function shape isn't defined
// eslint-disable-next-line @typescript-eslint/ban-types
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;
}

View File

@ -0,0 +1,191 @@
import autobind from 'autobind-decorator';
import { isLiteralValue } from './expr';
import { funcDefs } from './lib';
import { envVarsDef } from '.';
import type { Type, PageVar } from '.';
import type { Expr, Variable } from './expr';
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;
}
}

View File

@ -0,0 +1,29 @@
export class I18n<T extends Record<string, any>> {
public ts: T;
constructor(locale: T) {
this.ts = locale;
//#region BIND
this.t = this.t.bind(this);
//#endregion
}
// string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, string | number>): string {
try {
let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replace(`{${k}}`, v.toString());
}
}
return str;
} catch (err) {
console.warn(`missing localization '${key}'`);
return key;
}
}
}

View File

@ -0,0 +1,36 @@
// 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) {
iset('idb-test', 'test').catch(err => {
console.error('idb error', err);
console.error('indexedDB is unavailable. It will use localStorage.');
idbAvailable = false;
});
} else {
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));
}

View File

@ -0,0 +1,13 @@
import { lang } from '@/config';
export async function initializeSw() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register(`/sw.js`, { scope: '/', type: 'classic' });
navigator.serviceWorker.ready.then(registration => {
registration.active?.postMessage({
msg: 'initialize',
lang,
});
});
}

View File

@ -0,0 +1,3 @@
export function isDeviceDarkmode() {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}

View File

@ -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'],
};
/*!
* Programmatically 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}`];
}

View File

@ -0,0 +1,666 @@
// TODO: sharedに置いてバックエンドのと統合したい
export const langmap = {
'ach': {
nativeName: 'Lwo',
},
'ady': {
nativeName: 'Адыгэбзэ',
},
'af': {
nativeName: 'Afrikaans',
},
'af-NA': {
nativeName: 'Afrikaans (Namibia)',
},
'af-ZA': {
nativeName: 'Afrikaans (South Africa)',
},
'ak': {
nativeName: 'Tɕɥi',
},
'ar': {
nativeName: 'العربية',
},
'ar-AR': {
nativeName: 'العربية',
},
'ar-MA': {
nativeName: 'العربية',
},
'ar-SA': {
nativeName: 'العربية (السعودية)',
},
'ay-BO': {
nativeName: 'Aymar aru',
},
'az': {
nativeName: 'Azərbaycan dili',
},
'az-AZ': {
nativeName: 'Azərbaycan dili',
},
'be-BY': {
nativeName: 'Беларуская',
},
'bg': {
nativeName: 'Български',
},
'bg-BG': {
nativeName: 'Български',
},
'bn': {
nativeName: 'বাংলা',
},
'bn-IN': {
nativeName: 'বাংলা (ভারত)',
},
'bn-BD': {
nativeName: 'বাংলা(বাংলাদেশ)',
},
'br': {
nativeName: 'Brezhoneg',
},
'bs-BA': {
nativeName: 'Bosanski',
},
'ca': {
nativeName: 'Català',
},
'ca-ES': {
nativeName: 'Català',
},
'cak': {
nativeName: 'Maya Kaqchikel',
},
'ck-US': {
nativeName: 'ᏣᎳᎩ (tsalagi)',
},
'cs': {
nativeName: 'Čeština',
},
'cs-CZ': {
nativeName: 'Čeština',
},
'cy': {
nativeName: 'Cymraeg',
},
'cy-GB': {
nativeName: 'Cymraeg',
},
'da': {
nativeName: 'Dansk',
},
'da-DK': {
nativeName: 'Dansk',
},
'de': {
nativeName: 'Deutsch',
},
'de-AT': {
nativeName: 'Deutsch (Österreich)',
},
'de-DE': {
nativeName: 'Deutsch (Deutschland)',
},
'de-CH': {
nativeName: 'Deutsch (Schweiz)',
},
'dsb': {
nativeName: 'Dolnoserbšćina',
},
'el': {
nativeName: 'Ελληνικά',
},
'el-GR': {
nativeName: 'Ελληνικά',
},
'en': {
nativeName: 'English',
},
'en-GB': {
nativeName: 'English (UK)',
},
'en-AU': {
nativeName: 'English (Australia)',
},
'en-CA': {
nativeName: 'English (Canada)',
},
'en-IE': {
nativeName: 'English (Ireland)',
},
'en-IN': {
nativeName: 'English (India)',
},
'en-PI': {
nativeName: 'English (Pirate)',
},
'en-SG': {
nativeName: 'English (Singapore)',
},
'en-UD': {
nativeName: 'English (Upside Down)',
},
'en-US': {
nativeName: 'English (US)',
},
'en-ZA': {
nativeName: 'English (South Africa)',
},
'en@pirate': {
nativeName: 'English (Pirate)',
},
'eo': {
nativeName: 'Esperanto',
},
'eo-EO': {
nativeName: 'Esperanto',
},
'es': {
nativeName: 'Español',
},
'es-AR': {
nativeName: 'Español (Argentine)',
},
'es-419': {
nativeName: 'Español (Latinoamérica)',
},
'es-CL': {
nativeName: 'Español (Chile)',
},
'es-CO': {
nativeName: 'Español (Colombia)',
},
'es-EC': {
nativeName: 'Español (Ecuador)',
},
'es-ES': {
nativeName: 'Español (España)',
},
'es-LA': {
nativeName: 'Español (Latinoamérica)',
},
'es-NI': {
nativeName: 'Español (Nicaragua)',
},
'es-MX': {
nativeName: 'Español (México)',
},
'es-US': {
nativeName: 'Español (Estados Unidos)',
},
'es-VE': {
nativeName: 'Español (Venezuela)',
},
'et': {
nativeName: 'eesti keel',
},
'et-EE': {
nativeName: 'Eesti (Estonia)',
},
'eu': {
nativeName: 'Euskara',
},
'eu-ES': {
nativeName: 'Euskara',
},
'fa': {
nativeName: 'فارسی',
},
'fa-IR': {
nativeName: 'فارسی',
},
'fb-LT': {
nativeName: 'Leet Speak',
},
'ff': {
nativeName: 'Fulah',
},
'fi': {
nativeName: 'Suomi',
},
'fi-FI': {
nativeName: 'Suomi',
},
'fo': {
nativeName: 'Føroyskt',
},
'fo-FO': {
nativeName: 'Føroyskt (Færeyjar)',
},
'fr': {
nativeName: 'Français',
},
'fr-CA': {
nativeName: 'Français (Canada)',
},
'fr-FR': {
nativeName: 'Français (France)',
},
'fr-BE': {
nativeName: 'Français (Belgique)',
},
'fr-CH': {
nativeName: 'Français (Suisse)',
},
'fy-NL': {
nativeName: 'Frysk',
},
'ga': {
nativeName: 'Gaeilge',
},
'ga-IE': {
nativeName: 'Gaeilge',
},
'gd': {
nativeName: 'Gàidhlig',
},
'gl': {
nativeName: 'Galego',
},
'gl-ES': {
nativeName: 'Galego',
},
'gn-PY': {
nativeName: 'Avañe\'ẽ',
},
'gu-IN': {
nativeName: 'ગુજરાતી',
},
'gv': {
nativeName: 'Gaelg',
},
'gx-GR': {
nativeName: 'Ἑλληνική ἀρχαία',
},
'he': {
nativeName: 'עברית‏',
},
'he-IL': {
nativeName: 'עברית‏',
},
'hi': {
nativeName: 'हिन्दी',
},
'hi-IN': {
nativeName: 'हिन्दी',
},
'hr': {
nativeName: 'Hrvatski',
},
'hr-HR': {
nativeName: 'Hrvatski',
},
'hsb': {
nativeName: 'Hornjoserbšćina',
},
'ht': {
nativeName: 'Kreyòl',
},
'hu': {
nativeName: 'Magyar',
},
'hu-HU': {
nativeName: 'Magyar',
},
'hy': {
nativeName: 'Հայերեն',
},
'hy-AM': {
nativeName: 'Հայերեն (Հայաստան)',
},
'id': {
nativeName: 'Bahasa Indonesia',
},
'id-ID': {
nativeName: 'Bahasa Indonesia',
},
'is': {
nativeName: 'Íslenska',
},
'is-IS': {
nativeName: 'Íslenska (Iceland)',
},
'it': {
nativeName: 'Italiano',
},
'it-IT': {
nativeName: 'Italiano',
},
'ja': {
nativeName: '日本語',
},
'ja-JP': {
nativeName: '日本語 (日本)',
},
'jv-ID': {
nativeName: 'Basa Jawa',
},
'ka-GE': {
nativeName: 'ქართული',
},
'kk-KZ': {
nativeName: 'Қазақша',
},
'km': {
nativeName: 'ភាសាខ្មែរ',
},
'kl': {
nativeName: 'kalaallisut',
},
'km-KH': {
nativeName: 'ភាសាខ្មែរ',
},
'kab': {
nativeName: 'Taqbaylit',
},
'kn': {
nativeName: 'ಕನ್ನಡ',
},
'kn-IN': {
nativeName: 'ಕನ್ನಡ (India)',
},
'ko': {
nativeName: '한국어',
},
'ko-KR': {
nativeName: '한국어 (한국)',
},
'ku-TR': {
nativeName: 'Kurdî',
},
'kw': {
nativeName: 'Kernewek',
},
'la': {
nativeName: 'Latin',
},
'la-VA': {
nativeName: 'Latin',
},
'lb': {
nativeName: 'Lëtzebuergesch',
},
'li-NL': {
nativeName: 'Lèmbörgs',
},
'lt': {
nativeName: 'Lietuvių',
},
'lt-LT': {
nativeName: 'Lietuvių',
},
'lv': {
nativeName: 'Latviešu',
},
'lv-LV': {
nativeName: 'Latviešu',
},
'mai': {
nativeName: 'मैथिली, মৈথিলী',
},
'mg-MG': {
nativeName: 'Malagasy',
},
'mk': {
nativeName: 'Македонски',
},
'mk-MK': {
nativeName: 'Македонски (Македонски)',
},
'ml': {
nativeName: 'മലയാളം',
},
'ml-IN': {
nativeName: 'മലയാളം',
},
'mn-MN': {
nativeName: 'Монгол',
},
'mr': {
nativeName: 'मराठी',
},
'mr-IN': {
nativeName: 'मराठी',
},
'ms': {
nativeName: 'Bahasa Melayu',
},
'ms-MY': {
nativeName: 'Bahasa Melayu',
},
'mt': {
nativeName: 'Malti',
},
'mt-MT': {
nativeName: 'Malti',
},
'my': {
nativeName: 'ဗမာစကာ',
},
'no': {
nativeName: 'Norsk',
},
'nb': {
nativeName: 'Norsk (bokmål)',
},
'nb-NO': {
nativeName: 'Norsk (bokmål)',
},
'ne': {
nativeName: 'नेपाली',
},
'ne-NP': {
nativeName: 'नेपाली',
},
'nl': {
nativeName: 'Nederlands',
},
'nl-BE': {
nativeName: 'Nederlands (België)',
},
'nl-NL': {
nativeName: 'Nederlands (Nederland)',
},
'nn-NO': {
nativeName: 'Norsk (nynorsk)',
},
'oc': {
nativeName: 'Occitan',
},
'or-IN': {
nativeName: 'ଓଡ଼ିଆ',
},
'pa': {
nativeName: 'ਪੰਜਾਬੀ',
},
'pa-IN': {
nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)',
},
'pl': {
nativeName: 'Polski',
},
'pl-PL': {
nativeName: 'Polski',
},
'ps-AF': {
nativeName: 'پښتو',
},
'pt': {
nativeName: 'Português',
},
'pt-BR': {
nativeName: 'Português (Brasil)',
},
'pt-PT': {
nativeName: 'Português (Portugal)',
},
'qu-PE': {
nativeName: 'Qhichwa',
},
'rm-CH': {
nativeName: 'Rumantsch',
},
'ro': {
nativeName: 'Română',
},
'ro-RO': {
nativeName: 'Română',
},
'ru': {
nativeName: 'Русский',
},
'ru-RU': {
nativeName: 'Русский',
},
'sa-IN': {
nativeName: 'संस्कृतम्',
},
'se-NO': {
nativeName: 'Davvisámegiella',
},
'sh': {
nativeName: 'српскохрватски',
},
'si-LK': {
nativeName: 'සිංහල',
},
'sk': {
nativeName: 'Slovenčina',
},
'sk-SK': {
nativeName: 'Slovenčina (Slovakia)',
},
'sl': {
nativeName: 'Slovenščina',
},
'sl-SI': {
nativeName: 'Slovenščina',
},
'so-SO': {
nativeName: 'Soomaaliga',
},
'sq': {
nativeName: 'Shqip',
},
'sq-AL': {
nativeName: 'Shqip',
},
'sr': {
nativeName: 'Српски',
},
'sr-RS': {
nativeName: 'Српски (Serbia)',
},
'su': {
nativeName: 'Basa Sunda',
},
'sv': {
nativeName: 'Svenska',
},
'sv-SE': {
nativeName: 'Svenska',
},
'sw': {
nativeName: 'Kiswahili',
},
'sw-KE': {
nativeName: 'Kiswahili',
},
'ta': {
nativeName: 'தமிழ்',
},
'ta-IN': {
nativeName: 'தமிழ்',
},
'te': {
nativeName: 'తెలుగు',
},
'te-IN': {
nativeName: 'తెలుగు',
},
'tg': {
nativeName: 'забо́ни тоҷикӣ́',
},
'tg-TJ': {
nativeName: 'тоҷикӣ',
},
'th': {
nativeName: 'ภาษาไทย',
},
'th-TH': {
nativeName: 'ภาษาไทย (ประเทศไทย)',
},
'fil': {
nativeName: 'Filipino',
},
'tlh': {
nativeName: 'tlhIngan-Hol',
},
'tr': {
nativeName: 'Türkçe',
},
'tr-TR': {
nativeName: 'Türkçe',
},
'tt-RU': {
nativeName: 'татарча',
},
'uk': {
nativeName: 'Українська',
},
'uk-UA': {
nativeName: 'Українська',
},
'ur': {
nativeName: 'اردو',
},
'ur-PK': {
nativeName: 'اردو',
},
'uz': {
nativeName: 'O\'zbek',
},
'uz-UZ': {
nativeName: 'O\'zbek',
},
'vi': {
nativeName: 'Tiếng Việt',
},
'vi-VN': {
nativeName: 'Tiếng Việt',
},
'xh-ZA': {
nativeName: 'isiXhosa',
},
'yi': {
nativeName: 'ייִדיש',
},
'yi-DE': {
nativeName: 'ייִדיש (German)',
},
'zh': {
nativeName: '中文',
},
'zh-Hans': {
nativeName: '中文简体',
},
'zh-Hant': {
nativeName: '中文繁體',
},
'zh-CN': {
nativeName: '中文(中国大陆)',
},
'zh-HK': {
nativeName: '中文(香港)',
},
'zh-SG': {
nativeName: '中文(新加坡)',
},
'zh-TW': {
nativeName: '中文(台灣)',
},
'zu-ZA': {
nativeName: 'isiZulu',
},
};

View File

@ -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();
}

View File

@ -0,0 +1,36 @@
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.inputText({
title: i18n.ts.usernameOrUserId,
});
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.alert({
type: 'error',
text: i18n.ts.noSuchUser,
});
} else {
_notFound = true;
}
};
usernamePromise.then(show).catch(err => {
if (err.code === 'NO_SUCH_USER') {
notFound();
}
});
idPromise.then(show).catch(err => {
notFound();
});
}

View File

@ -0,0 +1,15 @@
import { query } from '@/scripts/url';
import { url } from '@/config';
export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string {
return `${url}/proxy/image.webp?${query({
url: imageUrl,
fallback: '1',
...(type ? { [type]: '1' } : {}),
})}`;
}
export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null {
if (imageUrl == null) return null;
return getProxiedImageUrl(imageUrl, type);
}

View File

@ -0,0 +1 @@
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle', 'rotate'];

View File

@ -0,0 +1,41 @@
import * as misskey from 'misskey-js';
import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue';
export const setPageMetadata = Symbol('setPageMetadata');
export const pageMetadataProvider = Symbol('pageMetadataProvider');
export type PageMetadata = {
title: string;
subtitle?: string;
icon?: string | null;
avatar?: misskey.entities.User | null;
userName?: misskey.entities.User | null;
bg?: string;
};
export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void {
const _metadata = isRef(metadata) ? metadata : ref(metadata);
provide(pageMetadataProvider, _metadata);
const set = inject(setPageMetadata) as any;
if (set) {
set(_metadata);
onMounted(() => {
set(_metadata);
});
onActivated(() => {
set(_metadata);
});
}
}
export function provideMetadataReceiver(callback: (info: ComputedRef<PageMetadata>) => void): void {
provide(setPageMetadata, callback);
}
export function injectPageMetadata(): PageMetadata | undefined {
return inject(pageMetadataProvider);
}

View File

@ -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) as HTMLElement[];
const objs: Matter.Body[] = [];
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: Matter.Body;
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.toString();
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 = window.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);
window.clearInterval(intervalId);
},
};
}

View File

@ -0,0 +1,21 @@
import { defineAsyncComponent } from 'vue';
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { popup } from '@/os';
export function pleaseLogin(path?: string) {
if ($i) return;
popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
autoSet: true,
message: i18n.ts.signinRequired,
}, {
cancelled: () => {
if (path) {
window.location.href = path;
}
},
}, 'closed');
if (!path) throw new Error('signin required');
}

View File

@ -0,0 +1,23 @@
import * as config from '@/config';
import { appendQuery } from './url';
export function popout(path: string, w?: HTMLElement) {
let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
url = appendQuery(url, 'zen');
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}`);
}
}

View File

@ -0,0 +1,158 @@
import { Ref } from 'vue';
export function calcPopupPosition(el: HTMLElement, props: {
anchorElement: HTMLElement | null;
innerMargin: number;
direction: 'top' | 'bottom' | 'left' | 'right';
align: 'top' | 'bottom' | 'left' | 'right' | 'center';
alignOffset?: number;
x?: number;
y?: number;
}): { top: number; left: number; transformOrigin: string; } {
const contentWidth = el.offsetWidth;
const contentHeight = el.offsetHeight;
let rect: DOMRect;
if (props.anchorElement) {
rect = props.anchorElement.getBoundingClientRect();
}
const calcPosWhenTop = () => {
let left: number;
let top: number;
if (props.anchorElement) {
left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin;
} else {
left = props.x;
top = (props.y - contentHeight) - props.innerMargin;
}
left -= (el.offsetWidth / 2);
if (left + contentWidth - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - contentWidth + window.pageXOffset - 1;
}
return [left, top];
};
const calcPosWhenBottom = () => {
let left: number;
let top: number;
if (props.anchorElement) {
left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2);
top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin;
} else {
left = props.x;
top = (props.y) + props.innerMargin;
}
left -= (el.offsetWidth / 2);
if (left + contentWidth - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - contentWidth + window.pageXOffset - 1;
}
return [left, top];
};
const calcPosWhenLeft = () => {
let left: number;
let top: number;
if (props.anchorElement) {
left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin;
top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
} else {
left = (props.x - contentWidth) - props.innerMargin;
top = props.y;
}
top -= (el.offsetHeight / 2);
if (top + contentHeight - window.pageYOffset > window.innerHeight) {
top = window.innerHeight - contentHeight + window.pageYOffset - 1;
}
return [left, top];
};
const calcPosWhenRight = () => {
let left: number;
let top: number;
if (props.anchorElement) {
left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin;
if (props.align === 'top') {
top = rect.top + window.pageYOffset;
if (props.alignOffset != null) top += props.alignOffset;
} else if (props.align === 'bottom') {
// TODO
} else { // center
top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2);
top -= (el.offsetHeight / 2);
}
} else {
left = props.x + props.innerMargin;
top = props.y;
top -= (el.offsetHeight / 2);
}
if (top + contentHeight - window.pageYOffset > window.innerHeight) {
top = window.innerHeight - contentHeight + window.pageYOffset - 1;
}
return [left, top];
};
const calc = (): {
left: number;
top: number;
transformOrigin: string;
} => {
switch (props.direction) {
case 'top': {
const [left, top] = calcPosWhenTop();
// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
if (top - window.pageYOffset < 0) {
const [left, top] = calcPosWhenBottom();
return { left, top, transformOrigin: 'center top' };
}
return { left, top, transformOrigin: 'center bottom' };
}
case 'bottom': {
const [left, top] = calcPosWhenBottom();
// TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
return { left, top, transformOrigin: 'center top' };
}
case 'left': {
const [left, top] = calcPosWhenLeft();
// ツールチップを左に向かって表示するスペースがなければ右に向かって出す
if (left - window.pageXOffset < 0) {
const [left, top] = calcPosWhenRight();
return { left, top, transformOrigin: 'left center' };
}
return { left, top, transformOrigin: 'right center' };
}
case 'right': {
const [left, top] = calcPosWhenRight();
// TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
return { left, top, transformOrigin: 'left center' };
}
}
};
return calc();
}

View File

@ -0,0 +1,41 @@
import { defineAsyncComponent, Ref, ref } from 'vue';
import { popup } from '@/os';
class ReactionPicker {
private src: Ref<HTMLElement | null> = ref(null);
private manualShowing = ref(false);
private onChosen?: (reaction: string) => void;
private onClosed?: () => void;
constructor() {
// nop
}
public async init() {
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.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: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) {
this.src.value = src;
this.manualShowing.value = true;
this.onChosen = onChosen;
this.onClosed = onClosed;
}
}
export const reactionPicker = new ReactionPicker();

View File

@ -0,0 +1,7 @@
export function safeURIDecode(str: string): string {
try {
return decodeURIComponent(str);
} catch {
return str;
}
}

View File

@ -0,0 +1,85 @@
type ScrollBehavior = 'auto' | 'smooth' | 'instant';
export function getScrollContainer(el: HTMLElement | null): HTMLElement | null {
if (el == null || el.tagName === 'HTML') return null;
const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y');
if (overflow === 'scroll' || overflow === '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 isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) {
if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance;
return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance;
}
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);
}

View File

@ -0,0 +1,63 @@
import * as os from '@/os';
import { i18n } from '@/i18n';
import { mainRouter } from '@/router';
export async function search() {
const { canceled, result: query } = await os.inputText({
title: i18n.ts.search,
});
if (canceled || query == null || query === '') return;
const q = query.trim();
if (q.startsWith('@') && !q.includes(' ')) {
mainRouter.push(`/${q}`);
return;
}
if (q.startsWith('#')) {
mainRouter.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.alert({
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.ts.fetchingAsApObject);
const res = await promise;
if (res.type === 'User') {
mainRouter.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === 'Note') {
mainRouter.push(`/notes/${res.object.id}`);
}
return;
}
mainRouter.push(`/search?q=${encodeURIComponent(q)}`);
}

View File

@ -0,0 +1,103 @@
import { ref } from 'vue';
import { DriveFile } from 'misskey-js/built/entities';
import * as os from '@/os';
import { stream } from '@/stream';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import { uploadFile } from '@/scripts/upload';
function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> {
return new Promise((res, rej) => {
const keepOriginal = ref(defaultStore.state.keepOriginalUploading);
const chooseFileFromPc = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = multiple;
input.onchange = () => {
const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value));
Promise.all(promises).then(driveFiles => {
res(multiple ? driveFiles : driveFiles[0]);
}).catch(err => {
// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
});
// 一応廃棄
(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.inputText({
title: i18n.ts.uploadFromUrl,
type: 'url',
placeholder: i18n.ts.uploadFromUrlDescription,
}).then(({ canceled, result: url }) => {
if (canceled) return;
const marker = Math.random().toString(); // TODO: UUIDとか使う
const connection = stream.useChannel('main');
connection.on('urlUploadFinished', urlResponse => {
if (urlResponse.marker === marker) {
res(multiple ? [urlResponse.file] : urlResponse.file);
connection.dispose();
}
});
os.api('drive/files/upload-from-url', {
url: url,
folderId: defaultStore.state.uploadFolder,
marker,
});
os.alert({
title: i18n.ts.uploadFromUrlRequested,
text: i18n.ts.uploadFromUrlMayTakeTime,
});
});
};
os.popupMenu([label ? {
text: label,
type: 'label',
} : undefined, {
type: 'switch',
text: i18n.ts.keepOriginalUploading,
ref: keepOriginal,
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: chooseFileFromPc,
}, {
text: i18n.ts.fromDrive,
icon: 'ti ti-cloud',
action: chooseFileFromDrive,
}, {
text: i18n.ts.fromUrl,
icon: 'ti ti-link',
action: chooseFileFromUrl,
}], src);
});
}
export function selectFile(src: any, label: string | null = null): Promise<DriveFile> {
return select(src, label, false) as Promise<DriveFile>;
}
export function selectFiles(src: any, label: string | null = null): Promise<DriveFile[]> {
return select(src, label, true) as Promise<DriveFile[]>;
}

View File

@ -0,0 +1,10 @@
import * as os from '@/os';
import { i18n } from '@/i18n';
export function showSuspendedDialog() {
return os.alert({
type: 'error',
title: i18n.ts.yourAccountSuspendedTitle,
text: i18n.ts.yourAccountSuspendedDescription,
});
}

View File

@ -0,0 +1,19 @@
/**
* 配列をシャッフル (破壊的)
*/
export function shuffle<T extends any[]>(array: T): T {
let currentIndex = array.length, randomIndex;
// While there remain elements to shuffle.
while (currentIndex !== 0) {
// Pick a remaining element.
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// And swap it with the current element.
[array[currentIndex], array[randomIndex]] = [
array[randomIndex], array[currentIndex]];
}
return array;
}

View File

@ -0,0 +1,66 @@
import { ColdDeviceStorage } from '@/store';
const cache = new Map<string, HTMLAudioElement>();
export const soundsTypes = [
null,
'syuilo/up',
'syuilo/down',
'syuilo/pope1',
'syuilo/pope2',
'syuilo/waon',
'syuilo/popo',
'syuilo/triple',
'syuilo/poi1',
'syuilo/poi2',
'syuilo/pirori',
'syuilo/pirori-wet',
'syuilo/pirori-square-wet',
'syuilo/square-pico',
'syuilo/reverved',
'syuilo/ryukyu',
'syuilo/kick',
'syuilo/snare',
'syuilo/queue-jammed',
'aisha/1',
'aisha/2',
'aisha/3',
'noizenecio/kick_gaba1',
'noizenecio/kick_gaba2',
'noizenecio/kick_gaba3',
'noizenecio/kick_gaba4',
'noizenecio/kick_gaba5',
'noizenecio/kick_gaba6',
'noizenecio/kick_gaba7',
] as const;
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();
}

View File

@ -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;
}
}

View File

@ -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;
};

View File

@ -0,0 +1,148 @@
import { ref } from 'vue';
import tinycolor from 'tinycolor2';
import { globalEvents } from '@/events';
export type Theme = {
id: string;
name: string;
author: string;
desc?: string;
base?: 'dark' | 'light';
props: Record<string, string>;
};
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
import { deepClone } from './clone';
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
export const getBuiltinThemes = () => Promise.all(
[
'l-light',
'l-coffee',
'l-apricot',
'l-rainy',
'l-vivid',
'l-cherry',
'l-sushi',
'l-u0',
'd-dark',
'd-persimmon',
'd-astro',
'd-future',
'd-botanical',
'd-green-lime',
'd-green-orange',
'd-cherry',
'd-ice',
'd-u0',
].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)),
);
export const getBuiltinThemesRef = () => {
const builtinThemes = ref<Theme[]>([]);
getBuiltinThemes().then(themes => builtinThemes.value = themes);
return builtinThemes;
};
let timeout = null;
export function applyTheme(theme: Theme, persist = true) {
if (timeout) window.clearTimeout(timeout);
document.documentElement.classList.add('_themeChanging_');
timeout = window.setTimeout(() => {
document.documentElement.classList.remove('_themeChanging_');
}, 1000);
const colorSchema = theme.base === 'dark' ? 'dark' : 'light';
// Deep copy
const _theme = deepClone(theme);
if (_theme.base) {
const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);
if (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['htmlThemeColor']);
break;
}
}
for (const [k, v] of Object.entries(props)) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
}
document.documentElement.style.setProperty('color-schema', colorSchema);
if (persist) {
localStorage.setItem('theme', JSON.stringify(props));
localStorage.setItem('colorSchema', colorSchema);
}
// 色計算など再度行えるようにクライアント全体に通知
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;
}

View File

@ -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]));
}

View File

@ -0,0 +1,49 @@
export const timezones = [{
name: 'UTC',
abbrev: 'UTC',
offset: 0,
}, {
name: 'Europe/Berlin',
abbrev: 'CET',
offset: 60,
}, {
name: 'Asia/Tokyo',
abbrev: 'JST',
offset: 540,
}, {
name: 'Asia/Seoul',
abbrev: 'KST',
offset: 540,
}, {
name: 'Asia/Shanghai',
abbrev: 'CST',
offset: 480,
}, {
name: 'Australia/Sydney',
abbrev: 'AEST',
offset: 600,
}, {
name: 'Australia/Darwin',
abbrev: 'ACST',
offset: 570,
}, {
name: 'Australia/Perth',
abbrev: 'AWST',
offset: 480,
}, {
name: 'America/New_York',
abbrev: 'EST',
offset: -300,
}, {
name: 'America/Mexico_City',
abbrev: 'CST',
offset: -360,
}, {
name: 'America/Phoenix',
abbrev: 'MST',
offset: -420,
}, {
name: 'America/Los_Angeles',
abbrev: 'PST',
offset: -480,
}];

View File

@ -0,0 +1,23 @@
const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
export let isTouchUsing = false;
export let isScreenTouching = false;
if (isTouchSupported) {
window.addEventListener('touchstart', () => {
// maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも
// タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする
isTouchUsing = true;
isScreenTouching = true;
}, { passive: true });
window.addEventListener('touchend', () => {
// 子要素のtouchstartイベントでstopPropagation()が呼ばれると親要素に伝搬されずタッチされたと判定されないため、
// touchendイベントでもtouchstartイベントと同様にtrueにする
isTouchUsing = true;
isScreenTouching = false;
}, { passive: true });
}

View File

@ -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();
}
}

View File

@ -0,0 +1,137 @@
import { reactive, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { readAndCompressImage } from 'browser-image-resizer';
import { getCompressionConfig } from './upload/compress-config';
import { defaultStore } from '@/store';
import { apiUrl } from '@/config';
import { $i } from '@/account';
import { alert } from '@/os';
import { i18n } from '@/i18n';
type Uploading = {
id: string;
name: string;
progressMax: number | undefined;
progressValue: number | undefined;
img: string;
};
export const uploads = ref<Uploading[]>([]);
const mimeTypeMap = {
'image/webp': 'webp',
'image/jpeg': 'jpg',
'image/png': 'png',
} as const;
export function uploadFile(
file: File,
folder?: any,
name?: string,
keepOriginal: boolean = defaultStore.state.keepOriginalUploading,
): Promise<Misskey.entities.DriveFile> {
if ($i == null) throw new Error('Not logged in');
if (folder && typeof folder === 'object') folder = folder.id;
return new Promise((resolve, reject) => {
const id = Math.random().toString();
const reader = new FileReader();
reader.onload = async (): Promise<void> => {
const ctx = reactive<Uploading>({
id: id,
name: name ?? file.name ?? 'untitled',
progressMax: undefined,
progressValue: undefined,
img: window.URL.createObjectURL(file),
});
uploads.value.push(ctx);
const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
let resizedImage: Blob | undefined;
if (config) {
try {
const resized = await readAndCompressImage(file, config);
if (resized.size < file.size || file.type === 'image/webp') {
// The compression may not always reduce the file size
// (and WebP is not browser safe yet)
resizedImage = resized;
}
if (_DEV_) {
const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
}
ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
} catch (err) {
console.error('Failed to resize image', err);
}
}
const formData = new FormData();
formData.append('i', $i.token);
formData.append('force', 'true');
formData.append('file', resizedImage ?? file);
formData.append('name', ctx.name);
if (folder) formData.append('folderId', folder);
const xhr = new XMLHttpRequest();
xhr.open('POST', apiUrl + '/drive/files/create', true);
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
uploads.value = uploads.value.filter(x => x.id !== id);
if (ev.target?.response) {
const res = JSON.parse(ev.target.response);
if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseInappropriate,
});
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseNoFreeSpace,
});
} else {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
});
}
} else {
alert({
type: 'error',
title: 'Failed to upload',
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
});
}
reject();
return;
}
const driveFile = JSON.parse(ev.target.response);
resolve(driveFile);
uploads.value = uploads.value.filter(x => x.id !== id);
}) as (ev: ProgressEvent<EventTarget>) => any;
xhr.upload.onprogress = ev => {
if (ev.lengthComputable) {
ctx.progressMax = ev.total;
ctx.progressValue = ev.loaded;
}
};
xhr.send(formData);
};
reader.readAsArrayBuffer(file);
});
}

View File

@ -0,0 +1,23 @@
import isAnimated from 'is-file-animated';
import type { BrowserImageResizerConfig } from 'browser-image-resizer';
const compressTypeMap = {
'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
'image/png': { quality: 1, mimeType: 'image/png' },
'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
'image/svg+xml': { quality: 1, mimeType: 'image/png' },
} as const;
export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfig | undefined> {
const imgConfig = compressTypeMap[file.type];
if (!imgConfig || await isAnimated(file)) {
return;
}
return {
maxWidth: 2048,
maxHeight: 2048,
debug: true,
...imgConfig,
};
}

View File

@ -0,0 +1,13 @@
export function query(obj: Record<string, any>): 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((p) => `${p[0]}=${encodeURIComponent(p[1])}`)
.join('&');
}
export function appendQuery(url: string, query: string): string {
return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
}

View File

@ -0,0 +1,54 @@
import { onUnmounted, ref } from 'vue';
import * as os from '@/os';
import MkChartTooltip from '@/components/MkChartTooltip.vue';
export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) {
const tooltipShowing = ref(false);
const tooltipX = ref(0);
const tooltipY = ref(0);
const tooltipTitle = ref(null);
const tooltipSeries = ref(null);
let disposeTooltipComponent;
os.popup(MkChartTooltip, {
showing: tooltipShowing,
x: tooltipX,
y: tooltipY,
title: tooltipTitle,
series: tooltipSeries,
}, {}).then(({ dispose }) => {
disposeTooltipComponent = dispose;
});
onUnmounted(() => {
if (disposeTooltipComponent) disposeTooltipComponent();
});
function handler(context) {
if (context.tooltip.opacity === 0) {
tooltipShowing.value = false;
return;
}
tooltipTitle.value = context.tooltip.title[0];
tooltipSeries.value = context.tooltip.body.map((b, i) => ({
backgroundColor: context.tooltip.labelColors[i].backgroundColor,
borderColor: context.tooltip.labelColors[i].borderColor,
text: b.lines[0],
}));
const rect = context.chart.canvas.getBoundingClientRect();
tooltipShowing.value = true;
tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX;
if (opts.position === 'top') {
tooltipY.value = rect.top + window.pageYOffset;
} else if (opts.position === 'middle') {
tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY;
}
}
return {
handler,
};
}

View File

@ -0,0 +1,24 @@
import { onMounted, onUnmounted } from 'vue';
export function useInterval(fn: () => void, interval: number, options: {
immediate: boolean;
afterMounted: boolean;
}): void {
if (Number.isNaN(interval)) return;
let intervalId: number | null = null;
if (options.afterMounted) {
onMounted(() => {
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
});
} else {
if (options.immediate) fn();
intervalId = window.setInterval(fn, interval);
}
onUnmounted(() => {
if (intervalId) window.clearInterval(intervalId);
});
}

View File

@ -0,0 +1,47 @@
import { inject, onUnmounted, Ref } from 'vue';
import { i18n } from '@/i18n';
import * as os from '@/os';
export function useLeaveGuard(enabled: Ref<boolean>) {
/* TODO
const setLeaveGuard = inject('setLeaveGuard');
if (setLeaveGuard) {
setLeaveGuard(async () => {
if (!enabled.value) return false;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.leaveConfirm,
});
return canceled;
});
} else {
onBeforeRouteLeave(async (to, from) => {
if (!enabled.value) return true;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.leaveConfirm,
});
return !canceled;
});
}
*/
/*
function onBeforeLeave(ev: BeforeUnloadEvent) {
if (enabled.value) {
ev.preventDefault();
ev.returnValue = '';
}
}
window.addEventListener('beforeunload', onBeforeLeave);
onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeLeave);
});
*/
}

View File

@ -0,0 +1,110 @@
import { onUnmounted, Ref } from 'vue';
import * as misskey from 'misskey-js';
import { stream } from '@/stream';
import { $i } from '@/account';
export function useNoteCapture(props: {
rootEl: Ref<HTMLElement>;
note: Ref<misskey.entities.Note>;
isDeletedRef: Ref<boolean>;
}) {
const note = props.note;
const connection = $i ? stream : null;
function onStreamNoteUpdated(noteData): void {
const { type, id, body } = noteData;
if (id !== note.value.id) return;
switch (type) {
case 'reacted': {
const reaction = body.reaction;
if (body.emoji) {
const emojis = note.value.emojis || [];
if (!emojis.includes(body.emoji)) {
note.value.emojis = [...emojis, body.emoji];
}
}
// TODO: reactionsプロパティがない場合ってあったっけ なければ || {} は消せる
const currentCount = (note.value.reactions || {})[reaction] || 0;
note.value.reactions[reaction] = currentCount + 1;
if ($i && (body.userId === $i.id)) {
note.value.myReaction = reaction;
}
break;
}
case 'unreacted': {
const reaction = body.reaction;
// TODO: reactionsプロパティがない場合ってあったっけ なければ || {} は消せる
const currentCount = (note.value.reactions || {})[reaction] || 0;
note.value.reactions[reaction] = Math.max(0, currentCount - 1);
if ($i && (body.userId === $i.id)) {
note.value.myReaction = null;
}
break;
}
case 'pollVoted': {
const choice = body.choice;
const choices = [...note.value.poll.choices];
choices[choice] = {
...choices[choice],
votes: choices[choice].votes + 1,
...($i && (body.userId === $i.id) ? {
isVoted: true,
} : {}),
};
note.value.poll.choices = choices;
break;
}
case 'deleted': {
props.isDeletedRef.value = true;
break;
}
}
}
function capture(withHandler = false): void {
if (connection) {
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id });
if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated);
}
}
function decapture(withHandler = false): void {
if (connection) {
connection.send('un', {
id: note.value.id,
});
if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated);
}
}
function onStreamConnected() {
capture(false);
}
capture(true);
if (connection) {
connection.on('_connected_', onStreamConnected);
}
onUnmounted(() => {
decapture(true);
if (connection) {
connection.off('_connected_', onStreamConnected);
}
});
}

View File

@ -0,0 +1,86 @@
import { Ref, ref, watch, onUnmounted } from 'vue';
export function useTooltip(
elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>,
onShow: (showing: Ref<boolean>) => void,
delay = 300,
): void {
let isHovering = false;
// iOS(Androidも)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ
// 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる
// TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...
let shouldIgnoreMouseover = false;
let timeoutId: number;
let changeShowingState: (() => void) | null;
const open = () => {
close();
if (!isHovering) return;
if (elRef.value == null) return;
const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため
const showing = ref(true);
onShow(showing);
changeShowingState = () => {
showing.value = false;
};
};
const close = () => {
if (changeShowingState != null) {
changeShowingState();
changeShowingState = null;
}
};
const onMouseover = () => {
if (isHovering) return;
if (shouldIgnoreMouseover) return;
isHovering = true;
timeoutId = window.setTimeout(open, delay);
};
const onMouseleave = () => {
if (!isHovering) return;
isHovering = false;
window.clearTimeout(timeoutId);
close();
};
const onTouchstart = () => {
shouldIgnoreMouseover = true;
if (isHovering) return;
isHovering = true;
timeoutId = window.setTimeout(open, delay);
};
const onTouchend = () => {
if (!isHovering) return;
isHovering = false;
window.clearTimeout(timeoutId);
close();
};
const stop = watch(elRef, () => {
if (elRef.value) {
stop();
const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el;
el.addEventListener('mouseover', onMouseover, { passive: true });
el.addEventListener('mouseleave', onMouseleave, { passive: true });
el.addEventListener('touchstart', onTouchstart, { passive: true });
el.addEventListener('touchend', onTouchend, { passive: true });
el.addEventListener('click', close, { passive: true });
}
}, {
immediate: true,
flush: 'post',
});
onUnmounted(() => {
close();
});
}