v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com> Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
This commit is contained in:
5
src/client/scripts/2fa.ts
Normal file
5
src/client/scripts/2fa.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export function hexifyAB(buffer) {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map(item => item.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
267
src/client/scripts/aiscript/evaluator.ts
Normal file
267
src/client/scripts/aiscript/evaluator.ts
Normal file
@ -0,0 +1,267 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import * as seedrandom from 'seedrandom';
|
||||
import { Variable, PageVar, envVarsDef, funcDefs, Block, isFnBlock } from '.';
|
||||
import { version } from '../../config';
|
||||
|
||||
type Fn = {
|
||||
slots: string[];
|
||||
exec: (args: Record<string, any>) => ReturnType<ASEvaluator['evaluate']>;
|
||||
};
|
||||
|
||||
/**
|
||||
* AiScript evaluator
|
||||
*/
|
||||
export class ASEvaluator {
|
||||
private variables: Variable[];
|
||||
private pageVars: PageVar[];
|
||||
private envVars: Record<keyof typeof envVarsDef, any>;
|
||||
|
||||
private opts: {
|
||||
randomSeed: string; user?: any; visitor?: any; page?: any; url?: string;
|
||||
};
|
||||
|
||||
constructor(variables: Variable[], pageVars: PageVar[], opts: ASEvaluator['opts']) {
|
||||
this.variables = variables;
|
||||
this.pageVars = pageVars;
|
||||
this.opts = opts;
|
||||
|
||||
const date = new Date();
|
||||
|
||||
this.envVars = {
|
||||
AI: 'kawaii',
|
||||
VERSION: version,
|
||||
URL: opts.page ? `${opts.url}/@${opts.page.user.username}/pages/${opts.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,
|
||||
MY_NOTES_COUNT: opts.user ? opts.user.notesCount : 0,
|
||||
MY_FOLLOWERS_COUNT: opts.user ? opts.user.followersCount : 0,
|
||||
MY_FOLLOWING_COUNT: opts.user ? opts.user.followingCount : 0,
|
||||
SEED: opts.randomSeed ? opts.randomSeed : '',
|
||||
YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`,
|
||||
NULL: null
|
||||
};
|
||||
}
|
||||
|
||||
@autobind
|
||||
public updatePageVar(name: string, value: any) {
|
||||
const pageVar = this.pageVars.find(v => v.name === name);
|
||||
if (pageVar !== undefined) {
|
||||
pageVar.value = value;
|
||||
} else {
|
||||
throw new AiScriptError(`No such page var '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public updateRandomSeed(seed: string) {
|
||||
this.opts.randomSeed = seed;
|
||||
this.envVars.SEED = seed;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private interpolate(str: string, scope: Scope) {
|
||||
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 Scope([values]));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
@autobind
|
||||
private evaluate(block: Block, scope: Scope): any {
|
||||
if (block.type === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (block.type === 'number') {
|
||||
return parseInt(block.value, 10);
|
||||
}
|
||||
|
||||
if (block.type === 'text' || block.type === 'multiLineText') {
|
||||
return this.interpolate(block.value || '', scope);
|
||||
}
|
||||
|
||||
if (block.type === 'textList') {
|
||||
return this.interpolate(block.value || '', scope).trim().split('\n');
|
||||
}
|
||||
|
||||
if (block.type === 'ref') {
|
||||
return scope.getState(block.value);
|
||||
}
|
||||
|
||||
if (isFnBlock(block)) { // ユーザー関数定義
|
||||
return {
|
||||
slots: block.value.slots.map(x => x.name),
|
||||
exec: (slotArg: Record<string, any>) => {
|
||||
return this.evaluate(block.value.expression, scope.createChildScope(slotArg, block.id));
|
||||
}
|
||||
} as Fn;
|
||||
}
|
||||
|
||||
if (block.type.startsWith('fn:')) { // ユーザー関数呼び出し
|
||||
const fnName = block.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(block.args[i], scope);
|
||||
}
|
||||
return fn.exec(args);
|
||||
}
|
||||
|
||||
if (block.args === undefined) return null;
|
||||
|
||||
const date = new Date();
|
||||
const day = `${this.opts.visitor ? this.opts.visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
|
||||
|
||||
const funcs: { [p in keyof typeof funcDefs]: 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 = [];
|
||||
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(`${this.opts.randomSeed}:${block.id}`)() * 100) < probability,
|
||||
rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * (max - min + 1)),
|
||||
randomPick: (list: any[]) => list[Math.floor(seedrandom(`${this.opts.randomSeed}:${block.id}`)() * list.length)],
|
||||
dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${block.id}`)() * 100) < probability,
|
||||
dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${block.id}`)() * (max - min + 1)),
|
||||
dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${block.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 = [];
|
||||
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}:${block.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;
|
||||
},
|
||||
};
|
||||
|
||||
const fnName = block.type;
|
||||
const fn = (funcs as any)[fnName];
|
||||
if (fn == null) {
|
||||
throw new AiScriptError(`No such function '${fnName}'`);
|
||||
} else {
|
||||
return fn(...block.args.map(x => this.evaluate(x, scope)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AiScriptError 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, AiScriptError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Scope {
|
||||
private layerdStates: Record<string, any>[];
|
||||
public name: string;
|
||||
|
||||
constructor(layerdStates: Scope['layerdStates'], name?: Scope['name']) {
|
||||
this.layerdStates = layerdStates;
|
||||
this.name = name || 'anonymous';
|
||||
}
|
||||
|
||||
@autobind
|
||||
public createChildScope(states: Record<string, any>, name?: Scope['name']): Scope {
|
||||
const layer = [states, ...this.layerdStates];
|
||||
return new Scope(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 AiScriptError(
|
||||
`No such variable '${name}' in scope '${this.name}'`, {
|
||||
scope: this.layerdStates
|
||||
});
|
||||
}
|
||||
}
|
140
src/client/scripts/aiscript/index.ts
Normal file
140
src/client/scripts/aiscript/index.ts
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* AiScript
|
||||
*/
|
||||
|
||||
import {
|
||||
faMagic,
|
||||
faSquareRootAlt,
|
||||
faAlignLeft,
|
||||
faShareAlt,
|
||||
faPlus,
|
||||
faMinus,
|
||||
faTimes,
|
||||
faDivide,
|
||||
faList,
|
||||
faQuoteRight,
|
||||
faEquals,
|
||||
faGreaterThan,
|
||||
faLessThan,
|
||||
faGreaterThanEqual,
|
||||
faLessThanEqual,
|
||||
faNotEqual,
|
||||
faDice,
|
||||
faSortNumericUp,
|
||||
faExchangeAlt,
|
||||
faRecycle,
|
||||
faIndent,
|
||||
faCalculator,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { faFlag } from '@fortawesome/free-regular-svg-icons';
|
||||
|
||||
export type Block<V = any> = {
|
||||
id: string;
|
||||
type: string;
|
||||
args: Block[];
|
||||
value: V;
|
||||
};
|
||||
|
||||
export type FnBlock = Block<{
|
||||
slots: {
|
||||
name: string;
|
||||
type: Type;
|
||||
}[];
|
||||
expression: Block;
|
||||
}>;
|
||||
|
||||
export type Variable = Block & {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null;
|
||||
|
||||
export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = {
|
||||
if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: faShareAlt, },
|
||||
for: { in: ['number', 'function'], out: null, category: 'flow', icon: faRecycle, },
|
||||
not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: faFlag, },
|
||||
or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, },
|
||||
and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: faFlag, },
|
||||
add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faPlus, },
|
||||
subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faMinus, },
|
||||
multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faTimes, },
|
||||
divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faDivide, },
|
||||
mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: faDivide, },
|
||||
round: { in: ['number'], out: 'number', category: 'operation', icon: faCalculator, },
|
||||
eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faEquals, },
|
||||
notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: faNotEqual, },
|
||||
gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThan, },
|
||||
lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThan, },
|
||||
gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faGreaterThanEqual, },
|
||||
ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: faLessThanEqual, },
|
||||
strLen: { in: ['string'], out: 'number', category: 'text', icon: faQuoteRight, },
|
||||
strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: faQuoteRight, },
|
||||
strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: faQuoteRight, },
|
||||
strReverse: { in: ['string'], out: 'string', category: 'text', icon: faQuoteRight, },
|
||||
join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: faQuoteRight, },
|
||||
stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: faExchangeAlt, },
|
||||
numberToString: { in: ['number'], out: 'string', category: 'convert', icon: faExchangeAlt, },
|
||||
splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: faExchangeAlt, },
|
||||
pick: { in: [null, 'number'], out: null, category: 'list', icon: faIndent, },
|
||||
listLen: { in: [null], out: 'number', category: 'list', icon: faIndent, },
|
||||
rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, },
|
||||
dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: faDice, },
|
||||
seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: faDice, },
|
||||
random: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, },
|
||||
dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: faDice, },
|
||||
seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: faDice, },
|
||||
randomPick: { in: [0], out: 0, category: 'random', icon: faDice, },
|
||||
dailyRandomPick: { in: [0], out: 0, category: 'random', icon: faDice, },
|
||||
seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: faDice, },
|
||||
DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: faDice, }, // dailyRandomPickWithProbabilityMapping
|
||||
};
|
||||
|
||||
export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = {
|
||||
text: { out: 'string', category: 'value', icon: faQuoteRight, },
|
||||
multiLineText: { out: 'string', category: 'value', icon: faAlignLeft, },
|
||||
textList: { out: 'stringArray', category: 'value', icon: faList, },
|
||||
number: { out: 'number', category: 'value', icon: faSortNumericUp, },
|
||||
ref: { out: null, category: 'value', icon: faMagic, },
|
||||
fn: { out: 'function', category: 'value', icon: faSquareRootAlt, },
|
||||
};
|
||||
|
||||
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 function isFnBlock(block: Block): block is FnBlock {
|
||||
return block.type === 'fn';
|
||||
}
|
||||
|
||||
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',
|
||||
MY_NOTES_COUNT: 'number',
|
||||
MY_FOLLOWERS_COUNT: 'number',
|
||||
MY_FOLLOWING_COUNT: 'number',
|
||||
SEED: null,
|
||||
YMD: 'string',
|
||||
NULL: null,
|
||||
};
|
||||
|
||||
export function isLiteralBlock(v: Block) {
|
||||
if (v.type === null) return true;
|
||||
if (literalDefs[v.type]) return true;
|
||||
return false;
|
||||
}
|
186
src/client/scripts/aiscript/type-checker.ts
Normal file
186
src/client/scripts/aiscript/type-checker.ts
Normal file
@ -0,0 +1,186 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import { Type, Block, funcDefs, envVarsDef, Variable, PageVar, isLiteralBlock } from '.';
|
||||
|
||||
type TypeError = {
|
||||
arg: number;
|
||||
expect: Type;
|
||||
actual: Type;
|
||||
};
|
||||
|
||||
/**
|
||||
* AiScript type checker
|
||||
*/
|
||||
export class ASTypeChecker {
|
||||
public variables: Variable[];
|
||||
public pageVars: PageVar[];
|
||||
|
||||
constructor(variables: ASTypeChecker['variables'] = [], pageVars: ASTypeChecker['pageVars'] = []) {
|
||||
this.variables = variables;
|
||||
this.pageVars = pageVars;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public typeCheck(v: Block): TypeError | null {
|
||||
if (isLiteralBlock(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: Block, 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: Block): 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 === '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;
|
||||
}
|
||||
}
|
48
src/client/scripts/collect-page-vars.ts
Normal file
48
src/client/scripts/collect-page-vars.ts
Normal file
@ -0,0 +1,48 @@
|
||||
export function collectPageVars(content) {
|
||||
const pageVars = [];
|
||||
const collect = (xs: any[]) => {
|
||||
for (const x of xs) {
|
||||
if (x.type === 'textInput') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'string',
|
||||
value: x.default || ''
|
||||
});
|
||||
} else if (x.type === 'textareaInput') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'string',
|
||||
value: x.default || ''
|
||||
});
|
||||
} else if (x.type === 'numberInput') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'number',
|
||||
value: x.default || 0
|
||||
});
|
||||
} else if (x.type === 'switch') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'boolean',
|
||||
value: x.default || false
|
||||
});
|
||||
} else if (x.type === 'counter') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'number',
|
||||
value: 0
|
||||
});
|
||||
} else if (x.type === 'radioButton') {
|
||||
pageVars.push({
|
||||
name: x.name,
|
||||
type: 'string',
|
||||
value: x.default || ''
|
||||
});
|
||||
} else if (x.children) {
|
||||
collect(x.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(content);
|
||||
return pageVars;
|
||||
}
|
59
src/client/scripts/compose-notification.ts
Normal file
59
src/client/scripts/compose-notification.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import getNoteSummary from '../../misc/get-note-summary';
|
||||
import getUserName from '../../misc/get-user-name';
|
||||
|
||||
type Notification = {
|
||||
title: string;
|
||||
body: string;
|
||||
icon: string;
|
||||
onclick?: any;
|
||||
};
|
||||
|
||||
// TODO: i18n
|
||||
|
||||
export default function(type, data): Notification {
|
||||
switch (type) {
|
||||
case 'driveFileCreated':
|
||||
return {
|
||||
title: 'File uploaded',
|
||||
body: data.name,
|
||||
icon: data.url
|
||||
};
|
||||
|
||||
case 'notification':
|
||||
switch (data.type) {
|
||||
case 'mention':
|
||||
return {
|
||||
title: `${getUserName(data.user)}:`,
|
||||
body: getNoteSummary(data),
|
||||
icon: data.user.avatarUrl
|
||||
};
|
||||
|
||||
case 'reply':
|
||||
return {
|
||||
title: `You got reply from ${getUserName(data.user)}:`,
|
||||
body: getNoteSummary(data),
|
||||
icon: data.user.avatarUrl
|
||||
};
|
||||
|
||||
case 'quote':
|
||||
return {
|
||||
title: `${getUserName(data.user)}:`,
|
||||
body: getNoteSummary(data),
|
||||
icon: data.user.avatarUrl
|
||||
};
|
||||
|
||||
case 'reaction':
|
||||
return {
|
||||
title: `${getUserName(data.user)}: ${data.reaction}:`,
|
||||
body: getNoteSummary(data.note),
|
||||
icon: data.user.avatarUrl
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
9
src/client/scripts/contains.ts
Normal file
9
src/client/scripts/contains.ts
Normal 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;
|
||||
};
|
33
src/client/scripts/copy-to-clipboard.ts
Normal file
33
src/client/scripts/copy-to-clipboard.ts
Normal 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;
|
||||
};
|
31
src/client/scripts/gen-search-query.ts
Normal file
31
src/client/scripts/gen-search-query.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import parseAcct from '../../misc/acct/parse';
|
||||
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.$root.api('users/show', parseAcct(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
|
||||
};
|
||||
}
|
8
src/client/scripts/get-instance-name.ts
Normal file
8
src/client/scripts/get-instance-name.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export function getInstanceName() {
|
||||
const siteName = document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement;
|
||||
if (siteName && siteName.content) {
|
||||
return siteName.content;
|
||||
}
|
||||
|
||||
return 'Misskey';
|
||||
}
|
10
src/client/scripts/get-md5.ts
Normal file
10
src/client/scripts/get-md5.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// スクリプトサイズがデカい
|
||||
//import * as crypto from 'crypto';
|
||||
|
||||
export default (data: ArrayBuffer) => {
|
||||
//const buf = new Buffer(data);
|
||||
//const hash = crypto.createHash('md5');
|
||||
//hash.update(buf);
|
||||
//return hash.digest('hex');
|
||||
return '';
|
||||
};
|
11
src/client/scripts/get-static-image-url.ts
Normal file
11
src/client/scripts/get-static-image-url.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { url as instanceUrl } from '../config';
|
||||
import * as url from '../../prelude/url';
|
||||
|
||||
export function getStaticImageUrl(baseUrl: string): string {
|
||||
const u = new URL(baseUrl);
|
||||
const dummy = `${u.host}${u.pathname}`; // 拡張子がないとキャッシュしてくれないCDNがあるので
|
||||
return `${instanceUrl}/proxy/${dummy}?${url.query({
|
||||
url: u.href,
|
||||
static: '1'
|
||||
})}`;
|
||||
}
|
106
src/client/scripts/hotkey.ts
Normal file
106
src/client/scripts/hotkey.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import keyCode from './keycode';
|
||||
import { concat } from '../../prelude/array';
|
||||
|
||||
type pattern = {
|
||||
which: string[];
|
||||
ctrl?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
};
|
||||
|
||||
type action = {
|
||||
patterns: pattern[];
|
||||
|
||||
callback: Function;
|
||||
};
|
||||
|
||||
const getKeyMap = keymap => Object.entries(keymap).map(([patterns, callback]): action => {
|
||||
const result = {
|
||||
patterns: [],
|
||||
callback: callback
|
||||
} as action;
|
||||
|
||||
result.patterns = patterns.split('|').map(part => {
|
||||
const pattern = {
|
||||
which: [],
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
} as pattern;
|
||||
|
||||
const keys = part.trim().split('+').map(x => x.trim().toLowerCase());
|
||||
for (const key of keys) {
|
||||
switch (key) {
|
||||
case 'ctrl': pattern.ctrl = true; break;
|
||||
case 'alt': pattern.alt = true; break;
|
||||
case 'shift': pattern.shift = true; break;
|
||||
default: pattern.which = keyCode(key).map(k => k.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return pattern;
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const ignoreElemens = ['input', 'textarea'];
|
||||
|
||||
function match(e: KeyboardEvent, patterns: action['patterns']): boolean {
|
||||
const key = e.code.toLowerCase();
|
||||
return patterns.some(pattern => pattern.which.includes(key) &&
|
||||
pattern.ctrl == e.ctrlKey &&
|
||||
pattern.shift == e.shiftKey &&
|
||||
pattern.alt == e.altKey &&
|
||||
!e.metaKey
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
install(Vue) {
|
||||
Vue.directive('hotkey', {
|
||||
bind(el, binding) {
|
||||
el._hotkey_global = binding.modifiers.global === true;
|
||||
|
||||
const actions = getKeyMap(binding.value);
|
||||
|
||||
// flatten
|
||||
const reservedKeys = concat(actions.map(a => a.patterns));
|
||||
|
||||
el._misskey_reservedKeys = reservedKeys;
|
||||
|
||||
el._keyHandler = (e: KeyboardEvent) => {
|
||||
const targetReservedKeys = document.activeElement ? ((document.activeElement as any)._misskey_reservedKeys || []) : [];
|
||||
if (document.activeElement && ignoreElemens.some(el => document.activeElement.matches(el))) return;
|
||||
|
||||
for (const action of actions) {
|
||||
const matched = match(e, action.patterns);
|
||||
|
||||
if (matched) {
|
||||
if (el._hotkey_global && match(e, targetReservedKeys)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
action.callback(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (el._hotkey_global) {
|
||||
document.addEventListener('keydown', el._keyHandler);
|
||||
} else {
|
||||
el.addEventListener('keydown', el._keyHandler);
|
||||
}
|
||||
},
|
||||
|
||||
unbind(el) {
|
||||
if (el._hotkey_global) {
|
||||
document.removeEventListener('keydown', el._keyHandler);
|
||||
} else {
|
||||
el.removeEventListener('keydown', el._keyHandler);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
33
src/client/scripts/keycode.ts
Normal file
33
src/client/scripts/keycode.ts
Normal 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'],
|
||||
};
|
||||
|
||||
/*!
|
||||
* Programatically add the following
|
||||
*/
|
||||
|
||||
// lower case chars
|
||||
for (let i = 97; i < 123; i++) {
|
||||
const char = String.fromCharCode(i);
|
||||
aliases[char] = `Key${char.toUpperCase()}`;
|
||||
}
|
||||
|
||||
// numbers
|
||||
for (let i = 0; i < 10; i++) {
|
||||
aliases[i] = [`Numpad${i}`, `Digit${i}`];
|
||||
}
|
21
src/client/scripts/loading.ts
Normal file
21
src/client/scripts/loading.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import * as NProgress from 'nprogress';
|
||||
NProgress.configure({
|
||||
trickleSpeed: 500,
|
||||
showSpinner: false
|
||||
});
|
||||
|
||||
const root = document.getElementsByTagName('html')[0];
|
||||
|
||||
export default {
|
||||
start: () => {
|
||||
root.classList.add('progress');
|
||||
NProgress.start();
|
||||
},
|
||||
done: () => {
|
||||
root.classList.remove('progress');
|
||||
NProgress.done();
|
||||
},
|
||||
set: val => {
|
||||
NProgress.set(val);
|
||||
}
|
||||
};
|
134
src/client/scripts/paging.ts
Normal file
134
src/client/scripts/paging.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import Vue from 'vue';
|
||||
|
||||
export default (opts) => ({
|
||||
data() {
|
||||
return {
|
||||
items: [],
|
||||
offset: 0,
|
||||
fetching: true,
|
||||
moreFetching: false,
|
||||
inited: false,
|
||||
more: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
empty(): boolean {
|
||||
return this.items.length == 0 && !this.fetching && this.inited;
|
||||
},
|
||||
|
||||
error(): boolean {
|
||||
return !this.fetching && !this.inited;
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
pagination() {
|
||||
this.init();
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
opts.displayLimit = opts.displayLimit || 30;
|
||||
this.init();
|
||||
},
|
||||
|
||||
methods: {
|
||||
isScrollTop() {
|
||||
return window.scrollY <= 8;
|
||||
},
|
||||
|
||||
updateItem(i, item) {
|
||||
Vue.set((this as any).items, i, item);
|
||||
},
|
||||
|
||||
reload() {
|
||||
this.items = [];
|
||||
this.init();
|
||||
},
|
||||
|
||||
async init() {
|
||||
this.fetching = true;
|
||||
if (opts.before) opts.before(this);
|
||||
let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params;
|
||||
if (params && params.then) params = await params;
|
||||
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
|
||||
await this.$root.api(endpoint, {
|
||||
limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
|
||||
...params
|
||||
}).then(x => {
|
||||
if (!this.pagination.noPaging && (x.length === (this.pagination.limit || 10) + 1)) {
|
||||
x.pop();
|
||||
this.items = x;
|
||||
this.more = true;
|
||||
} else {
|
||||
this.items = x;
|
||||
this.more = false;
|
||||
}
|
||||
this.offset = x.length;
|
||||
this.inited = true;
|
||||
this.fetching = false;
|
||||
if (opts.after) opts.after(this, null);
|
||||
}, e => {
|
||||
this.fetching = false;
|
||||
if (opts.after) opts.after(this, e);
|
||||
});
|
||||
},
|
||||
|
||||
async fetchMore() {
|
||||
if (!this.more || this.moreFetching || this.items.length === 0) return;
|
||||
this.moreFetching = true;
|
||||
let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
|
||||
if (params && params.then) params = await params;
|
||||
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
|
||||
await this.$root.api(endpoint, {
|
||||
limit: (this.pagination.limit || 10) + 1,
|
||||
...(this.pagination.offsetMode ? {
|
||||
offset: this.offset,
|
||||
} : {
|
||||
untilId: this.items[this.items.length - 1].id,
|
||||
}),
|
||||
...params
|
||||
}).then(x => {
|
||||
if (x.length === (this.pagination.limit || 10) + 1) {
|
||||
x.pop();
|
||||
this.items = this.items.concat(x);
|
||||
this.more = true;
|
||||
} else {
|
||||
this.items = this.items.concat(x);
|
||||
this.more = false;
|
||||
}
|
||||
this.offset += x.length;
|
||||
this.moreFetching = false;
|
||||
}, e => {
|
||||
this.moreFetching = false;
|
||||
});
|
||||
},
|
||||
|
||||
prepend(item, silent = false) {
|
||||
if (opts.onPrepend) {
|
||||
const cancel = opts.onPrepend(this, item, silent);
|
||||
if (cancel) return;
|
||||
}
|
||||
|
||||
// Prepend the item
|
||||
this.items.unshift(item);
|
||||
|
||||
if (this.isScrollTop()) {
|
||||
// オーバーフローしたら古い投稿は捨てる
|
||||
if (this.items.length >= opts.displayLimit) {
|
||||
this.items = this.items.slice(0, opts.displayLimit);
|
||||
this.more = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
append(item) {
|
||||
this.items.push(item);
|
||||
},
|
||||
|
||||
remove(find) {
|
||||
this.items = this.items.filter(x => !find(x));
|
||||
},
|
||||
}
|
||||
});
|
10
src/client/scripts/please-login.ts
Normal file
10
src/client/scripts/please-login.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export default ($root: any) => {
|
||||
if ($root.$store.getters.isSignedIn) return;
|
||||
|
||||
$root.dialog({
|
||||
title: $root.$t('@.signin-required'),
|
||||
text: null
|
||||
});
|
||||
|
||||
throw new Error('signin required');
|
||||
};
|
64
src/client/scripts/search.ts
Normal file
64
src/client/scripts/search.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { faHistory } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export async function search(v: any, q: string) {
|
||||
q = q.trim();
|
||||
|
||||
if (q.startsWith('@') && !q.includes(' ')) {
|
||||
v.$router.push(`/${q}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (q.startsWith('#')) {
|
||||
v.$router.push(`/tags/${encodeURIComponent(q.substr(1))}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// like 2018/03/12
|
||||
if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) {
|
||||
const date = new Date(q.replace(/-/g, '/'));
|
||||
|
||||
// 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは
|
||||
// 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので
|
||||
// 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の
|
||||
// 結果になってしまい、2018/03/12 のコンテンツは含まれない)
|
||||
if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) {
|
||||
date.setHours(23, 59, 59, 999);
|
||||
}
|
||||
|
||||
v.$root.$emit('warp', date);
|
||||
v.$root.dialog({
|
||||
icon: faHistory,
|
||||
iconOnly: true, autoClose: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (q.startsWith('https://')) {
|
||||
const dialog = v.$root.dialog({
|
||||
type: 'waiting',
|
||||
text: v.$t('fetchingAsApObject') + '...',
|
||||
showOkButton: false,
|
||||
showCancelButton: false,
|
||||
cancelableByBgClick: false
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await v.$root.api('ap/show', {
|
||||
uri: q
|
||||
});
|
||||
dialog.close();
|
||||
if (res.type == 'User') {
|
||||
v.$router.push(`/@${res.object.username}@${res.object.host}`);
|
||||
} else if (res.type == 'Note') {
|
||||
v.$router.push(`/notes/${res.object.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
dialog.close();
|
||||
// TODO: Show error
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
v.$router.push(`/search?q=${encodeURIComponent(q)}`);
|
||||
}
|
12
src/client/scripts/select-drive-file.ts
Normal file
12
src/client/scripts/select-drive-file.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import DriveWindow from '../components/drive-window.vue';
|
||||
|
||||
export function selectDriveFile($root: any, multiple) {
|
||||
return new Promise((res, rej) => {
|
||||
const w = $root.new(DriveWindow, {
|
||||
multiple
|
||||
});
|
||||
w.$once('selected', files => {
|
||||
res(multiple ? files : files[0]);
|
||||
});
|
||||
});
|
||||
}
|
301
src/client/scripts/stream.ts
Normal file
301
src/client/scripts/stream.ts
Normal file
@ -0,0 +1,301 @@
|
||||
import autobind from 'autobind-decorator';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import ReconnectingWebsocket from 'reconnecting-websocket';
|
||||
import { wsUrl } from '../config';
|
||||
import MiOS from '../mios';
|
||||
|
||||
/**
|
||||
* Misskey stream connection
|
||||
*/
|
||||
export default class Stream extends EventEmitter {
|
||||
private stream: ReconnectingWebsocket;
|
||||
public state: string;
|
||||
private sharedConnectionPools: Pool[] = [];
|
||||
private sharedConnections: SharedConnection[] = [];
|
||||
private nonSharedConnections: NonSharedConnection[] = [];
|
||||
|
||||
constructor(os: MiOS) {
|
||||
super();
|
||||
|
||||
this.state = 'initializing';
|
||||
|
||||
const user = os.store.state.i;
|
||||
|
||||
this.stream = new ReconnectingWebsocket(wsUrl + (user ? `?i=${user.token}` : ''), '', { minReconnectionDelay: 1 }); // https://github.com/pladaria/reconnecting-websocket/issues/91
|
||||
this.stream.addEventListener('open', this.onOpen);
|
||||
this.stream.addEventListener('close', this.onClose);
|
||||
this.stream.addEventListener('message', this.onMessage);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public useSharedConnection(channel: string): SharedConnection {
|
||||
let pool = this.sharedConnectionPools.find(p => p.channel === channel);
|
||||
|
||||
if (pool == null) {
|
||||
pool = new Pool(this, channel);
|
||||
this.sharedConnectionPools.push(pool);
|
||||
}
|
||||
|
||||
const connection = new SharedConnection(this, channel, pool);
|
||||
this.sharedConnections.push(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public removeSharedConnection(connection: SharedConnection) {
|
||||
this.sharedConnections = this.sharedConnections.filter(c => c !== connection);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public removeSharedConnectionPool(pool: Pool) {
|
||||
this.sharedConnectionPools = this.sharedConnectionPools.filter(p => p !== pool);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public connectToChannel(channel: string, params?: any): NonSharedConnection {
|
||||
const connection = new NonSharedConnection(this, channel, params);
|
||||
this.nonSharedConnections.push(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public disconnectToChannel(connection: NonSharedConnection) {
|
||||
this.nonSharedConnections = this.nonSharedConnections.filter(c => c !== connection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback of when open connection
|
||||
*/
|
||||
@autobind
|
||||
private onOpen() {
|
||||
const isReconnect = this.state == 'reconnecting';
|
||||
|
||||
this.state = 'connected';
|
||||
this.emit('_connected_');
|
||||
|
||||
// チャンネル再接続
|
||||
if (isReconnect) {
|
||||
for (const p of this.sharedConnectionPools)
|
||||
p.connect();
|
||||
for (const c of this.nonSharedConnections)
|
||||
c.connect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback of when close connection
|
||||
*/
|
||||
@autobind
|
||||
private onClose() {
|
||||
if (this.state == 'connected') {
|
||||
this.state = 'reconnecting';
|
||||
this.emit('_disconnected_');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback of when received a message from connection
|
||||
*/
|
||||
@autobind
|
||||
private onMessage(message) {
|
||||
const { type, body } = JSON.parse(message.data);
|
||||
|
||||
if (type == 'channel') {
|
||||
const id = body.id;
|
||||
|
||||
let connections: Connection[];
|
||||
|
||||
connections = this.sharedConnections.filter(c => c.id === id);
|
||||
|
||||
if (connections.length === 0) {
|
||||
connections = [this.nonSharedConnections.find(c => c.id === id)];
|
||||
}
|
||||
|
||||
for (const c of connections.filter(c => c != null)) {
|
||||
c.emit(body.type, body.body);
|
||||
}
|
||||
} else {
|
||||
this.emit(type, body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to connection
|
||||
*/
|
||||
@autobind
|
||||
public send(typeOrPayload, payload?) {
|
||||
const data = payload === undefined ? typeOrPayload : {
|
||||
type: typeOrPayload,
|
||||
body: payload
|
||||
};
|
||||
|
||||
this.stream.send(JSON.stringify(data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Close this connection
|
||||
*/
|
||||
@autobind
|
||||
public close() {
|
||||
this.stream.removeEventListener('open', this.onOpen);
|
||||
this.stream.removeEventListener('message', this.onMessage);
|
||||
}
|
||||
}
|
||||
|
||||
class Pool {
|
||||
public channel: string;
|
||||
public id: string;
|
||||
protected stream: Stream;
|
||||
public users = 0;
|
||||
private disposeTimerId: any;
|
||||
private isConnected = false;
|
||||
|
||||
constructor(stream: Stream, channel: string) {
|
||||
this.channel = channel;
|
||||
this.stream = stream;
|
||||
|
||||
this.id = Math.random().toString().substr(2, 8);
|
||||
|
||||
this.stream.on('_disconnected_', this.onStreamDisconnected);
|
||||
}
|
||||
|
||||
@autobind
|
||||
private onStreamDisconnected() {
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public inc() {
|
||||
if (this.users === 0 && !this.isConnected) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
this.users++;
|
||||
|
||||
// タイマー解除
|
||||
if (this.disposeTimerId) {
|
||||
clearTimeout(this.disposeTimerId);
|
||||
this.disposeTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public dec() {
|
||||
this.users--;
|
||||
|
||||
// そのコネクションの利用者が誰もいなくなったら
|
||||
if (this.users === 0) {
|
||||
// また直ぐに再利用される可能性があるので、一定時間待ち、
|
||||
// 新たな利用者が現れなければコネクションを切断する
|
||||
this.disposeTimerId = setTimeout(() => {
|
||||
this.disconnect();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
@autobind
|
||||
public connect() {
|
||||
if (this.isConnected) return;
|
||||
this.isConnected = true;
|
||||
this.stream.send('connect', {
|
||||
channel: this.channel,
|
||||
id: this.id
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
private disconnect() {
|
||||
this.stream.off('_disconnected_', this.onStreamDisconnected);
|
||||
this.stream.send('disconnect', { id: this.id });
|
||||
this.stream.removeSharedConnectionPool(this);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Connection extends EventEmitter {
|
||||
public channel: string;
|
||||
protected stream: Stream;
|
||||
public abstract id: string;
|
||||
|
||||
constructor(stream: Stream, channel: string) {
|
||||
super();
|
||||
|
||||
this.stream = stream;
|
||||
this.channel = channel;
|
||||
}
|
||||
|
||||
@autobind
|
||||
public send(id: string, typeOrPayload, payload?) {
|
||||
const type = payload === undefined ? typeOrPayload.type : typeOrPayload;
|
||||
const body = payload === undefined ? typeOrPayload.body : payload;
|
||||
|
||||
this.stream.send('ch', {
|
||||
id: id,
|
||||
type: type,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
public abstract dispose(): void;
|
||||
}
|
||||
|
||||
class SharedConnection extends Connection {
|
||||
private pool: Pool;
|
||||
|
||||
public get id(): string {
|
||||
return this.pool.id;
|
||||
}
|
||||
|
||||
constructor(stream: Stream, channel: string, pool: Pool) {
|
||||
super(stream, channel);
|
||||
|
||||
this.pool = pool;
|
||||
this.pool.inc();
|
||||
}
|
||||
|
||||
@autobind
|
||||
public send(typeOrPayload, payload?) {
|
||||
super.send(this.pool.id, typeOrPayload, payload);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public dispose() {
|
||||
this.pool.dec();
|
||||
this.removeAllListeners();
|
||||
this.stream.removeSharedConnection(this);
|
||||
}
|
||||
}
|
||||
|
||||
class NonSharedConnection extends Connection {
|
||||
public id: string;
|
||||
protected params: any;
|
||||
|
||||
constructor(stream: Stream, channel: string, params?: any) {
|
||||
super(stream, channel);
|
||||
|
||||
this.params = params;
|
||||
this.id = Math.random().toString().substr(2, 8);
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
@autobind
|
||||
public connect() {
|
||||
this.stream.send('connect', {
|
||||
channel: this.channel,
|
||||
id: this.id,
|
||||
params: this.params
|
||||
});
|
||||
}
|
||||
|
||||
@autobind
|
||||
public send(typeOrPayload, payload?) {
|
||||
super.send(this.id, typeOrPayload, payload);
|
||||
}
|
||||
|
||||
@autobind
|
||||
public dispose() {
|
||||
this.removeAllListeners();
|
||||
this.stream.send('disconnect', { id: this.id });
|
||||
this.stream.disconnectToChannel(this);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user