summaryrefslogtreecommitdiff
path: root/src/client/scripts
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2020-01-30 04:37:25 +0900
committerGitHub <noreply@github.com>2020-01-30 04:37:25 +0900
commitf6154dc0af1a0d65819e87240f4385f9573095cb (patch)
tree699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/client/scripts
parentAdd Event activity-type support (#5785) (diff)
downloadsharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.gz
sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.bz2
sharkey-f6154dc0af1a0d65819e87240f4385f9573095cb.zip
v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com> Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Diffstat (limited to 'src/client/scripts')
-rw-r--r--src/client/scripts/2fa.ts5
-rw-r--r--src/client/scripts/aiscript/evaluator.ts267
-rw-r--r--src/client/scripts/aiscript/index.ts140
-rw-r--r--src/client/scripts/aiscript/type-checker.ts186
-rw-r--r--src/client/scripts/collect-page-vars.ts48
-rw-r--r--src/client/scripts/compose-notification.ts59
-rw-r--r--src/client/scripts/contains.ts9
-rw-r--r--src/client/scripts/copy-to-clipboard.ts33
-rw-r--r--src/client/scripts/gen-search-query.ts31
-rw-r--r--src/client/scripts/get-instance-name.ts8
-rw-r--r--src/client/scripts/get-md5.ts10
-rw-r--r--src/client/scripts/get-static-image-url.ts11
-rw-r--r--src/client/scripts/hotkey.ts106
-rw-r--r--src/client/scripts/keycode.ts33
-rw-r--r--src/client/scripts/loading.ts21
-rw-r--r--src/client/scripts/paging.ts134
-rw-r--r--src/client/scripts/please-login.ts10
-rw-r--r--src/client/scripts/search.ts64
-rw-r--r--src/client/scripts/select-drive-file.ts12
-rw-r--r--src/client/scripts/stream.ts301
20 files changed, 1488 insertions, 0 deletions
diff --git a/src/client/scripts/2fa.ts b/src/client/scripts/2fa.ts
new file mode 100644
index 0000000000..e431361aac
--- /dev/null
+++ b/src/client/scripts/2fa.ts
@@ -0,0 +1,5 @@
+export function hexifyAB(buffer) {
+ return Array.from(new Uint8Array(buffer))
+ .map(item => item.toString(16).padStart(2, '0'))
+ .join('');
+}
diff --git a/src/client/scripts/aiscript/evaluator.ts b/src/client/scripts/aiscript/evaluator.ts
new file mode 100644
index 0000000000..cc1adf4499
--- /dev/null
+++ b/src/client/scripts/aiscript/evaluator.ts
@@ -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
+ });
+ }
+}
diff --git a/src/client/scripts/aiscript/index.ts b/src/client/scripts/aiscript/index.ts
new file mode 100644
index 0000000000..f2de1bb40d
--- /dev/null
+++ b/src/client/scripts/aiscript/index.ts
@@ -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;
+}
diff --git a/src/client/scripts/aiscript/type-checker.ts b/src/client/scripts/aiscript/type-checker.ts
new file mode 100644
index 0000000000..817e549864
--- /dev/null
+++ b/src/client/scripts/aiscript/type-checker.ts
@@ -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;
+ }
+}
diff --git a/src/client/scripts/collect-page-vars.ts b/src/client/scripts/collect-page-vars.ts
new file mode 100644
index 0000000000..a4096fb2c2
--- /dev/null
+++ b/src/client/scripts/collect-page-vars.ts
@@ -0,0 +1,48 @@
+export function collectPageVars(content) {
+ const pageVars = [];
+ const collect = (xs: any[]) => {
+ for (const x of xs) {
+ if (x.type === 'textInput') {
+ pageVars.push({
+ name: x.name,
+ type: 'string',
+ value: x.default || ''
+ });
+ } else if (x.type === 'textareaInput') {
+ pageVars.push({
+ name: x.name,
+ type: 'string',
+ value: x.default || ''
+ });
+ } else if (x.type === 'numberInput') {
+ pageVars.push({
+ name: x.name,
+ type: 'number',
+ value: x.default || 0
+ });
+ } else if (x.type === 'switch') {
+ pageVars.push({
+ name: x.name,
+ type: 'boolean',
+ value: x.default || false
+ });
+ } else if (x.type === 'counter') {
+ pageVars.push({
+ name: x.name,
+ type: 'number',
+ value: 0
+ });
+ } else if (x.type === 'radioButton') {
+ pageVars.push({
+ name: x.name,
+ type: 'string',
+ value: x.default || ''
+ });
+ } else if (x.children) {
+ collect(x.children);
+ }
+ }
+ };
+ collect(content);
+ return pageVars;
+}
diff --git a/src/client/scripts/compose-notification.ts b/src/client/scripts/compose-notification.ts
new file mode 100644
index 0000000000..bf32552506
--- /dev/null
+++ b/src/client/scripts/compose-notification.ts
@@ -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;
+ }
+}
diff --git a/src/client/scripts/contains.ts b/src/client/scripts/contains.ts
new file mode 100644
index 0000000000..770bda63bb
--- /dev/null
+++ b/src/client/scripts/contains.ts
@@ -0,0 +1,9 @@
+export default (parent, child, checkSame = true) => {
+ if (checkSame && parent === child) return true;
+ let node = child.parentNode;
+ while (node) {
+ if (node == parent) return true;
+ node = node.parentNode;
+ }
+ return false;
+};
diff --git a/src/client/scripts/copy-to-clipboard.ts b/src/client/scripts/copy-to-clipboard.ts
new file mode 100644
index 0000000000..ab13cab970
--- /dev/null
+++ b/src/client/scripts/copy-to-clipboard.ts
@@ -0,0 +1,33 @@
+/**
+ * Clipboardに値をコピー(TODO: 文字列以外も対応)
+ */
+export default val => {
+ // 空div 生成
+ const tmp = document.createElement('div');
+ // 選択用のタグ生成
+ const pre = document.createElement('pre');
+
+ // 親要素のCSSで user-select: none だとコピーできないので書き換える
+ pre.style.webkitUserSelect = 'auto';
+ pre.style.userSelect = 'auto';
+
+ tmp.appendChild(pre).textContent = val;
+
+ // 要素を画面外へ
+ const s = tmp.style;
+ s.position = 'fixed';
+ s.right = '200%';
+
+ // body に追加
+ document.body.appendChild(tmp);
+ // 要素を選択
+ document.getSelection().selectAllChildren(tmp);
+
+ // クリップボードにコピー
+ const result = document.execCommand('copy');
+
+ // 要素削除
+ document.body.removeChild(tmp);
+
+ return result;
+};
diff --git a/src/client/scripts/gen-search-query.ts b/src/client/scripts/gen-search-query.ts
new file mode 100644
index 0000000000..2520da75df
--- /dev/null
+++ b/src/client/scripts/gen-search-query.ts
@@ -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
+ };
+}
diff --git a/src/client/scripts/get-instance-name.ts b/src/client/scripts/get-instance-name.ts
new file mode 100644
index 0000000000..b12a3a4c67
--- /dev/null
+++ b/src/client/scripts/get-instance-name.ts
@@ -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';
+}
diff --git a/src/client/scripts/get-md5.ts b/src/client/scripts/get-md5.ts
new file mode 100644
index 0000000000..b002d762b1
--- /dev/null
+++ b/src/client/scripts/get-md5.ts
@@ -0,0 +1,10 @@
+// スクリプトサイズがデカい
+//import * as crypto from 'crypto';
+
+export default (data: ArrayBuffer) => {
+ //const buf = new Buffer(data);
+ //const hash = crypto.createHash('md5');
+ //hash.update(buf);
+ //return hash.digest('hex');
+ return '';
+};
diff --git a/src/client/scripts/get-static-image-url.ts b/src/client/scripts/get-static-image-url.ts
new file mode 100644
index 0000000000..eff76af256
--- /dev/null
+++ b/src/client/scripts/get-static-image-url.ts
@@ -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'
+ })}`;
+}
diff --git a/src/client/scripts/hotkey.ts b/src/client/scripts/hotkey.ts
new file mode 100644
index 0000000000..ec627ab15b
--- /dev/null
+++ b/src/client/scripts/hotkey.ts
@@ -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);
+ }
+ }
+ });
+ }
+};
diff --git a/src/client/scripts/keycode.ts b/src/client/scripts/keycode.ts
new file mode 100644
index 0000000000..5786c1dc0a
--- /dev/null
+++ b/src/client/scripts/keycode.ts
@@ -0,0 +1,33 @@
+export default (input: string): string[] => {
+ if (Object.keys(aliases).some(a => a.toLowerCase() == input.toLowerCase())) {
+ const codes = aliases[input];
+ return Array.isArray(codes) ? codes : [codes];
+ } else {
+ return [input];
+ }
+};
+
+export const aliases = {
+ 'esc': 'Escape',
+ 'enter': ['Enter', 'NumpadEnter'],
+ 'up': 'ArrowUp',
+ 'down': 'ArrowDown',
+ 'left': 'ArrowLeft',
+ 'right': 'ArrowRight',
+ 'plus': ['NumpadAdd', 'Semicolon'],
+};
+
+/*!
+* Programatically add the following
+*/
+
+// lower case chars
+for (let i = 97; i < 123; i++) {
+ const char = String.fromCharCode(i);
+ aliases[char] = `Key${char.toUpperCase()}`;
+}
+
+// numbers
+for (let i = 0; i < 10; i++) {
+ aliases[i] = [`Numpad${i}`, `Digit${i}`];
+}
diff --git a/src/client/scripts/loading.ts b/src/client/scripts/loading.ts
new file mode 100644
index 0000000000..70a3a4c85e
--- /dev/null
+++ b/src/client/scripts/loading.ts
@@ -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);
+ }
+};
diff --git a/src/client/scripts/paging.ts b/src/client/scripts/paging.ts
new file mode 100644
index 0000000000..b24d705f15
--- /dev/null
+++ b/src/client/scripts/paging.ts
@@ -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));
+ },
+ }
+});
diff --git a/src/client/scripts/please-login.ts b/src/client/scripts/please-login.ts
new file mode 100644
index 0000000000..7125541bb1
--- /dev/null
+++ b/src/client/scripts/please-login.ts
@@ -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');
+};
diff --git a/src/client/scripts/search.ts b/src/client/scripts/search.ts
new file mode 100644
index 0000000000..02dd39b035
--- /dev/null
+++ b/src/client/scripts/search.ts
@@ -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)}`);
+}
diff --git a/src/client/scripts/select-drive-file.ts b/src/client/scripts/select-drive-file.ts
new file mode 100644
index 0000000000..004ddf2fd9
--- /dev/null
+++ b/src/client/scripts/select-drive-file.ts
@@ -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]);
+ });
+ });
+}
diff --git a/src/client/scripts/stream.ts b/src/client/scripts/stream.ts
new file mode 100644
index 0000000000..7f0e1280b6
--- /dev/null
+++ b/src/client/scripts/stream.ts
@@ -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);
+ }
+}