Merge branch 'develop' into swn

This commit is contained in:
tamaina 2022-01-16 18:03:00 +09:00
commit 1750a19e0c
224 changed files with 3675 additions and 10139 deletions

View file

@ -15,9 +15,13 @@
### Changes ### Changes
- Room機能が削除されました - Room機能が削除されました
- 後日別リポジトリとして復活予定です - 後日別リポジトリとして復活予定です
- リバーシ機能が削除されました
- 後日別リポジトリとして復活予定です
- Chat UIが削除されました - Chat UIが削除されました
### Improvements ### Improvements
- カスタム絵文字一括編集機能
- カスタム絵文字一括インポート
### Bugfixes ### Bugfixes

View file

@ -242,7 +242,6 @@ uploadFromUrlDescription: "アップロードしたいファイルのURL"
uploadFromUrlRequested: "アップロードをリクエストしました" uploadFromUrlRequested: "アップロードをリクエストしました"
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。" uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
explore: "みつける" explore: "みつける"
games: "Misskey Games"
messageRead: "既読" messageRead: "既読"
noMoreHistory: "これより過去の履歴はありません" noMoreHistory: "これより過去の履歴はありません"
startMessaging: "チャットを開始" startMessaging: "チャットを開始"
@ -669,7 +668,6 @@ emailVerified: "メールアドレスが確認されました"
noteFavoritesCount: "お気に入りノートの数" noteFavoritesCount: "お気に入りノートの数"
pageLikesCount: "Pageにいいねした数" pageLikesCount: "Pageにいいねした数"
pageLikedCount: "Pageにいいねされた数" pageLikedCount: "Pageにいいねされた数"
reversiCount: "リバーシの対局数"
contact: "連絡先" contact: "連絡先"
useSystemFont: "システムのデフォルトのフォントを使う" useSystemFont: "システムのデフォルトのフォントを使う"
clips: "クリップ" clips: "クリップ"
@ -957,40 +955,6 @@ _mfm:
rotate: "回転" rotate: "回転"
rotateDescription: "指定した角度で回転させます。" rotateDescription: "指定した角度で回転させます。"
_reversi:
reversi: "リバーシ"
gameSettings: "対局の設定"
chooseBoard: "ボードを選択"
blackOrWhite: "先行/後攻"
blackIs: "{name}が黒(先行)"
rules: "ルール"
botSettings: "Botのオプション"
thisGameIsStartedSoon: "対局は数秒後に開始されます"
waitingForOther: "相手の準備が完了するのを待っています"
waitingForMe: "あなたの準備が完了するのを待っています"
waitingBoth: "準備してください"
ready: "準備完了"
cancelReady: "準備を再開"
opponentTurn: "相手のターンです"
myTurn: "あなたのターンです"
turnOf: "{name}のターンです"
pastTurnOf: "{name}のターン"
surrender: "投了"
surrendered: "投了により"
drawn: "引き分け"
won: "{name}の勝ち"
black: "黒"
white: "白"
total: "合計"
turnCount: "{count}ターン目"
myGames: "自分の対局"
allGames: "みんなの対局"
ended: "終了"
playing: "対局中"
isLlotheo: "石の少ない方が勝ち(ロセオ)"
loopedMap: "ループマップ"
canPutEverywhere: "どこでも置けるモード"
_instanceTicker: _instanceTicker:
none: "表示しない" none: "表示しない"
remote: "リモートユーザーに表示" remote: "リモートユーザーに表示"
@ -1118,8 +1082,6 @@ _sfx:
chatBg: "チャット(バックグラウンド)" chatBg: "チャット(バックグラウンド)"
antenna: "アンテナ受信" antenna: "アンテナ受信"
channel: "チャンネル通知" channel: "チャンネル通知"
reversiPutBlack: "リバーシ: 黒が打ったとき"
reversiPutWhite: "リバーシ: 白が打ったとき"
_ago: _ago:
unknown: "謎" unknown: "謎"

View file

@ -180,6 +180,7 @@
"typeorm": "0.2.39", "typeorm": "0.2.39",
"typescript": "4.4.4", "typescript": "4.4.4",
"ulid": "2.3.0", "ulid": "2.3.0",
"unzipper": "0.10.11",
"uuid": "8.3.2", "uuid": "8.3.2",
"web-push": "3.4.5", "web-push": "3.4.5",
"websocket": "1.0.34", "websocket": "1.0.34",

View file

@ -40,8 +40,6 @@ import { Signin } from '@/models/entities/signin';
import { AuthSession } from '@/models/entities/auth-session'; import { AuthSession } from '@/models/entities/auth-session';
import { FollowRequest } from '@/models/entities/follow-request'; import { FollowRequest } from '@/models/entities/follow-request';
import { Emoji } from '@/models/entities/emoji'; import { Emoji } from '@/models/entities/emoji';
import { ReversiGame } from '@/models/entities/games/reversi/game';
import { ReversiMatching } from '@/models/entities/games/reversi/matching';
import { UserNotePining } from '@/models/entities/user-note-pining'; import { UserNotePining } from '@/models/entities/user-note-pining';
import { Poll } from '@/models/entities/poll'; import { Poll } from '@/models/entities/poll';
import { UserKeypair } from '@/models/entities/user-keypair'; import { UserKeypair } from '@/models/entities/user-keypair';
@ -166,8 +164,6 @@ export const entities = [
AntennaNote, AntennaNote,
PromoNote, PromoNote,
PromoRead, PromoRead,
ReversiGame,
ReversiMatching,
Relay, Relay,
MutedNote, MutedNote,
Channel, Channel,

View file

@ -1,263 +0,0 @@
import { count, concat } from '@/prelude/array';
// MISSKEY REVERSI ENGINE
/**
* true ...
* false ...
*/
export type Color = boolean;
const BLACK = true;
const WHITE = false;
export type MapPixel = 'null' | 'empty';
export type Options = {
isLlotheo: boolean;
canPutEverywhere: boolean;
loopedBoard: boolean;
};
export type Undo = {
/**
*
*/
color: Color;
/**
*
*/
pos: number;
/**
*
*/
effects: number[];
/**
*
*/
turn: Color | null;
};
/**
*
*/
export default class Reversi {
public map: MapPixel[];
public mapWidth: number;
public mapHeight: number;
public board: (Color | null | undefined)[];
public turn: Color | null = BLACK;
public opts: Options;
public prevPos = -1;
public prevColor: Color | null = null;
private logs: Undo[] = [];
/**
*
*/
constructor(map: string[], opts: Options) {
//#region binds
this.put = this.put.bind(this);
//#endregion
//#region Options
this.opts = opts;
if (this.opts.isLlotheo == null) this.opts.isLlotheo = false;
if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false;
if (this.opts.loopedBoard == null) this.opts.loopedBoard = false;
//#endregion
//#region Parse map data
this.mapWidth = map[0].length;
this.mapHeight = map.length;
const mapData = map.join('');
this.board = mapData.split('').map(d => d === '-' ? null : d === 'b' ? BLACK : d === 'w' ? WHITE : undefined);
this.map = mapData.split('').map(d => d === '-' || d === 'b' || d === 'w' ? 'empty' : 'null');
//#endregion
// ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある
if (!this.canPutSomewhere(BLACK)) this.turn = this.canPutSomewhere(WHITE) ? WHITE : null;
}
/**
*
*/
public get blackCount() {
return count(BLACK, this.board);
}
/**
*
*/
public get whiteCount() {
return count(WHITE, this.board);
}
public transformPosToXy(pos: number): number[] {
const x = pos % this.mapWidth;
const y = Math.floor(pos / this.mapWidth);
return [x, y];
}
public transformXyToPos(x: number, y: number): number {
return x + (y * this.mapWidth);
}
/**
*
* @param color
* @param pos
*/
public put(color: Color, pos: number) {
this.prevPos = pos;
this.prevColor = color;
this.board[pos] = color;
// 反転させられる石を取得
const effects = this.effects(color, pos);
// 反転させる
for (const pos of effects) {
this.board[pos] = color;
}
const turn = this.turn;
this.logs.push({
color,
pos,
effects,
turn,
});
this.calcTurn();
}
private calcTurn() {
// ターン計算
this.turn =
this.canPutSomewhere(!this.prevColor) ? !this.prevColor :
this.canPutSomewhere(this.prevColor!) ? this.prevColor :
null;
}
public undo() {
const undo = this.logs.pop()!;
this.prevColor = undo.color;
this.prevPos = undo.pos;
this.board[undo.pos] = null;
for (const pos of undo.effects) {
const color = this.board[pos];
this.board[pos] = !color;
}
this.turn = undo.turn;
}
/**
*
* @param pos
*/
public mapDataGet(pos: number): MapPixel {
const [x, y] = this.transformPosToXy(pos);
return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? 'null' : this.map[pos];
}
/**
*
*/
public puttablePlaces(color: Color): number[] {
return Array.from(this.board.keys()).filter(i => this.canPut(color, i));
}
/**
*
*/
public canPutSomewhere(color: Color): boolean {
return this.puttablePlaces(color).length > 0;
}
/**
*
* @param color
* @param pos
*/
public canPut(color: Color, pos: number): boolean {
return (
this.board[pos] !== null ? false : // 既に石が置いてある場所には打てない
this.opts.canPutEverywhere ? this.mapDataGet(pos) == 'empty' : // 挟んでなくても置けるモード
this.effects(color, pos).length !== 0); // 相手の石を1つでも反転させられるか
}
/**
*
* @param color
* @param initPos
*/
public effects(color: Color, initPos: number): number[] {
const enemyColor = !color;
const diffVectors: [number, number][] = [
[ 0, -1], // 上
[ +1, -1], // 右上
[ +1, 0], // 右
[ +1, +1], // 右下
[ 0, +1], // 下
[ -1, +1], // 左下
[ -1, 0], // 左
[ -1, -1], // 左上
];
const effectsInLine = ([dx, dy]: [number, number]): number[] => {
const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy];
const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列
let [x, y] = this.transformPosToXy(initPos);
while (true) {
[x, y] = nextPos(x, y);
// 座標が指し示す位置がボード外に出たとき
if (this.opts.loopedBoard && this.transformXyToPos(
(x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth),
(y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight)) === initPos) {
// 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ)
return found;
} else if (x === -1 || y === -1 || x === this.mapWidth || y === this.mapHeight) {
return []; // 挟めないことが確定 (盤面外に到達)
}
const pos = this.transformXyToPos(x, y);
if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達)
const stone = this.board[pos];
if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達)
if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見)
if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見)
}
};
return concat(diffVectors.map(effectsInLine));
}
/**
*
*/
public get isEnded(): boolean {
return this.turn === null;
}
/**
* (null = )
*/
public get winner(): Color | null {
return this.isEnded ?
this.blackCount == this.whiteCount ? null :
this.opts.isLlotheo === this.blackCount > this.whiteCount ? WHITE : BLACK :
undefined as never;
}
}

View file

@ -1,896 +0,0 @@
/**
*
*
* :
* () ...
* - ...
* b ...
* w ...
*/
export type Map = {
name?: string;
category?: string;
author?: string;
data: string[];
};
export const fourfour: Map = {
name: '4x4',
category: '4x4',
data: [
'----',
'-wb-',
'-bw-',
'----',
],
};
export const sixsix: Map = {
name: '6x6',
category: '6x6',
data: [
'------',
'------',
'--wb--',
'--bw--',
'------',
'------',
],
};
export const roundedSixsix: Map = {
name: '6x6 rounded',
category: '6x6',
author: 'syuilo',
data: [
' ---- ',
'------',
'--wb--',
'--bw--',
'------',
' ---- ',
],
};
export const roundedSixsix2: Map = {
name: '6x6 rounded 2',
category: '6x6',
author: 'syuilo',
data: [
' -- ',
' ---- ',
'--wb--',
'--bw--',
' ---- ',
' -- ',
],
};
export const eighteight: Map = {
name: '8x8',
category: '8x8',
data: [
'--------',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'--------',
],
};
export const eighteightH1: Map = {
name: '8x8 handicap 1',
category: '8x8',
data: [
'b-------',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'--------',
],
};
export const eighteightH2: Map = {
name: '8x8 handicap 2',
category: '8x8',
data: [
'b-------',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'-------b',
],
};
export const eighteightH3: Map = {
name: '8x8 handicap 3',
category: '8x8',
data: [
'b------b',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'-------b',
],
};
export const eighteightH4: Map = {
name: '8x8 handicap 4',
category: '8x8',
data: [
'b------b',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'b------b',
],
};
export const eighteightH28: Map = {
name: '8x8 handicap 28',
category: '8x8',
data: [
'bbbbbbbb',
'b------b',
'b------b',
'b--wb--b',
'b--bw--b',
'b------b',
'b------b',
'bbbbbbbb',
],
};
export const roundedEighteight: Map = {
name: '8x8 rounded',
category: '8x8',
author: 'syuilo',
data: [
' ------ ',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
' ------ ',
],
};
export const roundedEighteight2: Map = {
name: '8x8 rounded 2',
category: '8x8',
author: 'syuilo',
data: [
' ---- ',
' ------ ',
'--------',
'---wb---',
'---bw---',
'--------',
' ------ ',
' ---- ',
],
};
export const roundedEighteight3: Map = {
name: '8x8 rounded 3',
category: '8x8',
author: 'syuilo',
data: [
' -- ',
' ---- ',
' ------ ',
'---wb---',
'---bw---',
' ------ ',
' ---- ',
' -- ',
],
};
export const eighteightWithNotch: Map = {
name: '8x8 with notch',
category: '8x8',
author: 'syuilo',
data: [
'--- ---',
'--------',
'--------',
' --wb-- ',
' --bw-- ',
'--------',
'--------',
'--- ---',
],
};
export const eighteightWithSomeHoles: Map = {
name: '8x8 with some holes',
category: '8x8',
author: 'syuilo',
data: [
'--- ----',
'----- --',
'-- -----',
'---wb---',
'---bw- -',
' -------',
'--- ----',
'--------',
],
};
export const circle: Map = {
name: 'Circle',
category: '8x8',
author: 'syuilo',
data: [
' -- ',
' ------ ',
' ------ ',
'---wb---',
'---bw---',
' ------ ',
' ------ ',
' -- ',
],
};
export const smile: Map = {
name: 'Smile',
category: '8x8',
author: 'syuilo',
data: [
' ------ ',
'--------',
'-- -- --',
'---wb---',
'-- bw --',
'--- ---',
'--------',
' ------ ',
],
};
export const window: Map = {
name: 'Window',
category: '8x8',
author: 'syuilo',
data: [
'--------',
'- -- -',
'- -- -',
'---wb---',
'---bw---',
'- -- -',
'- -- -',
'--------',
],
};
export const reserved: Map = {
name: 'Reserved',
category: '8x8',
author: 'Aya',
data: [
'w------b',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'b------w',
],
};
export const x: Map = {
name: 'X',
category: '8x8',
author: 'Aya',
data: [
'w------b',
'-w----b-',
'--w--b--',
'---wb---',
'---bw---',
'--b--w--',
'-b----w-',
'b------w',
],
};
export const parallel: Map = {
name: 'Parallel',
category: '8x8',
author: 'Aya',
data: [
'--------',
'--------',
'--------',
'---bb---',
'---ww---',
'--------',
'--------',
'--------',
],
};
export const lackOfBlack: Map = {
name: 'Lack of Black',
category: '8x8',
data: [
'--------',
'--------',
'--------',
'---w----',
'---bw---',
'--------',
'--------',
'--------',
],
};
export const squareParty: Map = {
name: 'Square Party',
category: '8x8',
author: 'syuilo',
data: [
'--------',
'-wwwbbb-',
'-w-wb-b-',
'-wwwbbb-',
'-bbbwww-',
'-b-bw-w-',
'-bbbwww-',
'--------',
],
};
export const minesweeper: Map = {
name: 'Minesweeper',
category: '8x8',
author: 'syuilo',
data: [
'b-b--w-w',
'-w-wb-b-',
'w-b--w-b',
'-b-wb-w-',
'-w-bw-b-',
'b-w--b-w',
'-b-bw-w-',
'w-w--b-b',
],
};
export const tenthtenth: Map = {
name: '10x10',
category: '10x10',
data: [
'----------',
'----------',
'----------',
'----------',
'----wb----',
'----bw----',
'----------',
'----------',
'----------',
'----------',
],
};
export const hole: Map = {
name: 'The Hole',
category: '10x10',
author: 'syuilo',
data: [
'----------',
'----------',
'--wb--wb--',
'--bw--bw--',
'---- ----',
'---- ----',
'--wb--wb--',
'--bw--bw--',
'----------',
'----------',
],
};
export const grid: Map = {
name: 'Grid',
category: '10x10',
author: 'syuilo',
data: [
'----------',
'- - -- - -',
'----------',
'- - -- - -',
'----wb----',
'----bw----',
'- - -- - -',
'----------',
'- - -- - -',
'----------',
],
};
export const cross: Map = {
name: 'Cross',
category: '10x10',
author: 'Aya',
data: [
' ---- ',
' ---- ',
' ---- ',
'----------',
'----wb----',
'----bw----',
'----------',
' ---- ',
' ---- ',
' ---- ',
],
};
export const charX: Map = {
name: 'Char X',
category: '10x10',
author: 'syuilo',
data: [
'--- ---',
'---- ----',
'----------',
' -------- ',
' --wb-- ',
' --bw-- ',
' -------- ',
'----------',
'---- ----',
'--- ---',
],
};
export const charY: Map = {
name: 'Char Y',
category: '10x10',
author: 'syuilo',
data: [
'--- ---',
'---- ----',
'----------',
' -------- ',
' --wb-- ',
' --bw-- ',
' ------ ',
' ------ ',
' ------ ',
' ------ ',
],
};
export const walls: Map = {
name: 'Walls',
category: '10x10',
author: 'Aya',
data: [
' bbbbbbbb ',
'w--------w',
'w--------w',
'w--------w',
'w---wb---w',
'w---bw---w',
'w--------w',
'w--------w',
'w--------w',
' bbbbbbbb ',
],
};
export const cpu: Map = {
name: 'CPU',
category: '10x10',
author: 'syuilo',
data: [
' b b b b ',
'w--------w',
' -------- ',
'w--------w',
' ---wb--- ',
' ---bw--- ',
'w--------w',
' -------- ',
'w--------w',
' b b b b ',
],
};
export const checker: Map = {
name: 'Checker',
category: '10x10',
author: 'Aya',
data: [
'----------',
'----------',
'----------',
'---wbwb---',
'---bwbw---',
'---wbwb---',
'---bwbw---',
'----------',
'----------',
'----------',
],
};
export const japaneseCurry: Map = {
name: 'Japanese curry',
category: '10x10',
author: 'syuilo',
data: [
'w-b-b-b-b-',
'-w-b-b-b-b',
'w-w-b-b-b-',
'-w-w-b-b-b',
'w-w-wwb-b-',
'-w-wbb-b-b',
'w-w-w-b-b-',
'-w-w-w-b-b',
'w-w-w-w-b-',
'-w-w-w-w-b',
],
};
export const mosaic: Map = {
name: 'Mosaic',
category: '10x10',
author: 'syuilo',
data: [
'- - - - - ',
' - - - - -',
'- - - - - ',
' - w w - -',
'- - b b - ',
' - w w - -',
'- - b b - ',
' - - - - -',
'- - - - - ',
' - - - - -',
],
};
export const arena: Map = {
name: 'Arena',
category: '10x10',
author: 'syuilo',
data: [
'- - -- - -',
' - - - - ',
'- ------ -',
' -------- ',
'- --wb-- -',
'- --bw-- -',
' -------- ',
'- ------ -',
' - - - - ',
'- - -- - -',
],
};
export const reactor: Map = {
name: 'Reactor',
category: '10x10',
author: 'syuilo',
data: [
'-w------b-',
'b- - - -w',
'- --wb-- -',
'---b w---',
'- b wb w -',
'- w bw b -',
'---w b---',
'- --bw-- -',
'w- - - -b',
'-b------w-',
],
};
export const sixeight: Map = {
name: '6x8',
category: 'Special',
data: [
'------',
'------',
'------',
'--wb--',
'--bw--',
'------',
'------',
'------',
],
};
export const spark: Map = {
name: 'Spark',
category: 'Special',
author: 'syuilo',
data: [
' - - ',
'----------',
' -------- ',
' -------- ',
' ---wb--- ',
' ---bw--- ',
' -------- ',
' -------- ',
'----------',
' - - ',
],
};
export const islands: Map = {
name: 'Islands',
category: 'Special',
author: 'syuilo',
data: [
'-------- ',
'---wb--- ',
'---bw--- ',
'-------- ',
' - - ',
' - - ',
' --------',
' --------',
' --------',
' --------',
],
};
export const galaxy: Map = {
name: 'Galaxy',
category: 'Special',
author: 'syuilo',
data: [
' ------ ',
' --www--- ',
' ------w--- ',
'---bbb--w---',
'--b---b-w-b-',
'-b--wwb-w-b-',
'-b-w-bww--b-',
'-b-w-b---b--',
'---w--bbb---',
' ---w------ ',
' ---www-- ',
' ------ ',
],
};
export const triangle: Map = {
name: 'Triangle',
category: 'Special',
author: 'syuilo',
data: [
' -- ',
' -- ',
' ---- ',
' ---- ',
' --wb-- ',
' --bw-- ',
' -------- ',
' -------- ',
'----------',
'----------',
],
};
export const iphonex: Map = {
name: 'iPhone X',
category: 'Special',
author: 'syuilo',
data: [
' -- -- ',
'--------',
'--------',
'--------',
'--------',
'---wb---',
'---bw---',
'--------',
'--------',
'--------',
'--------',
' ------ ',
],
};
export const dealWithIt: Map = {
name: 'Deal with it!',
category: 'Special',
author: 'syuilo',
data: [
'------------',
'--w-b-------',
' --b-w------',
' --w-b---- ',
' ------- ',
],
};
export const experiment: Map = {
name: 'Let\'s experiment',
category: 'Special',
author: 'syuilo',
data: [
' ------------ ',
'------wb------',
'------bw------',
'--------------',
' - - ',
'------ ------',
'bbbbbb wwwwww',
'bbbbbb wwwwww',
'bbbbbb wwwwww',
'bbbbbb wwwwww',
'wwwwww bbbbbb',
],
};
export const bigBoard: Map = {
name: 'Big board',
category: 'Special',
data: [
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'-------wb-------',
'-------bw-------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
'----------------',
],
};
export const twoBoard: Map = {
name: 'Two board',
category: 'Special',
author: 'Aya',
data: [
'-------- --------',
'-------- --------',
'-------- --------',
'---wb--- ---wb---',
'---bw--- ---bw---',
'-------- --------',
'-------- --------',
'-------- --------',
],
};
export const test1: Map = {
name: 'Test1',
category: 'Test',
data: [
'--------',
'---wb---',
'---bw---',
'--------',
],
};
export const test2: Map = {
name: 'Test2',
category: 'Test',
data: [
'------',
'------',
'-b--w-',
'-w--b-',
'-w--b-',
],
};
export const test3: Map = {
name: 'Test3',
category: 'Test',
data: [
'-w-',
'--w',
'w--',
'-w-',
'--w',
'w--',
'-w-',
'--w',
'w--',
'-w-',
'---',
'b--',
],
};
export const test4: Map = {
name: 'Test4',
category: 'Test',
data: [
'-w--b-',
'-w--b-',
'------',
'-w--b-',
'-w--b-',
],
};
// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)A1に打ってしまう
export const test6: Map = {
name: 'Test6',
category: 'Test',
data: [
'--wwwww-',
'wwwwwwww',
'wbbbwbwb',
'wbbbbwbb',
'wbwbbwbb',
'wwbwbbbb',
'--wbbbbb',
'-wwwww--',
],
};
// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)G7に打ってしまう
export const test7: Map = {
name: 'Test7',
category: 'Test',
data: [
'b--w----',
'b-wwww--',
'bwbwwwbb',
'wbwwwwb-',
'wwwwwww-',
'-wwbbwwb',
'--wwww--',
'--wwww--',
],
};
// 検証用: この盤面で藍(lv5)が黒で始めると何故か(?)A1に打ってしまう
export const test8: Map = {
name: 'Test8',
category: 'Test',
data: [
'--------',
'-----w--',
'w--www--',
'wwwwww--',
'bbbbwww-',
'wwwwww--',
'--www---',
'--ww----',
],
};

View file

@ -1,18 +0,0 @@
{
"name": "misskey-reversi",
"version": "0.0.5",
"description": "Misskey reversi engine",
"keywords": [
"misskey"
],
"author": "syuilo <i@syuilo.com>",
"license": "MIT",
"repository": "https://github.com/misskey-dev/misskey.git",
"bugs": "https://github.com/misskey-dev/misskey/issues",
"main": "./built/core.js",
"types": "./built/core.d.ts",
"scripts": {
"build": "tsc"
},
"dependencies": {}
}

View file

@ -1,21 +0,0 @@
{
"compilerOptions": {
"noEmitOnError": false,
"noImplicitAny": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"experimentalDecorators": true,
"declaration": true,
"sourceMap": false,
"target": "es2017",
"module": "commonjs",
"removeComments": false,
"noLib": false,
"outDir": "./built",
"rootDir": "./"
},
"compileOnSave": false,
"include": [
"./core.ts"
]
}

View file

@ -1,5 +1,6 @@
/** /**
* Random avatar generator * Identicon generator
* https://en.wikipedia.org/wiki/Identicon
*/ */
import * as p from 'pureimage'; import * as p from 'pureimage';
@ -34,9 +35,9 @@ const cellSize = actualSize / n;
const sideN = Math.floor(n / 2); const sideN = Math.floor(n / 2);
/** /**
* Generate buffer of random avatar by seed * Generate buffer of an identicon by seed
*/ */
export function genAvatar(seed: string, stream: WriteStream): Promise<void> { export function genIdenticon(seed: string, stream: WriteStream): Promise<void> {
const rand = gen.create(seed); const rand = gen.create(seed);
const canvas = p.make(size, size); const canvas = p.make(size, size);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');

View file

@ -22,8 +22,6 @@ import { packedFederationInstanceSchema } from '@/models/repositories/federation
import { packedQueueCountSchema } from '@/models/repositories/queue'; import { packedQueueCountSchema } from '@/models/repositories/queue';
import { packedGalleryPostSchema } from '@/models/repositories/gallery-post'; import { packedGalleryPostSchema } from '@/models/repositories/gallery-post';
import { packedEmojiSchema } from '@/models/repositories/emoji'; import { packedEmojiSchema } from '@/models/repositories/emoji';
import { packedReversiGameSchema } from '@/models/repositories/games/reversi/game';
import { packedReversiMatchingSchema } from '@/models/repositories/games/reversi/matching';
export const refs = { export const refs = {
User: packedUserSchema, User: packedUserSchema,
@ -49,8 +47,6 @@ export const refs = {
FederationInstance: packedFederationInstanceSchema, FederationInstance: packedFederationInstanceSchema,
GalleryPost: packedGalleryPostSchema, GalleryPost: packedGalleryPostSchema,
Emoji: packedEmojiSchema, Emoji: packedEmojiSchema,
ReversiGame: packedReversiGameSchema,
ReversiMatching: packedReversiMatchingSchema,
}; };
export type Packed<x extends keyof typeof refs> = ObjType<(typeof refs[x])['properties']>; export type Packed<x extends keyof typeof refs> = ObjType<(typeof refs[x])['properties']>;

View file

@ -1,133 +0,0 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { User } from '../../user';
import { id } from '../../../id';
@Entity()
export class ReversiGame {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
comment: 'The created date of the ReversiGame.',
})
public createdAt: Date;
@Column('timestamp with time zone', {
nullable: true,
comment: 'The started date of the ReversiGame.',
})
public startedAt: Date | null;
@Column(id())
public user1Id: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user1: User | null;
@Column(id())
public user2Id: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user2: User | null;
@Column('boolean', {
default: false,
})
public user1Accepted: boolean;
@Column('boolean', {
default: false,
})
public user2Accepted: boolean;
/**
* ()
* 1 ... user1
* 2 ... user2
*/
@Column('integer', {
nullable: true,
})
public black: number | null;
@Column('boolean', {
default: false,
})
public isStarted: boolean;
@Column('boolean', {
default: false,
})
public isEnded: boolean;
@Column({
...id(),
nullable: true,
})
public winnerId: User['id'] | null;
@Column({
...id(),
nullable: true,
})
public surrendered: User['id'] | null;
@Column('jsonb', {
default: [],
})
public logs: {
at: Date;
color: boolean;
pos: number;
}[];
@Column('varchar', {
array: true, length: 64,
})
public map: string[];
@Column('varchar', {
length: 32,
})
public bw: string;
@Column('boolean', {
default: false,
})
public isLlotheo: boolean;
@Column('boolean', {
default: false,
})
public canPutEverywhere: boolean;
@Column('boolean', {
default: false,
})
public loopedBoard: boolean;
@Column('jsonb', {
nullable: true, default: null,
})
public form1: any | null;
@Column('jsonb', {
nullable: true, default: null,
})
public form2: any | null;
/**
* posを文字列としてすべて連結したもののCRC32値
*/
@Column('varchar', {
length: 32, nullable: true,
})
public crc32: string | null;
}

View file

@ -1,35 +0,0 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { User } from '../../user';
import { id } from '../../../id';
@Entity()
export class ReversiMatching {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
comment: 'The created date of the ReversiMatching.',
})
public createdAt: Date;
@Index()
@Column(id())
public parentId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public parent: User | null;
@Index()
@Column(id())
public childId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public child: User | null;
}

View file

@ -18,7 +18,6 @@ import { AccessToken } from './entities/access-token';
import { UserNotePining } from './entities/user-note-pining'; import { UserNotePining } from './entities/user-note-pining';
import { SigninRepository } from './repositories/signin'; import { SigninRepository } from './repositories/signin';
import { MessagingMessageRepository } from './repositories/messaging-message'; import { MessagingMessageRepository } from './repositories/messaging-message';
import { ReversiGameRepository } from './repositories/games/reversi/game';
import { UserListRepository } from './repositories/user-list'; import { UserListRepository } from './repositories/user-list';
import { UserListJoining } from './entities/user-list-joining'; import { UserListJoining } from './entities/user-list-joining';
import { UserGroupRepository } from './repositories/user-group'; import { UserGroupRepository } from './repositories/user-group';
@ -30,7 +29,6 @@ import { BlockingRepository } from './repositories/blocking';
import { NoteReactionRepository } from './repositories/note-reaction'; import { NoteReactionRepository } from './repositories/note-reaction';
import { NotificationRepository } from './repositories/notification'; import { NotificationRepository } from './repositories/notification';
import { NoteFavoriteRepository } from './repositories/note-favorite'; import { NoteFavoriteRepository } from './repositories/note-favorite';
import { ReversiMatchingRepository } from './repositories/games/reversi/matching';
import { UserPublickey } from './entities/user-publickey'; import { UserPublickey } from './entities/user-publickey';
import { UserKeypair } from './entities/user-keypair'; import { UserKeypair } from './entities/user-keypair';
import { AppRepository } from './repositories/app'; import { AppRepository } from './repositories/app';
@ -107,8 +105,6 @@ export const AuthSessions = getCustomRepository(AuthSessionRepository);
export const AccessTokens = getRepository(AccessToken); export const AccessTokens = getRepository(AccessToken);
export const Signins = getCustomRepository(SigninRepository); export const Signins = getCustomRepository(SigninRepository);
export const MessagingMessages = getCustomRepository(MessagingMessageRepository); export const MessagingMessages = getCustomRepository(MessagingMessageRepository);
export const ReversiGames = getCustomRepository(ReversiGameRepository);
export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository);
export const Pages = getCustomRepository(PageRepository); export const Pages = getCustomRepository(PageRepository);
export const PageLikes = getCustomRepository(PageLikeRepository); export const PageLikes = getCustomRepository(PageLikeRepository);
export const GalleryPosts = getCustomRepository(GalleryPostRepository); export const GalleryPosts = getCustomRepository(GalleryPostRepository);

View file

@ -1,191 +0,0 @@
import { User } from '@/models/entities/user';
import { EntityRepository, Repository } from 'typeorm';
import { Users } from '../../../index';
import { ReversiGame } from '@/models/entities/games/reversi/game';
import { Packed } from '@/misc/schema';
@EntityRepository(ReversiGame)
export class ReversiGameRepository extends Repository<ReversiGame> {
public async pack(
src: ReversiGame['id'] | ReversiGame,
me?: { id: User['id'] } | null | undefined,
options?: {
detail?: boolean
}
): Promise<Packed<'ReversiGame'>> {
const opts = Object.assign({
detail: true,
}, options);
const game = typeof src === 'object' ? src : await this.findOneOrFail(src);
return {
id: game.id,
createdAt: game.createdAt.toISOString(),
startedAt: game.startedAt && game.startedAt.toISOString(),
isStarted: game.isStarted,
isEnded: game.isEnded,
form1: game.form1,
form2: game.form2,
user1Accepted: game.user1Accepted,
user2Accepted: game.user2Accepted,
user1Id: game.user1Id,
user2Id: game.user2Id,
user1: await Users.pack(game.user1Id, me),
user2: await Users.pack(game.user2Id, me),
winnerId: game.winnerId,
winner: game.winnerId ? await Users.pack(game.winnerId, me) : null,
surrendered: game.surrendered,
black: game.black,
bw: game.bw,
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
...(opts.detail ? {
logs: game.logs.map(log => ({
at: log.at.toISOString(),
color: log.color,
pos: log.pos,
})),
map: game.map,
} : {}),
};
}
}
export const packedReversiGameSchema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'date-time',
},
startedAt: {
type: 'string' as const,
optional: false as const, nullable: true as const,
format: 'date-time',
},
isStarted: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
isEnded: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
form1: {
type: 'any' as const,
optional: false as const, nullable: true as const,
},
form2: {
type: 'any' as const,
optional: false as const, nullable: true as const,
},
user1Accepted: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
user2Accepted: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
user1Id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
example: 'xxxxxxxxxx',
},
user2Id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
example: 'xxxxxxxxxx',
},
user1: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'User' as const,
},
user2: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'User' as const,
},
winnerId: {
type: 'string' as const,
optional: false as const, nullable: true as const,
format: 'id',
example: 'xxxxxxxxxx',
},
winner: {
type: 'object' as const,
optional: false as const, nullable: true as const,
ref: 'User' as const,
},
surrendered: {
type: 'string' as const,
optional: false as const, nullable: true as const,
format: 'id',
example: 'xxxxxxxxxx',
},
black: {
type: 'number' as const,
optional: false as const, nullable: true as const,
},
bw: {
type: 'string' as const,
optional: false as const, nullable: false as const,
},
isLlotheo: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
canPutEverywhere: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
loopedBoard: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
logs: {
type: 'array' as const,
optional: true as const, nullable: false as const,
items: {
type: 'object' as const,
optional: true as const, nullable: false as const,
properties: {
at: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'date-time',
},
color: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
pos: {
type: 'number' as const,
optional: false as const, nullable: false as const,
},
},
},
},
map: {
type: 'array' as const,
optional: true as const, nullable: false as const,
items: {
type: 'string' as const,
optional: false as const, nullable: false as const,
},
},
},
};

View file

@ -1,69 +0,0 @@
import { EntityRepository, Repository } from 'typeorm';
import { ReversiMatching } from '@/models/entities/games/reversi/matching';
import { Users } from '../../../index';
import { awaitAll } from '@/prelude/await-all';
import { User } from '@/models/entities/user';
import { Packed } from '@/misc/schema';
@EntityRepository(ReversiMatching)
export class ReversiMatchingRepository extends Repository<ReversiMatching> {
public async pack(
src: ReversiMatching['id'] | ReversiMatching,
me: { id: User['id'] }
): Promise<Packed<'ReversiMatching'>> {
const matching = typeof src === 'object' ? src : await this.findOneOrFail(src);
return await awaitAll({
id: matching.id,
createdAt: matching.createdAt.toISOString(),
parentId: matching.parentId,
parent: Users.pack(matching.parentId, me, {
detail: true,
}),
childId: matching.childId,
child: Users.pack(matching.childId, me, {
detail: true,
}),
});
}
}
export const packedReversiMatchingSchema = {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
example: 'xxxxxxxxxx',
},
createdAt: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'date-time',
},
parentId: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
example: 'xxxxxxxxxx',
},
parent: {
type: 'object' as const,
optional: false as const, nullable: true as const,
ref: 'User' as const,
},
childId: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
example: 'xxxxxxxxxx',
},
child: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'User' as const,
},
},
};

View file

@ -159,7 +159,7 @@ export class UserRepository extends Repository<User> {
if (user.avatarUrl) { if (user.avatarUrl) {
return user.avatarUrl; return user.avatarUrl;
} else { } else {
return `${config.url}/random-avatar/${user.id}`; return `${config.url}/identicon/${user.id}`;
} }
} }

View file

@ -213,6 +213,16 @@ export function createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']
}); });
} }
export function createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) {
return dbQueue.add('importCustomEmojis', {
user: user,
fileId: fileId,
}, {
removeOnComplete: true,
removeOnFail: true,
});
}
export function createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { export function createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
return dbQueue.add('deleteAccount', { return dbQueue.add('deleteAccount', {
user: user, user: user,

View file

@ -52,7 +52,7 @@ export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promi
}); });
}; };
await writeMeta(`{"metaVersion":1,"emojis":[`); await writeMeta(`{"metaVersion":2,"emojis":[`);
const customEmojis = await Emojis.find({ const customEmojis = await Emojis.find({
where: { where: {
@ -64,9 +64,9 @@ export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promi
}); });
for (const emoji of customEmojis) { for (const emoji of customEmojis) {
const exportId = ulid().toLowerCase();
const ext = mime.extension(emoji.type); const ext = mime.extension(emoji.type);
const emojiPath = path + '/' + exportId + (ext ? '.' + ext : ''); const fileName = emoji.name + (ext ? '.' + ext : '');
const emojiPath = path + '/' + fileName;
fs.writeFileSync(emojiPath, '', 'binary'); fs.writeFileSync(emojiPath, '', 'binary');
let downloaded = false; let downloaded = false;
@ -77,8 +77,12 @@ export async function exportCustomEmojis(job: Bull.Job, done: () => void): Promi
logger.error(e); logger.error(e);
} }
if (!downloaded) {
fs.unlinkSync(emojiPath);
}
const content = JSON.stringify({ const content = JSON.stringify({
id: exportId, fileName: fileName,
downloaded: downloaded, downloaded: downloaded,
emoji: emoji, emoji: emoji,
}); });

View file

@ -0,0 +1,84 @@
import * as Bull from 'bull';
import * as tmp from 'tmp';
import * as fs from 'fs';
const unzipper = require('unzipper');
import { getConnection } from 'typeorm';
import { queueLogger } from '../../logger';
import { downloadUrl } from '@/misc/download-url';
import { DriveFiles, Emojis } from '@/models/index';
import { DbUserImportJobData } from '@/queue/types';
import addFile from '@/services/drive/add-file';
import { genId } from '@/misc/gen-id';
const logger = queueLogger.createSubLogger('import-custom-emojis');
// TODO: 名前衝突時の動作を選べるようにする
export async function importCustomEmojis(job: Bull.Job<DbUserImportJobData>, done: any): Promise<void> {
logger.info(`Importing custom emojis ...`);
const file = await DriveFiles.findOne({
id: job.data.fileId,
});
if (file == null) {
done();
return;
}
// Create temp dir
const [path, cleanup] = await new Promise<[string, () => void]>((res, rej) => {
tmp.dir((e, path, cleanup) => {
if (e) return rej(e);
res([path, cleanup]);
});
});
logger.info(`Temp dir is ${path}`);
const destPath = path + '/emojis.zip';
try {
fs.writeFileSync(destPath, '', 'binary');
await downloadUrl(file.url, destPath);
} catch (e) { // TODO: 何度か再試行
logger.error(e);
throw e;
}
const outputPath = path + '/emojis';
const unzipStream = fs.createReadStream(destPath);
const extractor = unzipper.Extract({ path: outputPath });
extractor.on('close', async () => {
const metaRaw = fs.readFileSync(outputPath + '/meta.json', 'utf-8');
const meta = JSON.parse(metaRaw);
for (const record of meta.emojis) {
if (!record.downloaded) continue;
const emojiInfo = record.emoji;
const emojiPath = outputPath + '/' + record.fileName;
await Emojis.delete({
name: emojiInfo.name,
});
const driveFile = await addFile(null, emojiPath, record.fileName, null, null, true);
const emoji = await Emojis.insert({
id: genId(),
updatedAt: new Date(),
name: emojiInfo.name,
category: emojiInfo.category,
host: null,
aliases: emojiInfo.aliases,
url: driveFile.url,
type: driveFile.type,
}).then(x => Emojis.findOneOrFail(x.identifiers[0]));
}
await getConnection().queryResultCache!.remove(['meta_emojis']);
cleanup();
logger.succ('Imported');
done();
});
unzipStream.pipe(extractor);
logger.succ(`Unzipping to ${outputPath}`);
}

View file

@ -12,6 +12,7 @@ import { importUserLists } from './import-user-lists';
import { deleteAccount } from './delete-account'; import { deleteAccount } from './delete-account';
import { importMuting } from './import-muting'; import { importMuting } from './import-muting';
import { importBlocking } from './import-blocking'; import { importBlocking } from './import-blocking';
import { importCustomEmojis } from './import-custom-emojis';
const jobs = { const jobs = {
deleteDriveFiles, deleteDriveFiles,
@ -25,6 +26,7 @@ const jobs = {
importMuting, importMuting,
importBlocking, importBlocking,
importUserLists, importUserLists,
importCustomEmojis,
deleteAccount, deleteAccount,
} as Record<string, Bull.ProcessCallbackFunction<DbJobData> | Bull.ProcessPromiseFunction<DbJobData>>; } as Record<string, Bull.ProcessCallbackFunction<DbJobData> | Bull.ProcessPromiseFunction<DbJobData>>;

View file

@ -0,0 +1,39 @@
import $ from 'cafy';
import define from '../../../define';
import { ID } from '@/misc/cafy-id';
import { Emojis } from '@/models/index';
import { getConnection, In } from 'typeorm';
import { ApiError } from '../../../error';
export const meta = {
tags: ['admin'],
requireCredential: true as const,
requireModerator: true,
params: {
ids: {
validator: $.arr($.type(ID)),
},
aliases: {
validator: $.arr($.str),
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps) => {
const emojis = await Emojis.find({
id: In(ps.ids),
});
for (const emoji of emojis) {
await Emojis.update(emoji.id, {
updatedAt: new Date(),
aliases: [...new Set(emoji.aliases.concat(ps.aliases))],
});
}
await getConnection().queryResultCache!.remove(['meta_emojis']);
});

View file

@ -0,0 +1,37 @@
import $ from 'cafy';
import define from '../../../define';
import { ID } from '@/misc/cafy-id';
import { Emojis } from '@/models/index';
import { getConnection, In } from 'typeorm';
import { insertModerationLog } from '@/services/insert-moderation-log';
import { ApiError } from '../../../error';
export const meta = {
tags: ['admin'],
requireCredential: true as const,
requireModerator: true,
params: {
ids: {
validator: $.arr($.type(ID)),
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps, me) => {
const emojis = await Emojis.find({
id: In(ps.ids),
});
for (const emoji of emojis) {
await Emojis.delete(emoji.id);
await getConnection().queryResultCache!.remove(['meta_emojis']);
insertModerationLog(me, 'deleteEmoji', {
emoji: emoji,
});
}
});

View file

@ -37,7 +37,7 @@ export default define(meta, async (ps, me) => {
await getConnection().queryResultCache!.remove(['meta_emojis']); await getConnection().queryResultCache!.remove(['meta_emojis']);
insertModerationLog(me, 'removeEmoji', { insertModerationLog(me, 'deleteEmoji', {
emoji: emoji, emoji: emoji,
}); });
}); });

View file

@ -0,0 +1,21 @@
import $ from 'cafy';
import define from '../../../define';
import { createImportCustomEmojisJob } from '@/queue/index';
import ms from 'ms';
import { ID } from '@/misc/cafy-id';
export const meta = {
secure: true,
requireCredential: true as const,
requireModerator: true,
params: {
fileId: {
validator: $.type(ID),
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps, user) => {
createImportCustomEmojisJob(user, ps.fileId);
});

View file

@ -0,0 +1,39 @@
import $ from 'cafy';
import define from '../../../define';
import { ID } from '@/misc/cafy-id';
import { Emojis } from '@/models/index';
import { getConnection, In } from 'typeorm';
import { ApiError } from '../../../error';
export const meta = {
tags: ['admin'],
requireCredential: true as const,
requireModerator: true,
params: {
ids: {
validator: $.arr($.type(ID)),
},
aliases: {
validator: $.arr($.str),
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps) => {
const emojis = await Emojis.find({
id: In(ps.ids),
});
for (const emoji of emojis) {
await Emojis.update(emoji.id, {
updatedAt: new Date(),
aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)),
});
}
await getConnection().queryResultCache!.remove(['meta_emojis']);
});

View file

@ -0,0 +1,35 @@
import $ from 'cafy';
import define from '../../../define';
import { ID } from '@/misc/cafy-id';
import { Emojis } from '@/models/index';
import { getConnection, In } from 'typeorm';
import { ApiError } from '../../../error';
export const meta = {
tags: ['admin'],
requireCredential: true as const,
requireModerator: true,
params: {
ids: {
validator: $.arr($.type(ID)),
},
aliases: {
validator: $.arr($.str),
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps) => {
await Emojis.update({
id: In(ps.ids),
}, {
updatedAt: new Date(),
aliases: ps.aliases,
});
await getConnection().queryResultCache!.remove(['meta_emojis']);
});

View file

@ -0,0 +1,35 @@
import $ from 'cafy';
import define from '../../../define';
import { ID } from '@/misc/cafy-id';
import { Emojis } from '@/models/index';
import { getConnection, In } from 'typeorm';
import { ApiError } from '../../../error';
export const meta = {
tags: ['admin'],
requireCredential: true as const,
requireModerator: true,
params: {
ids: {
validator: $.arr($.type(ID)),
},
category: {
validator: $.optional.nullable.str,
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps) => {
await Emojis.update({
id: In(ps.ids),
}, {
updatedAt: new Date(),
category: ps.category,
});
await getConnection().queryResultCache!.remove(['meta_emojis']);
});

View file

@ -1,157 +0,0 @@
import $ from 'cafy';
import { ID } from '@/misc/cafy-id';
import define from '../../../define';
import { ReversiGames } from '@/models/index';
import { makePaginationQuery } from '../../../common/make-pagination-query';
import { Brackets } from 'typeorm';
export const meta = {
tags: ['games'],
params: {
limit: {
validator: $.optional.num.range(1, 100),
default: 10,
},
sinceId: {
validator: $.optional.type(ID),
},
untilId: {
validator: $.optional.type(ID),
},
my: {
validator: $.optional.bool,
default: false,
},
},
res: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
},
createdAt: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'date-time',
},
startedAt: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'date-time',
},
isStarted: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
isEnded: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
form1: {
type: 'any' as const,
optional: false as const, nullable: true as const,
},
form2: {
type: 'any' as const,
optional: false as const, nullable: true as const,
},
user1Accepted: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
default: false,
},
user2Accepted: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
default: false,
},
user1Id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
},
user2Id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
},
user1: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'User',
},
user2: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'User',
},
winnerId: {
type: 'string' as const,
optional: false as const, nullable: true as const,
format: 'id',
},
winner: {
type: 'object' as const,
optional: false as const, nullable: true as const,
ref: 'User',
},
surrendered: {
type: 'string' as const,
optional: false as const, nullable: true as const,
format: 'id',
},
black: {
type: 'number' as const,
optional: false as const, nullable: true as const,
},
bw: {
type: 'string' as const,
optional: false as const, nullable: false as const,
},
isLlotheo: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
canPutEverywhere: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
loopedBoard: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
},
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps, user) => {
const query = makePaginationQuery(ReversiGames.createQueryBuilder('game'), ps.sinceId, ps.untilId)
.andWhere('game.isStarted = TRUE');
if (ps.my && user) {
query.andWhere(new Brackets(qb => { qb
.where('game.user1Id = :userId', { userId: user.id })
.orWhere('game.user2Id = :userId', { userId: user.id });
}));
}
// Fetch games
const games = await query.take(ps.limit!).getMany();
return await Promise.all(games.map((g) => ReversiGames.pack(g, user, {
detail: false,
})));
});

View file

@ -1,169 +0,0 @@
import $ from 'cafy';
import { ID } from '@/misc/cafy-id';
import Reversi from '../../../../../../games/reversi/core';
import define from '../../../../define';
import { ApiError } from '../../../../error';
import { ReversiGames } from '@/models/index';
export const meta = {
tags: ['games'],
params: {
gameId: {
validator: $.type(ID),
},
},
errors: {
noSuchGame: {
message: 'No such game.',
code: 'NO_SUCH_GAME',
id: 'f13a03db-fae1-46c9-87f3-43c8165419e1',
},
},
res: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
},
createdAt: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'date-time',
},
startedAt: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'date-time',
},
isStarted: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
isEnded: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
form1: {
type: 'any' as const,
optional: false as const, nullable: true as const,
},
form2: {
type: 'any' as const,
optional: false as const, nullable: true as const,
},
user1Accepted: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
default: false,
},
user2Accepted: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
default: false,
},
user1Id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
},
user2Id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
},
user1: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'User',
},
user2: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'User',
},
winnerId: {
type: 'string' as const,
optional: false as const, nullable: true as const,
format: 'id',
},
winner: {
type: 'object' as const,
optional: false as const, nullable: true as const,
ref: 'User',
},
surrendered: {
type: 'string' as const,
optional: false as const, nullable: true as const,
format: 'id',
},
black: {
type: 'number' as const,
optional: false as const, nullable: true as const,
},
bw: {
type: 'string' as const,
optional: false as const, nullable: false as const,
},
isLlotheo: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
canPutEverywhere: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
loopedBoard: {
type: 'boolean' as const,
optional: false as const, nullable: false as const,
},
board: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'any' as const,
optional: false as const, nullable: false as const,
},
},
turn: {
type: 'any' as const,
optional: false as const, nullable: false as const,
},
},
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps, user) => {
const game = await ReversiGames.findOne(ps.gameId);
if (game == null) {
throw new ApiError(meta.errors.noSuchGame);
}
const o = new Reversi(game.map, {
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
});
for (const log of game.logs) {
o.put(log.color, log.pos);
}
const packed = await ReversiGames.pack(game, user);
return Object.assign({
board: o.board,
turn: o.turn,
}, packed);
});

View file

@ -1,68 +0,0 @@
import $ from 'cafy';
import { ID } from '@/misc/cafy-id';
import { publishReversiGameStream } from '@/services/stream';
import define from '../../../../define';
import { ApiError } from '../../../../error';
import { ReversiGames } from '@/models/index';
export const meta = {
tags: ['games'],
requireCredential: true as const,
params: {
gameId: {
validator: $.type(ID),
},
},
errors: {
noSuchGame: {
message: 'No such game.',
code: 'NO_SUCH_GAME',
id: 'ace0b11f-e0a6-4076-a30d-e8284c81b2df',
},
alreadyEnded: {
message: 'That game has already ended.',
code: 'ALREADY_ENDED',
id: '6c2ad4a6-cbf1-4a5b-b187-b772826cfc6d',
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: '6e04164b-a992-4c93-8489-2123069973e1',
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps, user) => {
const game = await ReversiGames.findOne(ps.gameId);
if (game == null) {
throw new ApiError(meta.errors.noSuchGame);
}
if (game.isEnded) {
throw new ApiError(meta.errors.alreadyEnded);
}
if ((game.user1Id !== user.id) && (game.user2Id !== user.id)) {
throw new ApiError(meta.errors.accessDenied);
}
const winnerId = game.user1Id === user.id ? game.user2Id : game.user1Id;
await ReversiGames.update(game.id, {
surrendered: user.id,
isEnded: true,
winnerId: winnerId,
});
publishReversiGameStream(game.id, 'ended', {
winnerId: winnerId,
game: await ReversiGames.pack(game.id, user),
});
});

View file

@ -1,59 +0,0 @@
import define from '../../../define';
import { ReversiMatchings } from '@/models/index';
export const meta = {
tags: ['games'],
requireCredential: true as const,
res: {
type: 'array' as const,
optional: false as const, nullable: false as const,
items: {
type: 'object' as const,
optional: false as const, nullable: false as const,
properties: {
id: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
},
createdAt: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'date-time',
},
parentId: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
},
parent: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'User',
},
childId: {
type: 'string' as const,
optional: false as const, nullable: false as const,
format: 'id',
},
child: {
type: 'object' as const,
optional: false as const, nullable: false as const,
ref: 'User',
},
},
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps, user) => {
// Find session
const invitations = await ReversiMatchings.find({
childId: user.id,
});
return await Promise.all(invitations.map((i) => ReversiMatchings.pack(i, user)));
});

View file

@ -1,109 +0,0 @@
import $ from 'cafy';
import { ID } from '@/misc/cafy-id';
import { publishMainStream, publishReversiStream } from '@/services/stream';
import { eighteight } from '../../../../../games/reversi/maps';
import define from '../../../define';
import { ApiError } from '../../../error';
import { getUser } from '../../../common/getters';
import { genId } from '@/misc/gen-id';
import { ReversiMatchings, ReversiGames } from '@/models/index';
import { ReversiGame } from '@/models/entities/games/reversi/game';
import { ReversiMatching } from '@/models/entities/games/reversi/matching';
export const meta = {
tags: ['games'],
requireCredential: true as const,
params: {
userId: {
validator: $.type(ID),
},
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '0b4f0559-b484-4e31-9581-3f73cee89b28',
},
isYourself: {
message: 'Target user is yourself.',
code: 'TARGET_IS_YOURSELF',
id: '96fd7bd6-d2bc-426c-a865-d055dcd2828e',
},
},
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps, user) => {
// Myself
if (ps.userId === user.id) {
throw new ApiError(meta.errors.isYourself);
}
// Find session
const exist = await ReversiMatchings.findOne({
parentId: ps.userId,
childId: user.id,
});
if (exist) {
// Destroy session
ReversiMatchings.delete(exist.id);
// Create game
const game = await ReversiGames.save({
id: genId(),
createdAt: new Date(),
user1Id: exist.parentId,
user2Id: user.id,
user1Accepted: false,
user2Accepted: false,
isStarted: false,
isEnded: false,
logs: [],
map: eighteight.data,
bw: 'random',
isLlotheo: false,
} as Partial<ReversiGame>);
publishReversiStream(exist.parentId, 'matched', await ReversiGames.pack(game, { id: exist.parentId }));
const other = await ReversiMatchings.count({
childId: user.id,
});
if (other == 0) {
publishMainStream(user.id, 'reversiNoInvites');
}
return await ReversiGames.pack(game, user);
} else {
// Fetch child
const child = await getUser(ps.userId).catch(e => {
if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw e;
});
// 以前のセッションはすべて削除しておく
await ReversiMatchings.delete({
parentId: user.id,
});
// セッションを作成
const matching = await ReversiMatchings.save({
id: genId(),
createdAt: new Date(),
parentId: user.id,
childId: child.id,
} as ReversiMatching);
const packed = await ReversiMatchings.pack(matching, child);
publishReversiStream(child.id, 'invited', packed);
publishMainStream(child.id, 'reversiInvited', packed);
return;
}
});

View file

@ -1,15 +0,0 @@
import define from '../../../../define';
import { ReversiMatchings } from '@/models/index';
export const meta = {
tags: ['games'],
requireCredential: true as const,
};
// eslint-disable-next-line import/no-default-export
export default define(meta, async (ps, user) => {
await ReversiMatchings.delete({
parentId: user.id,
});
});

View file

@ -2,7 +2,7 @@ import $ from 'cafy';
import define from '../../define'; import define from '../../define';
import { ApiError } from '../../error'; import { ApiError } from '../../error';
import { ID } from '@/misc/cafy-id'; import { ID } from '@/misc/cafy-id';
import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, ReversiGames, Users } from '@/models/index'; import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, Users } from '@/models/index';
export const meta = { export const meta = {
tags: ['users'], tags: ['users'],
@ -50,7 +50,6 @@ export default define(meta, async (ps, me) => {
pageLikedCount, pageLikedCount,
driveFilesCount, driveFilesCount,
driveUsage, driveUsage,
reversiCount,
] = await Promise.all([ ] = await Promise.all([
Notes.createQueryBuilder('note') Notes.createQueryBuilder('note')
.where('note.userId = :userId', { userId: user.id }) .where('note.userId = :userId', { userId: user.id })
@ -113,10 +112,6 @@ export default define(meta, async (ps, me) => {
.where('file.userId = :userId', { userId: user.id }) .where('file.userId = :userId', { userId: user.id })
.getCount(), .getCount(),
DriveFiles.calcDriveUsageOf(user), DriveFiles.calcDriveUsageOf(user),
ReversiGames.createQueryBuilder('game')
.where('game.user1Id = :userId', { userId: user.id })
.orWhere('game.user2Id = :userId', { userId: user.id })
.getCount(),
]); ]);
return { return {
@ -140,6 +135,5 @@ export default define(meta, async (ps, me) => {
pageLikedCount, pageLikedCount,
driveFilesCount, driveFilesCount,
driveUsage, driveUsage,
reversiCount,
}; };
}); });

View file

@ -1,372 +0,0 @@
import autobind from 'autobind-decorator';
import * as CRC32 from 'crc-32';
import { publishReversiGameStream } from '@/services/stream';
import Reversi from '../../../../../games/reversi/core';
import * as maps from '../../../../../games/reversi/maps';
import Channel from '../../channel';
import { ReversiGame } from '@/models/entities/games/reversi/game';
import { ReversiGames, Users } from '@/models/index';
import { User } from '@/models/entities/user';
export default class extends Channel {
public readonly chName = 'gamesReversiGame';
public static shouldShare = false;
public static requireCredential = false;
private gameId: ReversiGame['id'] | null = null;
private watchers: Record<User['id'], Date> = {};
private emitWatchersIntervalId: ReturnType<typeof setInterval>;
@autobind
public async init(params: any) {
this.gameId = params.gameId;
// Subscribe game stream
this.subscriber.on(`reversiGameStream:${this.gameId}`, this.onEvent);
this.emitWatchersIntervalId = setInterval(this.emitWatchers, 5000);
const game = await ReversiGames.findOne(this.gameId!);
if (game == null) throw new Error('game not found');
// 観戦者イベント
this.watch(game);
}
@autobind
private onEvent(data: any) {
if (data.type === 'watching') {
const id = data.body;
this.watchers[id] = new Date();
} else {
this.send(data);
}
}
@autobind
private async emitWatchers() {
const now = new Date();
// Remove not watching users
for (const [userId, date] of Object.entries(this.watchers)) {
if (now.getTime() - date.getTime() > 5000) delete this.watchers[userId];
}
const users = await Users.packMany(Object.keys(this.watchers), null, { detail: false });
this.send({
type: 'watchers',
body: users,
});
}
@autobind
public dispose() {
// Unsubscribe events
this.subscriber.off(`reversiGameStream:${this.gameId}`, this.onEvent);
clearInterval(this.emitWatchersIntervalId);
}
@autobind
public onMessage(type: string, body: any) {
switch (type) {
case 'accept': this.accept(true); break;
case 'cancelAccept': this.accept(false); break;
case 'updateSettings': this.updateSettings(body.key, body.value); break;
case 'initForm': this.initForm(body); break;
case 'updateForm': this.updateForm(body.id, body.value); break;
case 'message': this.message(body); break;
case 'set': this.set(body.pos); break;
case 'check': this.check(body.crc32); break;
}
}
@autobind
private async updateSettings(key: string, value: any) {
if (this.user == null) return;
const game = await ReversiGames.findOne(this.gameId!);
if (game == null) throw new Error('game not found');
if (game.isStarted) return;
if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return;
if ((game.user1Id === this.user.id) && game.user1Accepted) return;
if ((game.user2Id === this.user.id) && game.user2Accepted) return;
if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard'].includes(key)) return;
await ReversiGames.update(this.gameId!, {
[key]: value,
});
publishReversiGameStream(this.gameId!, 'updateSettings', {
key: key,
value: value,
});
}
@autobind
private async initForm(form: any) {
if (this.user == null) return;
const game = await ReversiGames.findOne(this.gameId!);
if (game == null) throw new Error('game not found');
if (game.isStarted) return;
if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return;
const set = game.user1Id === this.user.id ? {
form1: form,
} : {
form2: form,
};
await ReversiGames.update(this.gameId!, set);
publishReversiGameStream(this.gameId!, 'initForm', {
userId: this.user.id,
form,
});
}
@autobind
private async updateForm(id: string, value: any) {
if (this.user == null) return;
const game = await ReversiGames.findOne(this.gameId!);
if (game == null) throw new Error('game not found');
if (game.isStarted) return;
if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return;
const form = game.user1Id === this.user.id ? game.form2 : game.form1;
const item = form.find((i: any) => i.id == id);
if (item == null) return;
item.value = value;
const set = game.user1Id === this.user.id ? {
form2: form,
} : {
form1: form,
};
await ReversiGames.update(this.gameId!, set);
publishReversiGameStream(this.gameId!, 'updateForm', {
userId: this.user.id,
id,
value,
});
}
@autobind
private async message(message: any) {
if (this.user == null) return;
message.id = Math.random();
publishReversiGameStream(this.gameId!, 'message', {
userId: this.user.id,
message,
});
}
@autobind
private async accept(accept: boolean) {
if (this.user == null) return;
const game = await ReversiGames.findOne(this.gameId!);
if (game == null) throw new Error('game not found');
if (game.isStarted) return;
let bothAccepted = false;
if (game.user1Id === this.user.id) {
await ReversiGames.update(this.gameId!, {
user1Accepted: accept,
});
publishReversiGameStream(this.gameId!, 'changeAccepts', {
user1: accept,
user2: game.user2Accepted,
});
if (accept && game.user2Accepted) bothAccepted = true;
} else if (game.user2Id === this.user.id) {
await ReversiGames.update(this.gameId!, {
user2Accepted: accept,
});
publishReversiGameStream(this.gameId!, 'changeAccepts', {
user1: game.user1Accepted,
user2: accept,
});
if (accept && game.user1Accepted) bothAccepted = true;
} else {
return;
}
if (bothAccepted) {
// 3秒後、まだacceptされていたらゲーム開始
setTimeout(async () => {
const freshGame = await ReversiGames.findOne(this.gameId!);
if (freshGame == null || freshGame.isStarted || freshGame.isEnded) return;
if (!freshGame.user1Accepted || !freshGame.user2Accepted) return;
let bw: number;
if (freshGame.bw == 'random') {
bw = Math.random() > 0.5 ? 1 : 2;
} else {
bw = parseInt(freshGame.bw, 10);
}
function getRandomMap() {
const mapCount = Object.entries(maps).length;
const rnd = Math.floor(Math.random() * mapCount);
return Object.values(maps)[rnd].data;
}
const map = freshGame.map != null ? freshGame.map : getRandomMap();
await ReversiGames.update(this.gameId!, {
startedAt: new Date(),
isStarted: true,
black: bw,
map: map,
});
//#region 盤面に最初から石がないなどして始まった瞬間に勝敗が決定する場合があるのでその処理
const o = new Reversi(map, {
isLlotheo: freshGame.isLlotheo,
canPutEverywhere: freshGame.canPutEverywhere,
loopedBoard: freshGame.loopedBoard,
});
if (o.isEnded) {
let winner;
if (o.winner === true) {
winner = freshGame.black == 1 ? freshGame.user1Id : freshGame.user2Id;
} else if (o.winner === false) {
winner = freshGame.black == 1 ? freshGame.user2Id : freshGame.user1Id;
} else {
winner = null;
}
await ReversiGames.update(this.gameId!, {
isEnded: true,
winnerId: winner,
});
publishReversiGameStream(this.gameId!, 'ended', {
winnerId: winner,
game: await ReversiGames.pack(this.gameId!, this.user),
});
}
//#endregion
publishReversiGameStream(this.gameId!, 'started',
await ReversiGames.pack(this.gameId!, this.user));
}, 3000);
}
}
// 石を打つ
@autobind
private async set(pos: number) {
if (this.user == null) return;
const game = await ReversiGames.findOne(this.gameId!);
if (game == null) throw new Error('game not found');
if (!game.isStarted) return;
if (game.isEnded) return;
if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) return;
const myColor =
((game.user1Id === this.user.id) && game.black == 1) || ((game.user2Id === this.user.id) && game.black == 2)
? true
: false;
const o = new Reversi(game.map, {
isLlotheo: game.isLlotheo,
canPutEverywhere: game.canPutEverywhere,
loopedBoard: game.loopedBoard,
});
// 盤面の状態を再生
for (const log of game.logs) {
o.put(log.color, log.pos);
}
if (o.turn !== myColor) return;
if (!o.canPut(myColor, pos)) return;
o.put(myColor, pos);
let winner;
if (o.isEnded) {
if (o.winner === true) {
winner = game.black == 1 ? game.user1Id : game.user2Id;
} else if (o.winner === false) {
winner = game.black == 1 ? game.user2Id : game.user1Id;
} else {
winner = null;
}
}
const log = {
at: new Date(),
color: myColor,
pos,
};
const crc32 = CRC32.str(game.logs.map(x => x.pos.toString()).join('') + pos.toString()).toString();
game.logs.push(log);
await ReversiGames.update(this.gameId!, {
crc32,
isEnded: o.isEnded,
winnerId: winner,
logs: game.logs,
});
publishReversiGameStream(this.gameId!, 'set', Object.assign(log, {
next: o.turn,
}));
if (o.isEnded) {
publishReversiGameStream(this.gameId!, 'ended', {
winnerId: winner,
game: await ReversiGames.pack(this.gameId!, this.user),
});
}
}
@autobind
private async check(crc32: string | number) {
const game = await ReversiGames.findOne(this.gameId!);
if (game == null) throw new Error('game not found');
if (!game.isStarted) return;
if (crc32.toString() !== game.crc32) {
this.send('rescue', await ReversiGames.pack(game, this.user));
}
// ついでに観戦者イベントを発行
this.watch(game);
}
@autobind
private watch(game: ReversiGame) {
if (this.user != null) {
if ((game.user1Id !== this.user.id) && (game.user2Id !== this.user.id)) {
publishReversiGameStream(this.gameId!, 'watching', this.user.id);
}
}
}
}

View file

@ -1,34 +0,0 @@
import autobind from 'autobind-decorator';
import { publishMainStream } from '@/services/stream';
import Channel from '../../channel';
import { ReversiMatchings } from '@/models/index';
export default class extends Channel {
public readonly chName = 'gamesReversi';
public static shouldShare = true;
public static requireCredential = true;
@autobind
public async init(params: any) {
// Subscribe reversi stream
this.subscriber.on(`reversiStream:${this.user!.id}`, data => {
this.send(data);
});
}
@autobind
public async onMessage(type: string, body: any) {
switch (type) {
case 'ping': {
if (body.id == null) return;
const matching = await ReversiMatchings.findOne({
parentId: this.user!.id,
childId: body.id,
});
if (matching == null) return;
publishMainStream(matching.childId, 'reversiInvited', await ReversiMatchings.pack(matching, { id: matching.childId }));
break;
}
}
}
}

View file

@ -13,8 +13,6 @@ import drive from './drive';
import hashtag from './hashtag'; import hashtag from './hashtag';
import channel from './channel'; import channel from './channel';
import admin from './admin'; import admin from './admin';
import gamesReversi from './games/reversi';
import gamesReversiGame from './games/reversi-game';
export default { export default {
main, main,
@ -32,6 +30,4 @@ export default {
hashtag, hashtag,
channel, channel,
admin, admin,
gamesReversi,
gamesReversiGame,
}; };

View file

@ -11,7 +11,6 @@ import { Emoji } from '@/models/entities/emoji';
import { UserList } from '@/models/entities/user-list'; import { UserList } from '@/models/entities/user-list';
import { MessagingMessage } from '@/models/entities/messaging-message'; import { MessagingMessage } from '@/models/entities/messaging-message';
import { UserGroup } from '@/models/entities/user-group'; import { UserGroup } from '@/models/entities/user-group';
import { ReversiGame } from '@/models/entities/games/reversi/game';
import { AbuseUserReport } from '@/models/entities/abuse-user-report'; import { AbuseUserReport } from '@/models/entities/abuse-user-report';
import { Signin } from '@/models/entities/signin'; import { Signin } from '@/models/entities/signin';
import { Page } from '@/models/entities/page'; import { Page } from '@/models/entities/page';
@ -77,8 +76,6 @@ export interface MainStreamTypes {
readAllChannels: undefined; readAllChannels: undefined;
unreadChannel: Note['id']; unreadChannel: Note['id'];
myTokenRegenerated: undefined; myTokenRegenerated: undefined;
reversiNoInvites: undefined;
reversiInvited: Packed<'ReversiMatching'>;
signin: Signin; signin: Signin;
registryUpdated: { registryUpdated: {
scope?: string[]; scope?: string[];
@ -158,47 +155,6 @@ export interface MessagingIndexStreamTypes {
message: Packed<'MessagingMessage'>; message: Packed<'MessagingMessage'>;
} }
export interface ReversiStreamTypes {
matched: Packed<'ReversiGame'>;
invited: Packed<'ReversiMatching'>;
}
export interface ReversiGameStreamTypes {
started: Packed<'ReversiGame'>;
ended: {
winnerId?: User['id'] | null,
game: Packed<'ReversiGame'>;
};
updateSettings: {
key: string;
value: FIXME;
};
initForm: {
userId: User['id'];
form: FIXME;
};
updateForm: {
userId: User['id'];
id: string;
value: FIXME;
};
message: {
userId: User['id'];
message: FIXME;
};
changeAccepts: {
user1: boolean;
user2: boolean;
};
set: {
at: Date;
color: boolean;
pos: number;
next: boolean;
};
watching: User['id'];
}
export interface AdminStreamTypes { export interface AdminStreamTypes {
newAbuseUserReport: { newAbuseUserReport: {
id: AbuseUserReport['id']; id: AbuseUserReport['id'];
@ -268,14 +224,6 @@ export type StreamMessages = {
name: `messagingIndexStream:${User['id']}`; name: `messagingIndexStream:${User['id']}`;
payload: EventUnionFromDictionary<MessagingIndexStreamTypes>; payload: EventUnionFromDictionary<MessagingIndexStreamTypes>;
}; };
reversi: {
name: `reversiStream:${User['id']}`;
payload: EventUnionFromDictionary<ReversiStreamTypes>;
};
reversiGame: {
name: `reversiGameStream:${ReversiGame['id']}`;
payload: EventUnionFromDictionary<ReversiGameStreamTypes>;
};
admin: { admin: {
name: `adminStream:${User['id']}`; name: `adminStream:${User['id']}`;
payload: EventUnionFromDictionary<AdminStreamTypes>; payload: EventUnionFromDictionary<AdminStreamTypes>;

View file

@ -23,7 +23,7 @@ import Logger from '@/services/logger';
import { envOption } from '../env'; import { envOption } from '../env';
import { UserProfiles, Users } from '@/models/index'; import { UserProfiles, Users } from '@/models/index';
import { networkChart } from '@/services/chart/index'; import { networkChart } from '@/services/chart/index';
import { genAvatar } from '@/misc/gen-avatar'; import { genIdenticon } from '@/misc/gen-identicon';
import { createTemp } from '@/misc/create-temp'; import { createTemp } from '@/misc/create-temp';
import { publishMainStream } from '@/services/stream'; import { publishMainStream } from '@/services/stream';
import * as Acct from 'misskey-js/built/acct'; import * as Acct from 'misskey-js/built/acct';
@ -84,9 +84,9 @@ router.get('/avatar/@:acct', async ctx => {
} }
}); });
router.get('/random-avatar/:x', async ctx => { router.get('/identicon/:x', async ctx => {
const [temp] = await createTemp(); const [temp] = await createTemp();
await genAvatar(ctx.params.x, fs.createWriteStream(temp)); await genIdenticon(ctx.params.x, fs.createWriteStream(temp));
ctx.set('Content-Type', 'image/png'); ctx.set('Content-Type', 'image/png');
ctx.body = fs.createReadStream(temp); ctx.body = fs.createReadStream(temp);
}); });

View file

@ -396,9 +396,6 @@ router.get('/cli', async ctx => {
const override = (source: string, target: string, depth: number = 0) => const override = (source: string, target: string, depth: number = 0) =>
[, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/');
router.get('/othello', async ctx => ctx.redirect(override(ctx.URL.pathname, 'games/reversi', 1)));
router.get('/reversi', async ctx => ctx.redirect(override(ctx.URL.pathname, 'games')));
router.get('/flush', async ctx => { router.get('/flush', async ctx => {
await ctx.render('flush'); await ctx.render('flush');
}); });

View file

@ -2,7 +2,6 @@ import { redisClient } from '../db/redis';
import { User } from '@/models/entities/user'; import { User } from '@/models/entities/user';
import { Note } from '@/models/entities/note'; import { Note } from '@/models/entities/note';
import { UserList } from '@/models/entities/user-list'; import { UserList } from '@/models/entities/user-list';
import { ReversiGame } from '@/models/entities/games/reversi/game';
import { UserGroup } from '@/models/entities/user-group'; import { UserGroup } from '@/models/entities/user-group';
import config from '@/config/index'; import config from '@/config/index';
import { Antenna } from '@/models/entities/antenna'; import { Antenna } from '@/models/entities/antenna';
@ -20,8 +19,6 @@ import {
MessagingIndexStreamTypes, MessagingIndexStreamTypes,
MessagingStreamTypes, MessagingStreamTypes,
NoteStreamTypes, NoteStreamTypes,
ReversiGameStreamTypes,
ReversiStreamTypes,
UserListStreamTypes, UserListStreamTypes,
UserStreamTypes, UserStreamTypes,
} from '@/server/api/stream/types'; } from '@/server/api/stream/types';
@ -90,14 +87,6 @@ class Publisher {
this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}; };
public publishReversiStream = <K extends keyof ReversiStreamTypes>(userId: User['id'], type: K, value?: ReversiStreamTypes[K]): void => {
this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
};
public publishReversiGameStream = <K extends keyof ReversiGameStreamTypes>(gameId: ReversiGame['id'], type: K, value?: ReversiGameStreamTypes[K]): void => {
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
};
public publishNotesStream = (note: Packed<'Note'>): void => { public publishNotesStream = (note: Packed<'Note'>): void => {
this.publish('notesStream', null, note); this.publish('notesStream', null, note);
}; };
@ -124,6 +113,4 @@ export const publishAntennaStream = publisher.publishAntennaStream;
export const publishMessagingStream = publisher.publishMessagingStream; export const publishMessagingStream = publisher.publishMessagingStream;
export const publishGroupMessagingStream = publisher.publishGroupMessagingStream; export const publishGroupMessagingStream = publisher.publishGroupMessagingStream;
export const publishMessagingIndexStream = publisher.publishMessagingIndexStream; export const publishMessagingIndexStream = publisher.publishMessagingIndexStream;
export const publishReversiStream = publisher.publishReversiStream;
export const publishReversiGameStream = publisher.publishReversiGameStream;
export const publishAdminStream = publisher.publishAdminStream; export const publishAdminStream = publisher.publishAdminStream;

View file

@ -1522,6 +1522,11 @@ big-integer@^1.6.16:
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.48.tgz#8fd88bd1632cba4a1c8c3e3d7159f08bb95b4b9e" resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.48.tgz#8fd88bd1632cba4a1c8c3e3d7159f08bb95b4b9e"
integrity sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w== integrity sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w==
big-integer@^1.6.17:
version "1.6.51"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==
big.js@^5.2.2: big.js@^5.2.2:
version "5.2.2" version "5.2.2"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
@ -1532,6 +1537,14 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c"
integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow==
binary@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
integrity sha1-n2BVO8XOjDOG87VTz/R0Yq3sqnk=
dependencies:
buffers "~0.1.1"
chainsaw "~0.1.0"
bl@^4.0.1, bl@^4.0.3: bl@^4.0.1, bl@^4.0.3:
version "4.0.3" version "4.0.3"
resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489" resolved "https://registry.yarnpkg.com/bl/-/bl-4.0.3.tgz#12d6287adc29080e22a705e5764b2a9522cdc489"
@ -1546,6 +1559,11 @@ bluebird@^3.7.2:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bluebird@~3.4.1:
version "3.4.7"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
integrity sha1-9y12C+Cbf3bQjtj66Ysomo0F+rM=
blurhash@1.1.4: blurhash@1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.4.tgz#a7010ceb3019cd2c9809b17c910ebf6175d29244" resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.4.tgz#a7010ceb3019cd2c9809b17c910ebf6175d29244"
@ -1677,6 +1695,11 @@ buffer-from@^1.0.0, buffer-from@^1.1.1:
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
buffer-indexof-polyfill@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c"
integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==
buffer-writer@2.0.0: buffer-writer@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04"
@ -1707,6 +1730,11 @@ buffer@^6.0.3:
base64-js "^1.3.1" base64-js "^1.3.1"
ieee754 "^1.2.1" ieee754 "^1.2.1"
buffers@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb"
integrity sha1-skV5w77U1tOWru5tmorn9Ugqt7s=
bufferutil@^4.0.1: bufferutil@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.1.tgz#3a177e8e5819a1243fe16b63a199951a7ad8d4a7" resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.1.tgz#3a177e8e5819a1243fe16b63a199951a7ad8d4a7"
@ -1875,6 +1903,13 @@ cbor@8.1.0:
dependencies: dependencies:
nofilter "^3.1.0" nofilter "^3.1.0"
chainsaw@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98"
integrity sha1-XqtQsor+WAdNDVgpE4iCi15fvJg=
dependencies:
traverse ">=0.3.0 <0.4"
chalk@4.0.0: chalk@4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.0.0.tgz#6e98081ed2d17faab615eb52ac66ec1fe6209e72" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.0.0.tgz#6e98081ed2d17faab615eb52ac66ec1fe6209e72"
@ -2789,6 +2824,13 @@ dotenv@^8.2.0:
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw== integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==
duplexer2@~0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
dependencies:
readable-stream "^2.0.2"
ecc-jsbn@~0.1.1: ecc-jsbn@~0.1.1:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
@ -3480,6 +3522,16 @@ fsevents@~2.1.2:
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
fstream@^1.0.12:
version "1.0.12"
resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==
dependencies:
graceful-fs "^4.1.2"
inherits "~2.0.0"
mkdirp ">=0.5 0"
rimraf "2"
function-bind@^1.1.1: function-bind@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@ -3690,7 +3742,7 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.4:
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==
graceful-fs@^4.2.0: graceful-fs@^4.2.0, graceful-fs@^4.2.2:
version "4.2.8" version "4.2.8"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
@ -4007,7 +4059,7 @@ inflight@^1.0.4:
once "^1.3.0" once "^1.3.0"
wrappy "1" wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3:
version "2.0.4" version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@ -4800,6 +4852,11 @@ lilconfig@^2.0.3:
resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.3.tgz#68f3005e921dafbd2a2afb48379986aa6d2579fd" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.3.tgz#68f3005e921dafbd2a2afb48379986aa6d2579fd"
integrity sha512-EHKqr/+ZvdKCifpNrJCKxBTgk5XupZA3y/aCPY9mxfgBzmgh93Mt/WqjjQ38oMxXuvDokaKiM3lAgvSH2sjtHg== integrity sha512-EHKqr/+ZvdKCifpNrJCKxBTgk5XupZA3y/aCPY9mxfgBzmgh93Mt/WqjjQ38oMxXuvDokaKiM3lAgvSH2sjtHg==
listenercount@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937"
integrity sha1-hMinKrWcRyUyFIDJdeZQg0LnCTc=
loader-runner@^4.2.0: loader-runner@^4.2.0:
version "4.2.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.2.0.tgz#d7022380d66d14c5fb1d496b89864ebcfd478384" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.2.0.tgz#d7022380d66d14c5fb1d496b89864ebcfd478384"
@ -5204,7 +5261,7 @@ mkdirp-classic@^0.5.3:
resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
mkdirp@0.x, mkdirp@^0.5.4: mkdirp@0.x, "mkdirp@>=0.5 0", mkdirp@^0.5.4:
version "0.5.5" version "0.5.5"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
@ -6598,7 +6655,7 @@ readable-stream@1.1.x:
isarray "0.0.1" isarray "0.0.1"
string_decoder "~0.10.x" string_decoder "~0.10.x"
readable-stream@^2.0.0, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.2.2: readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@~2.3.6:
version "2.3.7" version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
@ -6780,6 +6837,13 @@ reusify@^1.0.4:
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
rimraf@2:
version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
dependencies:
glob "^7.1.3"
rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
@ -6914,7 +6978,7 @@ set-blocking@^2.0.0, set-blocking@~2.0.0:
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
setimmediate@^1.0.5: setimmediate@^1.0.5, setimmediate@~1.0.4:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=
@ -7584,6 +7648,11 @@ trace-redirect@1.0.6:
resolved "https://registry.yarnpkg.com/trace-redirect/-/trace-redirect-1.0.6.tgz#ac629b5bf8247d30dde5a35fe9811b811075b504" resolved "https://registry.yarnpkg.com/trace-redirect/-/trace-redirect-1.0.6.tgz#ac629b5bf8247d30dde5a35fe9811b811075b504"
integrity sha512-UUfa1DjjU5flcjMdaFIiIEGDTyu2y/IiMjOX4uGXa7meKBS4vD4f2Uy/tken9Qkd4Jsm4sRsfZcIIPqrRVF3Mg== integrity sha512-UUfa1DjjU5flcjMdaFIiIEGDTyu2y/IiMjOX4uGXa7meKBS4vD4f2Uy/tken9Qkd4Jsm4sRsfZcIIPqrRVF3Mg==
"traverse@>=0.3.0 <0.4":
version "0.3.9"
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9"
integrity sha1-cXuPIgzAu3tE5AUUwisui7xw2Lk=
ts-jest@^25.2.1: ts-jest@^25.2.1:
version "25.5.1" version "25.5.1"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-25.5.1.tgz#2913afd08f28385d54f2f4e828be4d261f4337c7" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-25.5.1.tgz#2913afd08f28385d54f2f4e828be4d261f4337c7"
@ -7827,6 +7896,22 @@ unpipe@1.0.0:
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
unzipper@0.10.11:
version "0.10.11"
resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e"
integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw==
dependencies:
big-integer "^1.6.17"
binary "~0.3.0"
bluebird "~3.4.1"
buffer-indexof-polyfill "~1.0.0"
duplexer2 "~0.1.4"
fstream "^1.0.12"
graceful-fs "^4.2.2"
listenercount "~1.0.1"
readable-stream "~2.3.6"
setimmediate "~1.0.4"
uri-js@^4.2.2: uri-js@^4.2.2:
version "4.2.2" version "4.2.2"
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"

View file

@ -14,6 +14,10 @@ module.exports = {
"plugin:vue/vue3-recommended" "plugin:vue/vue3-recommended"
], ],
rules: { rules: {
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// data の禁止理由: 抽象的すぎるため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
"id-denylist": ["error", "window", "data", "e"],
"vue/attributes-order": ["error", { "vue/attributes-order": ["error", {
"alphabetical": false "alphabetical": false
}], }],

View file

@ -21,7 +21,6 @@
"@types/katex": "0.11.1", "@types/katex": "0.11.1",
"@types/matter-js": "0.17.6", "@types/matter-js": "0.17.6",
"@types/mocha": "8.2.3", "@types/mocha": "8.2.3",
"@types/node": "16.11.12",
"@types/oauth": "0.9.1", "@types/oauth": "0.9.1",
"@types/parse5": "6.0.3", "@types/parse5": "6.0.3",
"@types/punycode": "2.1.0", "@types/punycode": "2.1.0",
@ -75,7 +74,7 @@
"ms": "2.1.3", "ms": "2.1.3",
"nested-property": "4.0.0", "nested-property": "4.0.0",
"parse5": "6.0.1", "parse5": "6.0.1",
"photoswipe": "git://github.com/dimsemenov/photoswipe#v5-beta", "photoswipe": "git+https://github.com/dimsemenov/photoswipe#v5-beta",
"portscanner": "2.2.0", "portscanner": "2.2.0",
"postcss": "8.4.5", "postcss": "8.4.5",
"postcss-loader": "6.2.1", "postcss-loader": "6.2.1",

View file

@ -10,13 +10,13 @@
<XCwButton v-model="showContent" :note="note"/> <XCwButton v-model="showContent" :note="note"/>
</p> </p>
<div v-show="note.cw == null || showContent" class="content"> <div v-show="note.cw == null || showContent" class="content">
<XSubNote-content class="text" :note="note"/> <MkNoteSubNoteContent class="text" :note="note"/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<template v-if="depth < 5"> <template v-if="depth < 5">
<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/> <MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/>
</template> </template>
<div v-else class="more"> <div v-else class="more">
<MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA> <MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA>
@ -24,63 +24,36 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import * as misskey from 'misskey-js';
import { notePage } from '@/filters/note'; import { notePage } from '@/filters/note';
import XNoteHeader from './note-header.vue'; import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue'; import MkNoteSubNoteContent from './sub-note-content.vue';
import XCwButton from './cw-button.vue'; import XCwButton from './cw-button.vue';
import * as os from '@/os'; import * as os from '@/os';
export default defineComponent({ const props = withDefaults(defineProps<{
name: 'XSub', note: misskey.entities.Note;
detail?: boolean;
components: { // how many notes are in between this one and the note being viewed in detail
XNoteHeader, depth?: number;
XSubNoteContent, }>(), {
XCwButton, depth: 1,
},
props: {
note: {
type: Object,
required: true
},
detail: {
type: Boolean,
required: false,
default: false
},
// how many notes are in between this one and the note being viewed in detail
depth: {
type: Number,
required: false,
default: 1
},
},
data() {
return {
showContent: false,
replies: [],
};
},
created() {
if (this.detail) {
os.api('notes/children', {
noteId: this.note.id,
limit: 5
}).then(replies => {
this.replies = replies;
});
}
},
methods: {
notePage,
}
}); });
let showContent = $ref(false);
let replies: misskey.entities.Note[] = $ref([]);
if (props.detail) {
os.api('notes/children', {
noteId: props.note.id,
limit: 5
}).then(res => {
replies = res;
});
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -90,7 +90,7 @@ onMounted(() => {
const update = () => { const update = () => {
if (enabled.value) { if (enabled.value) {
tick(); tick();
setTimeout(update, 1000); window.setTimeout(update, 1000);
} }
}; };
update(); update();

View file

@ -90,7 +90,7 @@ function requestRender() {
'error-callback': callback, 'error-callback': callback,
}); });
} else { } else {
setTimeout(requestRender, 1); window.setTimeout(requestRender, 1);
} }
} }

View file

@ -167,7 +167,7 @@ export default defineComponent({
// //
// 0 // 0
const clock = setInterval(() => { const clock = window.setInterval(() => {
if (prefixEl.value) { if (prefixEl.value) {
if (prefixEl.value.offsetWidth) { if (prefixEl.value.offsetWidth) {
inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
@ -181,7 +181,7 @@ export default defineComponent({
}, 100); }, 100);
onUnmounted(() => { onUnmounted(() => {
clearInterval(clock); window.clearInterval(clock);
}); });
}); });
}); });

View file

@ -117,7 +117,7 @@ export default defineComponent({
// //
// 0 // 0
const clock = setInterval(() => { const clock = window.setInterval(() => {
if (prefixEl.value) { if (prefixEl.value) {
if (prefixEl.value.offsetWidth) { if (prefixEl.value.offsetWidth) {
inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
@ -131,7 +131,7 @@ export default defineComponent({
}, 100); }, 100);
onUnmounted(() => { onUnmounted(() => {
clearInterval(clock); window.clearInterval(clock);
}); });
}); });
}); });

View file

@ -4,130 +4,114 @@
</a> </a>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { inject } from 'vue';
import * as os from '@/os'; import * as os from '@/os';
import copyToClipboard from '@/scripts/copy-to-clipboard'; import copyToClipboard from '@/scripts/copy-to-clipboard';
import { router } from '@/router'; import { router } from '@/router';
import { url } from '@/config'; import { url } from '@/config';
import { popout } from '@/scripts/popout'; import { popout as popout_ } from '@/scripts/popout';
import { ColdDeviceStorage } from '@/store'; import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
export default defineComponent({ const props = withDefaults(defineProps<{
inject: { to: string;
navHook: { activeClass?: null | string;
default: null behavior?: null | 'window' | 'browser' | 'modalWindow';
}, }>(), {
sideViewHook: { activeClass: null,
default: null behavior: null,
});
const navHook = inject('navHook', null);
const sideViewHook = inject('sideViewHook', null);
const active = $computed(() => {
if (props.activeClass == null) return false;
const resolved = router.resolve(props.to);
if (resolved.path === router.currentRoute.value.path) return true;
if (resolved.name == null) return false;
if (router.currentRoute.value.name == null) return false;
return resolved.name === router.currentRoute.value.name;
});
function onContextmenu(ev) {
const selection = window.getSelection();
if (selection && selection.toString() !== '') return;
os.contextMenu([{
type: 'label',
text: props.to,
}, {
icon: 'fas fa-window-maximize',
text: i18n.locale.openInWindow,
action: () => {
os.pageWindow(props.to);
} }
}, }, sideViewHook ? {
icon: 'fas fa-columns',
props: { text: i18n.locale.openInSideView,
to: { action: () => {
type: String, sideViewHook(props.to);
required: true,
},
activeClass: {
type: String,
required: false,
},
behavior: {
type: String,
required: false,
},
},
computed: {
active() {
if (this.activeClass == null) return false;
const resolved = router.resolve(this.to);
if (resolved.path == this.$route.path) return true;
if (resolved.name == null) return false;
if (this.$route.name == null) return false;
return resolved.name == this.$route.name;
} }
}, } : undefined, {
icon: 'fas fa-expand-alt',
text: i18n.locale.showInPage,
action: () => {
router.push(props.to);
}
}, null, {
icon: 'fas fa-external-link-alt',
text: i18n.locale.openInNewTab,
action: () => {
window.open(props.to, '_blank');
}
}, {
icon: 'fas fa-link',
text: i18n.locale.copyLink,
action: () => {
copyToClipboard(`${url}${props.to}`);
}
}], ev);
}
methods: { function openWindow() {
onContextmenu(e) { os.pageWindow(props.to);
if (window.getSelection().toString() !== '') return; }
os.contextMenu([{
type: 'label',
text: this.to,
}, {
icon: 'fas fa-window-maximize',
text: this.$ts.openInWindow,
action: () => {
os.pageWindow(this.to);
}
}, this.sideViewHook ? {
icon: 'fas fa-columns',
text: this.$ts.openInSideView,
action: () => {
this.sideViewHook(this.to);
}
} : undefined, {
icon: 'fas fa-expand-alt',
text: this.$ts.showInPage,
action: () => {
this.$router.push(this.to);
}
}, null, {
icon: 'fas fa-external-link-alt',
text: this.$ts.openInNewTab,
action: () => {
window.open(this.to, '_blank');
}
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: () => {
copyToClipboard(`${url}${this.to}`);
}
}], e);
},
window() { function modalWindow() {
os.pageWindow(this.to); os.modalPageWindow(props.to);
}, }
modalWindow() { function popout() {
os.modalPageWindow(this.to); popout_(props.to);
}, }
popout() { function nav() {
popout(this.to); if (props.behavior === 'browser') {
}, location.href = props.to;
return;
}
nav() { if (props.behavior) {
if (this.behavior === 'browser') { if (props.behavior === 'window') {
location.href = this.to; return openWindow();
return; } else if (props.behavior === 'modalWindow') {
} return modalWindow();
if (this.behavior) {
if (this.behavior === 'window') {
return this.window();
} else if (this.behavior === 'modalWindow') {
return this.modalWindow();
}
}
if (this.navHook) {
this.navHook(this.to);
} else {
if (this.$store.state.defaultSideView && this.sideViewHook && this.to !== '/') {
return this.sideViewHook(this.to);
}
if (this.$router.currentRoute.value.path === this.to) {
window.scroll({ top: 0, behavior: 'smooth' });
} else {
this.$router.push(this.to);
}
}
} }
} }
});
if (navHook) {
navHook(props.to);
} else {
if (defaultStore.state.defaultSideView && sideViewHook && props.to !== '/') {
return sideViewHook(props.to);
}
if (router.currentRoute.value.path === props.to) {
window.scroll({ top: 0, behavior: 'smooth' });
} else {
router.push(props.to);
}
}
}
</script> </script>

View file

@ -20,7 +20,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, ref } from 'vue'; import { defineComponent, ref } from 'vue';
import { Instance, instance } from '@/instance'; import { instance } from '@/instance';
import { host } from '@/config'; import { host } from '@/config';
import MkButton from '@/components/ui/button.vue'; import MkButton from '@/components/ui/button.vue';
import { defaultStore } from '@/store'; import { defaultStore } from '@/store';
@ -48,9 +48,9 @@ export default defineComponent({
showMenu.value = !showMenu.value; showMenu.value = !showMenu.value;
}; };
const choseAd = (): Instance['ads'][number] | null => { const choseAd = (): (typeof instance)['ads'][number] | null => {
if (props.specify) { if (props.specify) {
return props.specify as Instance['ads'][number]; return props.specify as (typeof instance)['ads'][number];
} }
const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? { const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {

View file

@ -1,74 +1,54 @@
<template> <template>
<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :title="acct(user)" @click="onClick"> <span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :title="acct(user)" @click="onClick">
<img class="inner" :src="url" decoding="async"/> <img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</span> </span>
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :to="userPage(user)" :title="acct(user)" :target="target"> <MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
<img class="inner" :src="url" decoding="async"/> <img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/> <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</MkA> </MkA>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { onMounted, watch } from 'vue';
import * as misskey from 'misskey-js';
import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash'; import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
import { acct, userPage } from '@/filters/user'; import { acct, userPage } from '@/filters/user';
import MkUserOnlineIndicator from '@/components/user-online-indicator.vue'; import MkUserOnlineIndicator from '@/components/user-online-indicator.vue';
import { defaultStore } from '@/store';
export default defineComponent({ const props = withDefaults(defineProps<{
components: { user: misskey.entities.User;
MkUserOnlineIndicator target?: string | null;
}, disableLink?: boolean;
props: { disablePreview?: boolean;
user: { showIndicator?: boolean;
type: Object, }>(), {
required: true target: null,
}, disableLink: false,
target: { disablePreview: false,
required: false, showIndicator: false,
default: null });
},
disableLink: { const emit = defineEmits<{
required: false, (e: 'click', ev: MouseEvent): void;
default: false }>();
},
disablePreview: { const url = defaultStore.state.disableShowingAnimatedImages
required: false, ? getStaticImageUrl(props.user.avatarUrl)
default: false : props.user.avatarUrl;
},
showIndicator: { function onClick(ev: MouseEvent) {
required: false, emit('click', ev);
default: false }
}
}, let color = $ref();
emits: ['click'],
computed: { watch(() => props.user.avatarBlurhash, () => {
cat(): boolean { color = extractAvgColorFromBlurhash(props.user.avatarBlurhash);
return this.user.isCat; }, {
}, immediate: true,
url(): string {
return this.$store.state.disableShowingAnimatedImages
? getStaticImageUrl(this.user.avatarUrl)
: this.user.avatarUrl;
},
},
watch: {
'user.avatarBlurhash'() {
if (this.$el == null) return;
this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
}
},
mounted() {
this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
},
methods: {
onClick(e) {
this.$emit('click', e);
},
acct,
userPage
}
}); });
</script> </script>

View file

@ -4,27 +4,17 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
export default defineComponent({ const props = withDefaults(defineProps<{
props: { inline?: boolean;
inline: { colored?: boolean;
type: Boolean, mini?: boolean;
required: false, }>(), {
default: false inline: false,
}, colored: true,
colored: { mini: false,
type: Boolean,
required: false,
default: true
},
mini: {
type: Boolean,
required: false,
default: false
},
}
}); });
</script> </script>

View file

@ -1,15 +1,23 @@
<template> <template>
<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/> <MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :customEmojis="customEmojis" :isNote="isNote" class="havbbuyv" :class="{ nowrap }"/>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import MfmCore from '@/components/mfm'; import MfmCore from '@/components/mfm';
export default defineComponent({ const props = withDefaults(defineProps<{
components: { text: string;
MfmCore plain?: boolean;
} nowrap?: boolean;
author?: any;
customEmojis?: any;
isNote?: boolean;
}>(), {
plain: false,
nowrap: false,
author: null,
isNote: true,
}); });
</script> </script>

View file

@ -45,7 +45,7 @@ export default defineComponent({
calc(); calc();
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => {
setTimeout(() => { window.setTimeout(() => {
calc(); calc();
}, 100); }, 100);
}); });

View file

@ -1,73 +1,57 @@
<template> <template>
<time :title="absolute"> <time :title="absolute">
<template v-if="mode == 'relative'">{{ relative }}</template> <template v-if="mode === 'relative'">{{ relative }}</template>
<template v-else-if="mode == 'absolute'">{{ absolute }}</template> <template v-else-if="mode === 'absolute'">{{ absolute }}</template>
<template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template> <template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
</time> </time>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { onUnmounted } from 'vue';
import { i18n } from '@/i18n';
export default defineComponent({ const props = withDefaults(defineProps<{
props: { time: Date | string;
time: { mode?: 'relative' | 'absolute' | 'detail';
type: [Date, String], }>(), {
required: true mode: 'relative',
},
mode: {
type: String,
default: 'relative'
}
},
data() {
return {
tickId: null,
now: new Date()
};
},
computed: {
_time(): Date {
return typeof this.time == 'string' ? new Date(this.time) : this.time;
},
absolute(): string {
return this._time.toLocaleString();
},
relative(): string {
const time = this._time;
const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/;
return (
ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
ago >= -1 ? this.$ts._ago.justNow :
ago < -1 ? this.$ts._ago.future :
this.$ts._ago.unknown);
}
},
created() {
if (this.mode == 'relative' || this.mode == 'detail') {
this.tickId = window.requestAnimationFrame(this.tick);
}
},
unmounted() {
if (this.mode === 'relative' || this.mode === 'detail') {
window.clearTimeout(this.tickId);
}
},
methods: {
tick() {
// TODO:
this.now = new Date();
this.tickId = setTimeout(() => {
window.requestAnimationFrame(this.tick);
}, 10000);
}
}
}); });
const _time = typeof props.time == 'string' ? new Date(props.time) : props.time;
const absolute = _time.toLocaleString();
let now = $ref(new Date());
const relative = $computed(() => {
const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
return (
ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
ago >= 86400 ? i18n.t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
ago >= -1 ? i18n.locale._ago.justNow :
ago < -1 ? i18n.locale._ago.future :
i18n.locale._ago.unknown);
});
function tick() {
// TODO:
now = new Date();
tickId = window.setTimeout(() => {
window.requestAnimationFrame(tick);
}, 10000);
}
let tickId: number;
if (props.mode === 'relative' || props.mode === 'detail') {
tickId = window.requestAnimationFrame(tick);
onUnmounted(() => {
window.clearTimeout(tickId);
});
}
</script> </script>

View file

@ -2,19 +2,14 @@
<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/> <Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import * as misskey from 'misskey-js';
export default defineComponent({ const props = withDefaults(defineProps<{
props: { user: misskey.entities.User;
user: { nowrap?: boolean;
type: Object, }>(), {
required: true nowrap: true,
},
nowrap: {
type: Boolean,
default: true
},
}
}); });
</script> </script>

View file

@ -1,8 +1,8 @@
<template> <template>
<MkModal ref="modal" :z-priority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')"> <MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')">
<div class="xubzgfga"> <div class="xubzgfga">
<header>{{ image.name }}</header> <header>{{ image.name }}</header>
<img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> <img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>
<footer> <footer>
<span>{{ image.type }}</span> <span>{{ image.type }}</span>
<span>{{ bytes(image.size) }}</span> <span>{{ bytes(image.size) }}</span>
@ -12,31 +12,23 @@
</MkModal> </MkModal>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import * as misskey from 'misskey-js';
import bytes from '@/filters/bytes'; import bytes from '@/filters/bytes';
import number from '@/filters/number'; import number from '@/filters/number';
import MkModal from '@/components/ui/modal.vue'; import MkModal from '@/components/ui/modal.vue';
export default defineComponent({ const props = withDefaults(defineProps<{
components: { image: misskey.entities.DriveFile;
MkModal, }>(), {
},
props: {
image: {
type: Object,
required: true
},
},
emits: ['closed'],
methods: {
bytes,
number,
}
}); });
const emit = defineEmits<{
(e: 'closed'): void;
}>();
const modal = $ref<InstanceType<typeof MkModal>>();
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -5,67 +5,43 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { onMounted } from 'vue';
import { decode } from 'blurhash'; import { decode } from 'blurhash';
export default defineComponent({ const props = withDefaults(defineProps<{
props: { src?: string | null;
src: { hash: string;
type: String, alt?: string;
required: false, title?: string | null;
default: null size?: number;
}, cover?: boolean;
hash: { }>(), {
type: String, src: null,
required: true alt: '',
}, title: null,
alt: { size: 64,
type: String, cover: true,
required: false, });
default: '',
},
title: {
type: String,
required: false,
default: null,
},
size: {
type: Number,
required: false,
default: 64
},
cover: {
type: Boolean,
required: false,
default: true,
}
},
data() { const canvas = $ref<HTMLCanvasElement>();
return { let loaded = $ref(false);
loaded: false,
};
},
mounted() { function draw() {
this.draw(); if (props.hash == null) return;
}, const pixels = decode(props.hash, props.size, props.size);
const ctx = canvas.getContext('2d');
const imageData = ctx!.createImageData(props.size, props.size);
imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0);
}
methods: { function onLoad() {
draw() { loaded = true;
if (this.hash == null) return; }
const pixels = decode(this.hash, this.size, this.size);
const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d');
const imageData = ctx!.createImageData(this.size, this.size);
imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0);
},
onLoad() { onMounted(() => {
this.loaded = true; draw();
}
}
}); });
</script> </script>

View file

@ -1,41 +1,22 @@
<template> <template>
<div class="hpaizdrt" :style="bg"> <div class="hpaizdrt" :style="bg">
<img v-if="info.faviconUrl" class="icon" :src="info.faviconUrl"/> <img v-if="instance.faviconUrl" class="icon" :src="instance.faviconUrl"/>
<span class="name">{{ info.name }}</span> <span class="name">{{ instance.name }}</span>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import { instanceName } from '@/config';
export default defineComponent({ const props = defineProps<{
props: { instance: any; // TODO
instance: { }>();
type: Object,
required: false
},
},
data() { const themeColor = props.instance.themeColor || '#777777';
return {
info: this.instance || {
faviconUrl: '/favicon.ico',
name: instanceName,
themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content
}
}
},
computed: { const bg = {
bg(): any { background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
const themeColor = this.info.themeColor || '#777777'; };
return {
background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
};
}
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -1,82 +1,36 @@
<template> <template>
<component :is="self ? 'MkA' : 'a'" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" <component :is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
:title="url" :title="url"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
> >
<slot></slot> <slot></slot>
<i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i> <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i>
</component> </component>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import { url as local } from '@/config'; import { url as local } from '@/config';
import { isTouchUsing } from '@/scripts/touch'; import { useTooltip } from '@/scripts/use-tooltip';
import * as os from '@/os'; import * as os from '@/os';
export default defineComponent({ const props = withDefaults(defineProps<{
props: { url: string;
url: { rel?: null | string;
type: String, }>(), {
required: true, });
},
rel: {
type: String,
required: false,
}
},
data() {
const self = this.url.startsWith(local);
return {
local,
self: self,
attr: self ? 'to' : 'href',
target: self ? null : '_blank',
showTimer: null,
hideTimer: null,
checkTimer: null,
close: null,
};
},
methods: {
async showPreview() {
if (!document.body.contains(this.$el)) return;
if (this.close) return;
const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), { const self = props.url.startsWith(local);
url: this.url, const attr = self ? 'to' : 'href';
source: this.$el const target = self ? null : '_blank';
});
this.close = () => { const el = $ref();
dispose();
};
this.checkTimer = setInterval(() => { useTooltip($$(el), (showing) => {
if (!document.body.contains(this.$el)) this.closePreview(); os.popup(import('@/components/url-preview-popup.vue'), {
}, 1000); showing,
}, url: props.url,
closePreview() { source: el,
if (this.close) { }, {}, 'closed');
clearInterval(this.checkTimer);
this.close();
this.close = null;
}
},
onMouseover() {
if (isTouchUsing) return;
clearTimeout(this.showTimer);
clearTimeout(this.hideTimer);
this.showTimer = setTimeout(this.showPreview, 500);
},
onMouseleave() {
if (isTouchUsing) return;
clearTimeout(this.showTimer);
clearTimeout(this.hideTimer);
this.hideTimer = setTimeout(this.closePreview, 500);
}
}
}); });
</script> </script>

View file

@ -6,7 +6,7 @@
<span>{{ $ts.clickToShow }}</span> <span>{{ $ts.clickToShow }}</span>
</div> </div>
<div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio"> <div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio">
<audio ref="audio" <audio ref="audioEl"
class="audio" class="audio"
:src="media.url" :src="media.url"
:title="media.name" :title="media.name"
@ -25,34 +25,26 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { onMounted } from 'vue';
import * as os from '@/os'; import * as misskey from 'misskey-js';
import { ColdDeviceStorage } from '@/store'; import { ColdDeviceStorage } from '@/store';
export default defineComponent({ const props = withDefaults(defineProps<{
props: { media: misskey.entities.DriveFile;
media: { }>(), {
type: Object, });
required: true
} const audioEl = $ref<HTMLAudioElement | null>();
}, let hide = $ref(true);
data() {
return { function volumechange() {
hide: true, if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume);
}; }
},
mounted() { onMounted(() => {
const audioTag = this.$refs.audio as HTMLAudioElement; if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume');
if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume'); });
},
methods: {
volumechange() {
const audioTag = this.$refs.audio as HTMLAudioElement;
ColdDeviceStorage.set('mediaVolume', audioTag.volume);
},
},
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -63,10 +63,10 @@ export default defineComponent({
this.draw(); this.draw();
// VueWatch // VueWatch
this.clock = setInterval(this.draw, 1000); this.clock = window.setInterval(this.draw, 1000);
}, },
beforeUnmount() { beforeUnmount() {
clearInterval(this.clock); window.clearInterval(this.clock);
}, },
methods: { methods: {
draw() { draw() {

View file

@ -8,8 +8,8 @@
:tabindex="!isDeleted ? '-1' : null" :tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }" :class="{ renote: isRenote }"
> >
<XSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/> <MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<div v-if="isRenote" class="renote"> <div v-if="isRenote" class="renote">
<MkAvatar class="avatar" :user="note.user"/> <MkAvatar class="avatar" :user="note.user"/>
<i class="fas fa-retweet"></i> <i class="fas fa-retweet"></i>
@ -107,7 +107,7 @@
</footer> </footer>
</div> </div>
</article> </article>
<XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> <MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
</div> </div>
<div v-else class="_panel muted" @click="muted = false"> <div v-else class="_panel muted" @click="muted = false">
<I18n :src="$ts.userSaysSomething" tag="small"> <I18n :src="$ts.userSaysSomething" tag="small">
@ -120,765 +120,171 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { sum } from '@/scripts/array'; import * as misskey from 'misskey-js';
import XSub from './note.sub.vue'; import MkNoteSub from './MkNoteSub.vue';
import XNoteHeader from './note-header.vue';
import XNoteSimple from './note-simple.vue'; import XNoteSimple from './note-simple.vue';
import XReactionsViewer from './reactions-viewer.vue'; import XReactionsViewer from './reactions-viewer.vue';
import XMediaList from './media-list.vue'; import XMediaList from './media-list.vue';
import XCwButton from './cw-button.vue'; import XCwButton from './cw-button.vue';
import XPoll from './poll.vue'; import XPoll from './poll.vue';
import XRenoteButton from './renote-button.vue'; import XRenoteButton from './renote-button.vue';
import MkUrlPreview from '@/components/url-preview.vue';
import MkInstanceTicker from '@/components/instance-ticker.vue';
import { pleaseLogin } from '@/scripts/please-login'; import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus';
import { url } from '@/config';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { checkWordMute } from '@/scripts/check-word-mute'; import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user'; import { userPage } from '@/filters/user';
import { notePage } from '@/filters/note'; import { notePage } from '@/filters/note';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { defaultStore, noteViewInterruptors } from '@/store';
import { noteActions, noteViewInterruptors } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker'; import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { $i } from '@/account';
// TODO: note.vue import { i18n } from '@/i18n';
export default defineComponent({ import { getNoteMenu } from '@/scripts/get-note-menu';
components: { import { useNoteCapture } from '@/scripts/use-note-capture';
XSub,
XNoteHeader, const props = defineProps<{
XNoteSimple, note: misskey.entities.Note;
XReactionsViewer, pinned?: boolean;
XMediaList, }>();
XCwButton,
XPoll, const inChannel = inject('inChannel', null);
XRenoteButton,
MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), const isRenote = (
MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), props.note.renote != null &&
}, props.note.text == null &&
props.note.fileIds.length === 0 &&
inject: { props.note.poll == null
inChannel: { );
default: null
}, const el = ref<HTMLElement>();
}, const menuButton = ref<HTMLElement>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
props: { const renoteTime = ref<HTMLElement>();
note: { const reactButton = ref<HTMLElement>();
type: Object, let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
required: true const isMyRenote = $i && ($i.id === props.note.userId);
}, const showContent = ref(false);
}, const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
emits: ['update:note'], const translation = ref(null);
const translating = ref(false);
data() { const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
return { const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
connection: null, const conversation = ref<misskey.entities.Note[]>([]);
conversation: [], const replies = ref<misskey.entities.Note[]>([]);
replies: [],
showContent: false, const keymap = {
isDeleted: false, 'r': () => reply(true),
muted: false, 'e|a|plus': () => react(true),
translation: null, 'q': () => renoteButton.value.renote(true),
translating: false, 'esc': blur,
notePage, 'm|o': () => menu(true),
}; 's': () => showContent.value != showContent.value,
}, };
computed: { useNoteCapture({
rs() { appearNote: $$(appearNote),
return this.$store.state.reactions; rootEl: el,
},
keymap(): any {
return {
'r': () => this.reply(true),
'e|a|plus': () => this.react(true),
'q': () => this.$refs.renoteButton.renote(true),
'f|b': this.favorite,
'delete|ctrl+d': this.del,
'ctrl+q': this.renoteDirectly,
'up|k|shift+tab': this.focusBefore,
'down|j|tab': this.focusAfter,
'esc': this.blur,
'm|o': () => this.menu(true),
's': this.toggleShowContent,
'1': () => this.reactDirectly(this.rs[0]),
'2': () => this.reactDirectly(this.rs[1]),
'3': () => this.reactDirectly(this.rs[2]),
'4': () => this.reactDirectly(this.rs[3]),
'5': () => this.reactDirectly(this.rs[4]),
'6': () => this.reactDirectly(this.rs[5]),
'7': () => this.reactDirectly(this.rs[6]),
'8': () => this.reactDirectly(this.rs[7]),
'9': () => this.reactDirectly(this.rs[8]),
'0': () => this.reactDirectly(this.rs[9]),
};
},
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
appearNote(): any {
return this.isRenote ? this.note.renote : this.note;
},
isMyNote(): boolean {
return this.$i && (this.$i.id === this.appearNote.userId);
},
isMyRenote(): boolean {
return this.$i && (this.$i.id === this.note.userId);
},
reactionsCount(): number {
return this.appearNote.reactions
? sum(Object.values(this.appearNote.reactions))
: 0;
},
urls(): string[] {
if (this.appearNote.text) {
return extractUrlFromMfm(mfm.parse(this.appearNote.text));
} else {
return null;
}
},
showTicker() {
if (this.$store.state.instanceTicker === 'always') return true;
if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
return false;
}
},
async created() {
if (this.$i) {
this.connection = stream;
}
this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
// plugin
if (noteViewInterruptors.length > 0) {
let result = this.note;
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
}
this.$emit('update:note', Object.freeze(result));
}
os.api('notes/children', {
noteId: this.appearNote.id,
limit: 30
}).then(replies => {
this.replies = replies;
});
if (this.appearNote.replyId) {
os.api('notes/conversation', {
noteId: this.appearNote.replyId
}).then(conversation => {
this.conversation = conversation.reverse();
});
}
},
mounted() {
this.capture(true);
if (this.$i) {
this.connection.on('_connected_', this.onStreamConnected);
}
},
beforeUnmount() {
this.decapture(true);
if (this.$i) {
this.connection.off('_connected_', this.onStreamConnected);
}
},
methods: {
updateAppearNote(v) {
this.$emit('update:note', Object.freeze(this.isRenote ? {
...this.note,
renote: {
...this.note.renote,
...v
}
} : {
...this.note,
...v
}));
},
readPromo() {
os.api('promo/read', {
noteId: this.appearNote.id
});
this.isDeleted = true;
},
capture(withHandler = false) {
if (this.$i) {
// TODO: sr
this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
}
},
decapture(withHandler = false) {
if (this.$i) {
this.connection.send('un', {
id: this.appearNote.id
});
if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
}
},
onStreamConnected() {
this.capture();
},
onStreamNoteUpdated(data) {
const { type, id, body } = data;
if (id !== this.appearNote.id) return;
switch (type) {
case 'reacted': {
const reaction = body.reaction;
// DeepShallown.reactions[reaction] = hoge()
let n = {
...this.appearNote,
};
if (body.emoji) {
const emojis = this.appearNote.emojis || [];
if (!emojis.includes(body.emoji)) {
n.emojis = [...emojis, body.emoji];
}
}
// TODO: reactions || {}
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
// Increment the count
n.reactions = {
...this.appearNote.reactions,
[reaction]: currentCount + 1
};
if (body.userId === this.$i.id) {
n.myReaction = reaction;
}
this.updateAppearNote(n);
break;
}
case 'unreacted': {
const reaction = body.reaction;
// DeepShallown.reactions[reaction] = hoge()
let n = {
...this.appearNote,
};
// TODO: reactions || {}
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
// Decrement the count
n.reactions = {
...this.appearNote.reactions,
[reaction]: Math.max(0, currentCount - 1)
};
if (body.userId === this.$i.id) {
n.myReaction = null;
}
this.updateAppearNote(n);
break;
}
case 'pollVoted': {
const choice = body.choice;
// DeepShallown.reactions[reaction] = hoge()
let n = {
...this.appearNote,
};
const choices = [...this.appearNote.poll.choices];
choices[choice] = {
...choices[choice],
votes: choices[choice].votes + 1,
...(body.userId === this.$i.id ? {
isVoted: true
} : {})
};
n.poll = {
...this.appearNote.poll,
choices: choices
};
this.updateAppearNote(n);
break;
}
case 'deleted': {
this.isDeleted = true;
break;
}
}
},
reply(viaKeyboard = false) {
pleaseLogin();
os.post({
reply: this.appearNote,
animation: !viaKeyboard,
}, () => {
this.focus();
});
},
renoteDirectly() {
os.apiWithDialog('notes/create', {
renoteId: this.appearNote.id
}, undefined, (res: any) => {
os.alert({
type: 'success',
text: this.$ts.renoted,
});
}, (e: Error) => {
if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
os.alert({
type: 'error',
text: this.$ts.cantRenote,
});
} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
os.alert({
type: 'error',
text: this.$ts.cantReRenote,
});
}
});
},
react(viaKeyboard = false) {
pleaseLogin();
this.blur();
reactionPicker.show(this.$refs.reactButton, reaction => {
os.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
}, () => {
this.focus();
});
},
reactDirectly(reaction) {
os.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
},
undoReact(note) {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id
});
},
favorite() {
pleaseLogin();
os.apiWithDialog('notes/favorites/create', {
noteId: this.appearNote.id
}, undefined, (res: any) => {
os.alert({
type: 'success',
text: this.$ts.favorited,
});
}, (e: Error) => {
if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
os.alert({
type: 'error',
text: this.$ts.alreadyFavorited,
});
} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
os.alert({
type: 'error',
text: this.$ts.cantFavorite,
});
}
});
},
del() {
os.confirm({
type: 'warning',
text: this.$ts.noteDeleteConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: this.appearNote.id
});
});
},
delEdit() {
os.confirm({
type: 'warning',
text: this.$ts.deleteAndEditConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: this.appearNote.id
});
os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
});
},
toggleFavorite(favorite: boolean) {
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
noteId: this.appearNote.id
});
},
toggleWatch(watch: boolean) {
os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
noteId: this.appearNote.id
});
},
toggleThreadMute(mute: boolean) {
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
noteId: this.appearNote.id
});
},
getMenu() {
let menu;
if (this.$i) {
const statePromise = os.api('notes/state', {
noteId: this.appearNote.id
});
menu = [{
icon: 'fas fa-copy',
text: this.$ts.copyContent,
action: this.copyContent
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: this.copyLink
}, (this.appearNote.url || this.appearNote.uri) ? {
icon: 'fas fa-external-link-square-alt',
text: this.$ts.showOnRemote,
action: () => {
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
}
} : undefined,
{
icon: 'fas fa-share-alt',
text: this.$ts.share,
action: this.share
},
this.$instance.translatorAvailable ? {
icon: 'fas fa-language',
text: this.$ts.translate,
action: this.translate
} : undefined,
null,
statePromise.then(state => state.isFavorited ? {
icon: 'fas fa-star',
text: this.$ts.unfavorite,
action: () => this.toggleFavorite(false)
} : {
icon: 'fas fa-star',
text: this.$ts.favorite,
action: () => this.toggleFavorite(true)
}),
{
icon: 'fas fa-paperclip',
text: this.$ts.clip,
action: () => this.clip()
},
(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
icon: 'fas fa-eye-slash',
text: this.$ts.unwatch,
action: () => this.toggleWatch(false)
} : {
icon: 'fas fa-eye',
text: this.$ts.watch,
action: () => this.toggleWatch(true)
}) : undefined,
statePromise.then(state => state.isMutedThread ? {
icon: 'fas fa-comment-slash',
text: this.$ts.unmuteThread,
action: () => this.toggleThreadMute(false)
} : {
icon: 'fas fa-comment-slash',
text: this.$ts.muteThread,
action: () => this.toggleThreadMute(true)
}),
this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
icon: 'fas fa-thumbtack',
text: this.$ts.unpin,
action: () => this.togglePin(false)
} : {
icon: 'fas fa-thumbtack',
text: this.$ts.pin,
action: () => this.togglePin(true)
} : undefined,
/*...(this.$i.isModerator || this.$i.isAdmin ? [
null,
{
icon: 'fas fa-bullhorn',
text: this.$ts.promote,
action: this.promote
}]
: []
),*/
...(this.appearNote.userId != this.$i.id ? [
null,
{
icon: 'fas fa-exclamation-circle',
text: this.$ts.reportAbuse,
action: () => {
const u = `${url}/notes/${this.appearNote.id}`;
os.popup(import('@/components/abuse-report-window.vue'), {
user: this.appearNote.user,
initialComment: `Note: ${u}\n-----\n`
}, {}, 'closed');
}
}]
: []
),
...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
null,
this.appearNote.userId == this.$i.id ? {
icon: 'fas fa-edit',
text: this.$ts.deleteAndEdit,
action: this.delEdit
} : undefined,
{
icon: 'fas fa-trash-alt',
text: this.$ts.delete,
danger: true,
action: this.del
}]
: []
)]
.filter(x => x !== undefined);
} else {
menu = [{
icon: 'fas fa-copy',
text: this.$ts.copyContent,
action: this.copyContent
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: this.copyLink
}, (this.appearNote.url || this.appearNote.uri) ? {
icon: 'fas fa-external-link-square-alt',
text: this.$ts.showOnRemote,
action: () => {
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
}
} : undefined]
.filter(x => x !== undefined);
}
if (noteActions.length > 0) {
menu = menu.concat([null, ...noteActions.map(action => ({
icon: 'fas fa-plug',
text: action.title,
action: () => {
action.handler(this.appearNote);
}
}))]);
}
return menu;
},
onContextmenu(e) {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(e.target)) return;
if (window.getSelection().toString() !== '') return;
if (this.$store.state.useReactionPickerForContextMenu) {
e.preventDefault();
this.react();
} else {
os.contextMenu(this.getMenu(), e).then(this.focus);
}
},
menu(viaKeyboard = false) {
os.popupMenu(this.getMenu(), this.$refs.menuButton, {
viaKeyboard
}).then(this.focus);
},
showRenoteMenu(viaKeyboard = false) {
if (!this.isMyRenote) return;
os.popupMenu([{
text: this.$ts.unrenote,
icon: 'fas fa-trash-alt',
danger: true,
action: () => {
os.api('notes/delete', {
noteId: this.note.id
});
this.isDeleted = true;
}
}], this.$refs.renoteTime, {
viaKeyboard: viaKeyboard
});
},
toggleShowContent() {
this.showContent = !this.showContent;
},
copyContent() {
copyToClipboard(this.appearNote.text);
os.success();
},
copyLink() {
copyToClipboard(`${url}/notes/${this.appearNote.id}`);
os.success();
},
togglePin(pin: boolean) {
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
noteId: this.appearNote.id
}, undefined, null, e => {
if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
os.alert({
type: 'error',
text: this.$ts.pinLimitExceeded
});
}
});
},
async clip() {
const clips = await os.api('clips/list');
os.popupMenu([{
icon: 'fas fa-plus',
text: this.$ts.createNew,
action: async () => {
const { canceled, result } = await os.form(this.$ts.createNewClip, {
name: {
type: 'string',
label: this.$ts.name
},
description: {
type: 'string',
required: false,
multiline: true,
label: this.$ts.description
},
isPublic: {
type: 'boolean',
label: this.$ts.public,
default: false
}
});
if (canceled) return;
const clip = await os.apiWithDialog('clips/create', result);
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
}
}, null, ...clips.map(clip => ({
text: clip.name,
action: () => {
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
}
}))], this.$refs.menuButton, {
}).then(this.focus);
},
async promote() {
const { canceled, result: days } = await os.inputNumber({
title: this.$ts.numberOfDays,
});
if (canceled) return;
os.apiWithDialog('admin/promo/create', {
noteId: this.appearNote.id,
expiresAt: Date.now() + (86400000 * days)
});
},
share() {
navigator.share({
title: this.$t('noteOf', { user: this.appearNote.user.name }),
text: this.appearNote.text,
url: `${url}/notes/${this.appearNote.id}`
});
},
async translate() {
if (this.translation != null) return;
this.translating = true;
const res = await os.api('notes/translate', {
noteId: this.appearNote.id,
targetLang: localStorage.getItem('lang') || navigator.language,
});
this.translating = false;
this.translation = res;
},
focus() {
this.$el.focus();
},
blur() {
this.$el.blur();
},
focusBefore() {
focusPrev(this.$el);
},
focusAfter() {
focusNext(this.$el);
},
userPage
}
}); });
function reply(viaKeyboard = false): void {
pleaseLogin();
os.post({
reply: appearNote,
animation: !viaKeyboard,
}, () => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
blur();
reactionPicker.show(reactButton.value, reaction => {
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction
});
}, () => {
focus();
});
}
function undoReact(note): void {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id
});
}
function onContextmenu(e): void {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(e.target)) return;
if (window.getSelection().toString() !== '') return;
if (defaultStore.state.useReactionPickerForContextMenu) {
e.preventDefault();
react();
} else {
os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
}
}
function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
viaKeyboard
}).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return;
os.popupMenu([{
text: i18n.locale.unrenote,
icon: 'fas fa-trash-alt',
danger: true,
action: () => {
os.api('notes/delete', {
noteId: props.note.id
});
isDeleted.value = true;
}
}], renoteTime.value, {
viaKeyboard: viaKeyboard
});
}
function focus() {
el.value.focus();
}
function blur() {
el.value.blur();
}
os.api('notes/children', {
noteId: appearNote.id,
limit: 30
}).then(res => {
replies.value = res;
});
if (appearNote.replyId) {
os.api('notes/conversation', {
noteId: appearNote.replyId
}).then(res => {
conversation.value = res.reverse();
});
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -19,30 +19,16 @@
</header> </header>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import * as misskey from 'misskey-js';
import { notePage } from '@/filters/note'; import { notePage } from '@/filters/note';
import { userPage } from '@/filters/user'; import { userPage } from '@/filters/user';
import * as os from '@/os';
export default defineComponent({ defineProps<{
props: { note: misskey.entities.Note;
note: { pinned?: boolean;
type: Object, }>();
required: true
},
},
data() {
return {
};
},
methods: {
notePage,
userPage
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -14,20 +14,12 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
export default defineComponent({ const props = defineProps<{
components: { text: string;
}, }>();
props: {
text: {
type: String,
required: true
}
},
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -9,40 +9,26 @@
<XCwButton v-model="showContent" :note="note"/> <XCwButton v-model="showContent" :note="note"/>
</p> </p>
<div v-show="note.cw == null || showContent" class="content"> <div v-show="note.cw == null || showContent" class="content">
<XSubNote-content class="text" :note="note"/> <MkNoteSubNoteContent class="text" :note="note"/>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import * as misskey from 'misskey-js';
import XNoteHeader from './note-header.vue'; import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue'; import MkNoteSubNoteContent from './sub-note-content.vue';
import XCwButton from './cw-button.vue'; import XCwButton from './cw-button.vue';
import * as os from '@/os';
export default defineComponent({ const props = defineProps<{
components: { note: misskey.entities.Note;
XNoteHeader, pinned?: boolean;
XSubNoteContent, }>();
XCwButton,
},
props: { const showContent = $ref(false);
note: {
type: Object,
required: true
}
},
data() {
return {
showContent: false
};
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -2,20 +2,21 @@
<div <div
v-if="!muted" v-if="!muted"
v-show="!isDeleted" v-show="!isDeleted"
ref="el"
v-hotkey="keymap" v-hotkey="keymap"
v-size="{ max: [500, 450, 350, 300] }" v-size="{ max: [500, 450, 350, 300] }"
class="tkcbzcuz" class="tkcbzcuz"
:tabindex="!isDeleted ? '-1' : null" :tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }" :class="{ renote: isRenote }"
> >
<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/> <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div> <div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ i18n.locale.pinnedNote }}</div>
<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div> <div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ i18n.locale.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.locale.hideThisNote }} <i class="fas fa-times"></i></button></div>
<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div> <div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ i18n.locale.featured }}</div>
<div v-if="isRenote" class="renote"> <div v-if="isRenote" class="renote">
<MkAvatar class="avatar" :user="note.user"/> <MkAvatar class="avatar" :user="note.user"/>
<i class="fas fa-retweet"></i> <i class="fas fa-retweet"></i>
<I18n :src="$ts.renotedBy" tag="span"> <I18n :src="i18n.locale.renotedBy" tag="span">
<template #user> <template #user>
<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)"> <MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
<MkUserName :user="note.user"/> <MkUserName :user="note.user"/>
@ -47,7 +48,7 @@
</p> </p>
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }"> <div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }">
<div class="text"> <div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.locale.private }})</span>
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA> <MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/> <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<a v-if="appearNote.renote != null" class="rp">RN:</a> <a v-if="appearNote.renote != null" class="rp">RN:</a>
@ -66,7 +67,7 @@
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div> <div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
<button v-if="collapsed" class="fade _button" @click="collapsed = false"> <button v-if="collapsed" class="fade _button" @click="collapsed = false">
<span>{{ $ts.showMore }}</span> <span>{{ i18n.locale.showMore }}</span>
</button> </button>
</div> </div>
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA> <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
@ -93,7 +94,7 @@
</article> </article>
</div> </div>
<div v-else class="muted" @click="muted = false"> <div v-else class="muted" @click="muted = false">
<I18n :src="$ts.userSaysSomething" tag="small"> <I18n :src="i18n.locale.userSaysSomething" tag="small">
<template #name> <template #name>
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)"> <MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/> <MkUserName :user="appearNote.user"/>
@ -103,11 +104,11 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { sum } from '@/scripts/array'; import * as misskey from 'misskey-js';
import XSub from './note.sub.vue'; import MkNoteSub from './MkNoteSub.vue';
import XNoteHeader from './note-header.vue'; import XNoteHeader from './note-header.vue';
import XNoteSimple from './note-simple.vue'; import XNoteSimple from './note-simple.vue';
import XReactionsViewer from './reactions-viewer.vue'; import XReactionsViewer from './reactions-viewer.vue';
@ -115,745 +116,164 @@ import XMediaList from './media-list.vue';
import XCwButton from './cw-button.vue'; import XCwButton from './cw-button.vue';
import XPoll from './poll.vue'; import XPoll from './poll.vue';
import XRenoteButton from './renote-button.vue'; import XRenoteButton from './renote-button.vue';
import MkUrlPreview from '@/components/url-preview.vue';
import MkInstanceTicker from '@/components/instance-ticker.vue';
import { pleaseLogin } from '@/scripts/please-login'; import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus'; import { focusPrev, focusNext } from '@/scripts/focus';
import { url } from '@/config';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { checkWordMute } from '@/scripts/check-word-mute'; import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user'; import { userPage } from '@/filters/user';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream'; import { defaultStore, noteViewInterruptors } from '@/store';
import { noteActions, noteViewInterruptors } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker'; import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { $i } from '@/account';
export default defineComponent({ import { i18n } from '@/i18n';
components: { import { getNoteMenu } from '@/scripts/get-note-menu';
XSub, import { useNoteCapture } from '@/scripts/use-note-capture';
XNoteHeader,
XNoteSimple, const props = defineProps<{
XReactionsViewer, note: misskey.entities.Note;
XMediaList, pinned?: boolean;
XCwButton, }>();
XPoll,
XRenoteButton, const inChannel = inject('inChannel', null);
MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), const isRenote = (
}, props.note.renote != null &&
props.note.text == null &&
inject: { props.note.fileIds.length === 0 &&
inChannel: { props.note.poll == null
default: null );
},
}, const el = ref<HTMLElement>();
const menuButton = ref<HTMLElement>();
props: { const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
note: { const renoteTime = ref<HTMLElement>();
type: Object, const reactButton = ref<HTMLElement>();
required: true let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
}, const isMyRenote = $i && ($i.id === props.note.userId);
pinned: { const showContent = ref(false);
type: Boolean, const collapsed = ref(appearNote.cw == null && appearNote.text != null && (
required: false, (appearNote.text.split('\n').length > 9) ||
default: false (appearNote.text.length > 500)
}, ));
}, const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
emits: ['update:note'], const translation = ref(null);
const translating = ref(false);
data() { const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
return { const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
connection: null,
replies: [], const keymap = {
showContent: false, 'r': () => reply(true),
collapsed: false, 'e|a|plus': () => react(true),
isDeleted: false, 'q': () => renoteButton.value.renote(true),
muted: false, 'up|k|shift+tab': focusBefore,
translation: null, 'down|j|tab': focusAfter,
translating: false, 'esc': blur,
}; 'm|o': () => menu(true),
}, 's': () => showContent.value != showContent.value,
};
computed: {
rs() { useNoteCapture({
return this.$store.state.reactions; appearNote: $$(appearNote),
}, rootEl: el,
keymap(): any {
return {
'r': () => this.reply(true),
'e|a|plus': () => this.react(true),
'q': () => this.$refs.renoteButton.renote(true),
'f|b': this.favorite,
'delete|ctrl+d': this.del,
'ctrl+q': this.renoteDirectly,
'up|k|shift+tab': this.focusBefore,
'down|j|tab': this.focusAfter,
'esc': this.blur,
'm|o': () => this.menu(true),
's': this.toggleShowContent,
'1': () => this.reactDirectly(this.rs[0]),
'2': () => this.reactDirectly(this.rs[1]),
'3': () => this.reactDirectly(this.rs[2]),
'4': () => this.reactDirectly(this.rs[3]),
'5': () => this.reactDirectly(this.rs[4]),
'6': () => this.reactDirectly(this.rs[5]),
'7': () => this.reactDirectly(this.rs[6]),
'8': () => this.reactDirectly(this.rs[7]),
'9': () => this.reactDirectly(this.rs[8]),
'0': () => this.reactDirectly(this.rs[9]),
};
},
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
appearNote(): any {
return this.isRenote ? this.note.renote : this.note;
},
isMyNote(): boolean {
return this.$i && (this.$i.id === this.appearNote.userId);
},
isMyRenote(): boolean {
return this.$i && (this.$i.id === this.note.userId);
},
reactionsCount(): number {
return this.appearNote.reactions
? sum(Object.values(this.appearNote.reactions))
: 0;
},
urls(): string[] {
if (this.appearNote.text) {
return extractUrlFromMfm(mfm.parse(this.appearNote.text));
} else {
return null;
}
},
showTicker() {
if (this.$store.state.instanceTicker === 'always') return true;
if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
return false;
}
},
async created() {
if (this.$i) {
this.connection = stream;
}
this.collapsed = this.appearNote.cw == null && this.appearNote.text && (
(this.appearNote.text.split('\n').length > 9) ||
(this.appearNote.text.length > 500)
);
this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
// plugin
if (noteViewInterruptors.length > 0) {
let result = this.note;
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
}
this.$emit('update:note', Object.freeze(result));
}
},
mounted() {
this.capture(true);
if (this.$i) {
this.connection.on('_connected_', this.onStreamConnected);
}
},
beforeUnmount() {
this.decapture(true);
if (this.$i) {
this.connection.off('_connected_', this.onStreamConnected);
}
},
methods: {
updateAppearNote(v) {
this.$emit('update:note', Object.freeze(this.isRenote ? {
...this.note,
renote: {
...this.note.renote,
...v
}
} : {
...this.note,
...v
}));
},
readPromo() {
os.api('promo/read', {
noteId: this.appearNote.id
});
this.isDeleted = true;
},
capture(withHandler = false) {
if (this.$i) {
// TODO: sr
this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
}
},
decapture(withHandler = false) {
if (this.$i) {
this.connection.send('un', {
id: this.appearNote.id
});
if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
}
},
onStreamConnected() {
this.capture();
},
onStreamNoteUpdated(data) {
const { type, id, body } = data;
if (id !== this.appearNote.id) return;
switch (type) {
case 'reacted': {
const reaction = body.reaction;
// DeepShallown.reactions[reaction] = hoge()
let n = {
...this.appearNote,
};
if (body.emoji) {
const emojis = this.appearNote.emojis || [];
if (!emojis.includes(body.emoji)) {
n.emojis = [...emojis, body.emoji];
}
}
// TODO: reactions || {}
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
// Increment the count
n.reactions = {
...this.appearNote.reactions,
[reaction]: currentCount + 1
};
if (body.userId === this.$i.id) {
n.myReaction = reaction;
}
this.updateAppearNote(n);
break;
}
case 'unreacted': {
const reaction = body.reaction;
// DeepShallown.reactions[reaction] = hoge()
let n = {
...this.appearNote,
};
// TODO: reactions || {}
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
// Decrement the count
n.reactions = {
...this.appearNote.reactions,
[reaction]: Math.max(0, currentCount - 1)
};
if (body.userId === this.$i.id) {
n.myReaction = null;
}
this.updateAppearNote(n);
break;
}
case 'pollVoted': {
const choice = body.choice;
// DeepShallown.reactions[reaction] = hoge()
let n = {
...this.appearNote,
};
const choices = [...this.appearNote.poll.choices];
choices[choice] = {
...choices[choice],
votes: choices[choice].votes + 1,
...(body.userId === this.$i.id ? {
isVoted: true
} : {})
};
n.poll = {
...this.appearNote.poll,
choices: choices
};
this.updateAppearNote(n);
break;
}
case 'deleted': {
this.isDeleted = true;
break;
}
}
},
reply(viaKeyboard = false) {
pleaseLogin();
os.post({
reply: this.appearNote,
animation: !viaKeyboard,
}, () => {
this.focus();
});
},
renoteDirectly() {
os.apiWithDialog('notes/create', {
renoteId: this.appearNote.id
}, undefined, (res: any) => {
os.alert({
type: 'success',
text: this.$ts.renoted,
});
}, (e: Error) => {
if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
os.alert({
type: 'error',
text: this.$ts.cantRenote,
});
} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
os.alert({
type: 'error',
text: this.$ts.cantReRenote,
});
}
});
},
react(viaKeyboard = false) {
pleaseLogin();
this.blur();
reactionPicker.show(this.$refs.reactButton, reaction => {
os.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
}, () => {
this.focus();
});
},
reactDirectly(reaction) {
os.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
},
undoReact(note) {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id
});
},
favorite() {
pleaseLogin();
os.apiWithDialog('notes/favorites/create', {
noteId: this.appearNote.id
}, undefined, (res: any) => {
os.alert({
type: 'success',
text: this.$ts.favorited,
});
}, (e: Error) => {
if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
os.alert({
type: 'error',
text: this.$ts.alreadyFavorited,
});
} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
os.alert({
type: 'error',
text: this.$ts.cantFavorite,
});
}
});
},
del() {
os.confirm({
type: 'warning',
text: this.$ts.noteDeleteConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: this.appearNote.id
});
});
},
delEdit() {
os.confirm({
type: 'warning',
text: this.$ts.deleteAndEditConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: this.appearNote.id
});
os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
});
},
toggleFavorite(favorite: boolean) {
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
noteId: this.appearNote.id
});
},
toggleWatch(watch: boolean) {
os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
noteId: this.appearNote.id
});
},
toggleThreadMute(mute: boolean) {
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
noteId: this.appearNote.id
});
},
getMenu() {
let menu;
if (this.$i) {
const statePromise = os.api('notes/state', {
noteId: this.appearNote.id
});
menu = [{
icon: 'fas fa-copy',
text: this.$ts.copyContent,
action: this.copyContent
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: this.copyLink
}, (this.appearNote.url || this.appearNote.uri) ? {
icon: 'fas fa-external-link-square-alt',
text: this.$ts.showOnRemote,
action: () => {
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
}
} : undefined,
{
icon: 'fas fa-share-alt',
text: this.$ts.share,
action: this.share
},
this.$instance.translatorAvailable ? {
icon: 'fas fa-language',
text: this.$ts.translate,
action: this.translate
} : undefined,
null,
statePromise.then(state => state.isFavorited ? {
icon: 'fas fa-star',
text: this.$ts.unfavorite,
action: () => this.toggleFavorite(false)
} : {
icon: 'fas fa-star',
text: this.$ts.favorite,
action: () => this.toggleFavorite(true)
}),
{
icon: 'fas fa-paperclip',
text: this.$ts.clip,
action: () => this.clip()
},
(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
icon: 'fas fa-eye-slash',
text: this.$ts.unwatch,
action: () => this.toggleWatch(false)
} : {
icon: 'fas fa-eye',
text: this.$ts.watch,
action: () => this.toggleWatch(true)
}) : undefined,
statePromise.then(state => state.isMutedThread ? {
icon: 'fas fa-comment-slash',
text: this.$ts.unmuteThread,
action: () => this.toggleThreadMute(false)
} : {
icon: 'fas fa-comment-slash',
text: this.$ts.muteThread,
action: () => this.toggleThreadMute(true)
}),
this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
icon: 'fas fa-thumbtack',
text: this.$ts.unpin,
action: () => this.togglePin(false)
} : {
icon: 'fas fa-thumbtack',
text: this.$ts.pin,
action: () => this.togglePin(true)
} : undefined,
/*
...(this.$i.isModerator || this.$i.isAdmin ? [
null,
{
icon: 'fas fa-bullhorn',
text: this.$ts.promote,
action: this.promote
}]
: []
),*/
...(this.appearNote.userId != this.$i.id ? [
null,
{
icon: 'fas fa-exclamation-circle',
text: this.$ts.reportAbuse,
action: () => {
const u = `${url}/notes/${this.appearNote.id}`;
os.popup(import('@/components/abuse-report-window.vue'), {
user: this.appearNote.user,
initialComment: `Note: ${u}\n-----\n`
}, {}, 'closed');
}
}]
: []
),
...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
null,
this.appearNote.userId == this.$i.id ? {
icon: 'fas fa-edit',
text: this.$ts.deleteAndEdit,
action: this.delEdit
} : undefined,
{
icon: 'fas fa-trash-alt',
text: this.$ts.delete,
danger: true,
action: this.del
}]
: []
)]
.filter(x => x !== undefined);
} else {
menu = [{
icon: 'fas fa-copy',
text: this.$ts.copyContent,
action: this.copyContent
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: this.copyLink
}, (this.appearNote.url || this.appearNote.uri) ? {
icon: 'fas fa-external-link-square-alt',
text: this.$ts.showOnRemote,
action: () => {
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
}
} : undefined]
.filter(x => x !== undefined);
}
if (noteActions.length > 0) {
menu = menu.concat([null, ...noteActions.map(action => ({
icon: 'fas fa-plug',
text: action.title,
action: () => {
action.handler(this.appearNote);
}
}))]);
}
return menu;
},
onContextmenu(e) {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(e.target)) return;
if (window.getSelection().toString() !== '') return;
if (this.$store.state.useReactionPickerForContextMenu) {
e.preventDefault();
this.react();
} else {
os.contextMenu(this.getMenu(), e).then(this.focus);
}
},
menu(viaKeyboard = false) {
os.popupMenu(this.getMenu(), this.$refs.menuButton, {
viaKeyboard
}).then(this.focus);
},
showRenoteMenu(viaKeyboard = false) {
if (!this.isMyRenote) return;
os.popupMenu([{
text: this.$ts.unrenote,
icon: 'fas fa-trash-alt',
danger: true,
action: () => {
os.api('notes/delete', {
noteId: this.note.id
});
this.isDeleted = true;
}
}], this.$refs.renoteTime, {
viaKeyboard: viaKeyboard
});
},
toggleShowContent() {
this.showContent = !this.showContent;
},
copyContent() {
copyToClipboard(this.appearNote.text);
os.success();
},
copyLink() {
copyToClipboard(`${url}/notes/${this.appearNote.id}`);
os.success();
},
togglePin(pin: boolean) {
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
noteId: this.appearNote.id
}, undefined, null, e => {
if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
os.alert({
type: 'error',
text: this.$ts.pinLimitExceeded
});
}
});
},
async clip() {
const clips = await os.api('clips/list');
os.popupMenu([{
icon: 'fas fa-plus',
text: this.$ts.createNew,
action: async () => {
const { canceled, result } = await os.form(this.$ts.createNewClip, {
name: {
type: 'string',
label: this.$ts.name
},
description: {
type: 'string',
required: false,
multiline: true,
label: this.$ts.description
},
isPublic: {
type: 'boolean',
label: this.$ts.public,
default: false
}
});
if (canceled) return;
const clip = await os.apiWithDialog('clips/create', result);
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
}
}, null, ...clips.map(clip => ({
text: clip.name,
action: () => {
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
}
}))], this.$refs.menuButton, {
}).then(this.focus);
},
async promote() {
const { canceled, result: days } = await os.inputNumber({
title: this.$ts.numberOfDays,
});
if (canceled) return;
os.apiWithDialog('admin/promo/create', {
noteId: this.appearNote.id,
expiresAt: Date.now() + (86400000 * days)
});
},
share() {
navigator.share({
title: this.$t('noteOf', { user: this.appearNote.user.name }),
text: this.appearNote.text,
url: `${url}/notes/${this.appearNote.id}`
});
},
async translate() {
if (this.translation != null) return;
this.translating = true;
const res = await os.api('notes/translate', {
noteId: this.appearNote.id,
targetLang: localStorage.getItem('lang') || navigator.language,
});
this.translating = false;
this.translation = res;
},
focus() {
this.$el.focus();
},
blur() {
this.$el.blur();
},
focusBefore() {
focusPrev(this.$el);
},
focusAfter() {
focusNext(this.$el);
},
userPage
}
}); });
function reply(viaKeyboard = false): void {
pleaseLogin();
os.post({
reply: appearNote,
animation: !viaKeyboard,
}, () => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
blur();
reactionPicker.show(reactButton.value, reaction => {
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction
});
}, () => {
focus();
});
}
function undoReact(note): void {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id
});
}
function onContextmenu(e): void {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(e.target)) return;
if (window.getSelection().toString() !== '') return;
if (defaultStore.state.useReactionPickerForContextMenu) {
e.preventDefault();
react();
} else {
os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), e).then(focus);
}
}
function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
viaKeyboard
}).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return;
os.popupMenu([{
text: i18n.locale.unrenote,
icon: 'fas fa-trash-alt',
danger: true,
action: () => {
os.api('notes/delete', {
noteId: props.note.id
});
isDeleted.value = true;
}
}], renoteTime.value, {
viaKeyboard: viaKeyboard
});
}
function focus() {
el.value.focus();
}
function blur() {
el.value.blur();
}
function focusBefore() {
focusPrev(el.value);
}
function focusAfter() {
focusNext(el.value);
}
function readPromo() {
os.api('promo/read', {
noteId: appearNote.id
});
isDeleted.value = true;
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -10,7 +10,7 @@
<template #default="{ items: notes }"> <template #default="{ items: notes }">
<div class="giivymft" :class="{ noGap }"> <div class="giivymft" :class="{ noGap }">
<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes"> <XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/> <XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/>
</XList> </XList>
</div> </div>
</template> </template>
@ -31,10 +31,6 @@ const props = defineProps<{
const pagingComponent = ref<InstanceType<typeof MkPagination>>(); const pagingComponent = ref<InstanceType<typeof MkPagination>>();
const updated = (oldValue, newValue) => {
pagingComponent.value?.updateItem(oldValue.id, () => newValue);
};
defineExpose({ defineExpose({
prepend: (note) => { prepend: (note) => {
pagingComponent.value?.prepend(note); pagingComponent.value?.prepend(note);

View file

@ -29,7 +29,7 @@ export default defineComponent({
}; };
}, },
mounted() { mounted() {
setTimeout(() => { window.setTimeout(() => {
this.showing = false; this.showing = false;
}, 6000); }, 6000);
} }

View file

@ -9,7 +9,7 @@
<template #default="{ items: notifications }"> <template #default="{ items: notifications }">
<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true"> <XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification, $event)"/> <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
</XList> </XList>
</template> </template>
@ -62,13 +62,6 @@ const onNotification = (notification) => {
} }
}; };
const noteUpdated = (item, note) => {
pagingComponent.value?.updateItem(item.id, old => ({
...old,
note: note,
}));
};
onMounted(() => { onMounted(() => {
const connection = stream.useChannel('main'); const connection = stream.useChannel('main');
connection.on('notification', onNotification); connection.on('notification', onNotification);

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,13 @@
<template> <template>
<MkEmoji :emoji="reaction" :custom-emojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/> <MkEmoji :emoji="reaction" :custom-emojis="customEmojis || []" :is-reaction="true" :normal="true" :no-style="noStyle"/>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
export default defineComponent({ const props = defineProps<{
props: { reaction: string;
reaction: { customEmojis?: any[]; // TODO
type: String, noStyle?: boolean;
required: true }>();
},
customEmojis: {
required: false,
default: () => []
},
noStyle: {
type: Boolean,
required: false,
default: false
},
},
});
</script> </script>

View file

@ -1,5 +1,5 @@
<template> <template>
<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="$emit('closed')"> <MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="emit('closed')">
<div class="beeadbfb"> <div class="beeadbfb">
<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
<div class="name">{{ reaction.replace('@.', '') }}</div> <div class="name">{{ reaction.replace('@.', '') }}</div>
@ -7,31 +7,20 @@
</MkTooltip> </MkTooltip>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import MkTooltip from './ui/tooltip.vue'; import MkTooltip from './ui/tooltip.vue';
import XReactionIcon from './reaction-icon.vue'; import XReactionIcon from './reaction-icon.vue';
export default defineComponent({ const props = defineProps<{
components: { reaction: string;
MkTooltip, emojis: any[]; // TODO
XReactionIcon, source: any; // TODO
}, }>();
props: {
reaction: { const emit = defineEmits<{
type: String, (e: 'closed'): void;
required: true, }>();
},
emojis: {
type: Array,
required: true,
},
source: {
required: true,
}
},
emits: ['closed'],
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -1,5 +1,5 @@
<template> <template>
<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="$emit('closed')"> <MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="emit('closed')">
<div class="bqxuuuey"> <div class="bqxuuuey">
<div class="reaction"> <div class="reaction">
<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/> <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
@ -16,39 +16,22 @@
</MkTooltip> </MkTooltip>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import MkTooltip from './ui/tooltip.vue'; import MkTooltip from './ui/tooltip.vue';
import XReactionIcon from './reaction-icon.vue'; import XReactionIcon from './reaction-icon.vue';
export default defineComponent({ const props = defineProps<{
components: { reaction: string;
MkTooltip, users: any[]; // TODO
XReactionIcon count: number;
}, emojis: any[]; // TODO
props: { source: any; // TODO
reaction: { }>();
type: String,
required: true, const emit = defineEmits<{
}, (e: 'closed'): void;
users: { }>();
type: Array,
required: true,
},
count: {
type: Number,
required: true,
},
emojis: {
type: Array,
required: true,
},
source: {
required: true,
}
},
emits: ['closed'],
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -4,31 +4,19 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { computed } from 'vue';
import * as misskey from 'misskey-js';
import { $i } from '@/account';
import XReaction from './reactions-viewer.reaction.vue'; import XReaction from './reactions-viewer.reaction.vue';
export default defineComponent({ const props = defineProps<{
components: { note: misskey.entities.Note;
XReaction }>();
},
props: { const initialReactions = new Set(Object.keys(props.note.reactions));
note: {
type: Object, const isMe = computed(() => $i && $i.id === props.note.userId);
required: true
},
},
data() {
return {
initialReactions: new Set(Object.keys(this.note.reactions))
};
},
computed: {
isMe(): boolean {
return this.$i && this.$i.id === this.note.userId;
},
},
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -1,5 +1,5 @@
<template> <template>
<MkTooltip ref="tooltip" :source="source" :max-width="250" @closed="$emit('closed')"> <MkTooltip ref="tooltip" :source="source" :max-width="250" @closed="emit('closed')">
<div class="beaffaef"> <div class="beaffaef">
<div v-for="u in users" :key="u.id" class="user"> <div v-for="u in users" :key="u.id" class="user">
<MkAvatar class="avatar" :user="u"/> <MkAvatar class="avatar" :user="u"/>
@ -10,29 +10,19 @@
</MkTooltip> </MkTooltip>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import MkTooltip from './ui/tooltip.vue'; import MkTooltip from './ui/tooltip.vue';
export default defineComponent({ const props = defineProps<{
components: { users: any[]; // TODO
MkTooltip, count: number;
}, source: any; // TODO
props: { }>();
users: {
type: Array, const emit = defineEmits<{
required: true, (e: 'closed'): void;
}, }>();
count: {
type: Number,
required: true,
},
source: {
required: true,
}
},
emits: ['closed'],
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -94,7 +94,7 @@ export default defineComponent({
} }
onMounted(() => { onMounted(() => {
setTimeout(() => { window.setTimeout(() => {
context.emit('end'); context.emit('end');
}, 1100); }, 1100);
}); });

View file

@ -2,8 +2,8 @@
<XModalWindow ref="dialog" <XModalWindow ref="dialog"
:width="370" :width="370"
:height="400" :height="400"
@close="$refs.dialog.close()" @close="dialog.close()"
@closed="$emit('closed')" @closed="emit('closed')"
> >
<template #header>{{ $ts.login }}</template> <template #header>{{ $ts.login }}</template>
@ -11,32 +11,26 @@
</XModalWindow> </XModalWindow>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import XModalWindow from '@/components/ui/modal-window.vue'; import XModalWindow from '@/components/ui/modal-window.vue';
import MkSignin from './signin.vue'; import MkSignin from './signin.vue';
export default defineComponent({ const props = withDefaults(defineProps<{
components: { autoSet?: boolean;
MkSignin, }>(), {
XModalWindow, autoSet: false,
},
props: {
autoSet: {
type: Boolean,
required: false,
default: false,
}
},
emits: ['done', 'closed'],
methods: {
onLogin(res) {
this.$emit('done', res);
this.$refs.dialog.close();
}
}
}); });
const emit = defineEmits<{
(e: 'done'): void;
(e: 'closed'): void;
}>();
const dialog = $ref<InstanceType<typeof XModalWindow>>();
function onLogin(res) {
emit('done', res);
dialog.close();
}
</script> </script>

View file

@ -2,7 +2,7 @@
<XModalWindow ref="dialog" <XModalWindow ref="dialog"
:width="366" :width="366"
:height="500" :height="500"
@close="$refs.dialog.close()" @close="dialog.close()"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
<template #header>{{ $ts.signup }}</template> <template #header>{{ $ts.signup }}</template>
@ -15,36 +15,30 @@
</XModalWindow> </XModalWindow>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import XModalWindow from '@/components/ui/modal-window.vue'; import XModalWindow from '@/components/ui/modal-window.vue';
import XSignup from './signup.vue'; import XSignup from './signup.vue';
export default defineComponent({ const props = withDefaults(defineProps<{
components: { autoSet?: boolean;
XSignup, }>(), {
XModalWindow, autoSet: false,
},
props: {
autoSet: {
type: Boolean,
required: false,
default: false,
}
},
emits: ['done', 'closed'],
methods: {
onSignup(res) {
this.$emit('done', res);
this.$refs.dialog.close();
},
onSignupEmailPending() {
this.$refs.dialog.close();
}
}
}); });
const emit = defineEmits<{
(e: 'done'): void;
(e: 'closed'): void;
}>();
const dialog = $ref<InstanceType<typeof XModalWindow>>();
function onSignup(res) {
emit('done', res);
dialog.close();
}
function onSignupEmailPending() {
dialog.close();
}
</script> </script>

View file

@ -21,35 +21,21 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import XPoll from './poll.vue'; import XPoll from './poll.vue';
import XMediaList from './media-list.vue'; import XMediaList from './media-list.vue';
import * as os from '@/os'; import * as misskey from 'misskey-js';
export default defineComponent({ const props = defineProps<{
components: { note: misskey.entities.Note;
XPoll, }>();
XMediaList,
}, const collapsed = $ref(
props: { props.note.cw == null && props.note.text != null && (
note: { (props.note.text.split('\n').length > 9) ||
type: Object, (props.note.text.length > 500)
required: true ));
}
},
data() {
return {
collapsed: false,
};
},
created() {
this.collapsed = this.note.cw == null && this.note.text && (
(this.note.text.split('\n').length > 9) ||
(this.note.text.length > 500)
);
}
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -26,7 +26,7 @@ const showing = ref(true);
const zIndex = os.claimZIndex('high'); const zIndex = os.claimZIndex('high');
onMounted(() => { onMounted(() => {
setTimeout(() => { window.setTimeout(() => {
showing.value = false; showing.value = false;
}, 4000); }, 4000);
}); });

View file

@ -117,14 +117,14 @@ export default defineComponent({
const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY); const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY);
setTimeout(() => { window.setTimeout(() => {
ripple.style.transform = 'scale(' + (scale / 2) + ')'; ripple.style.transform = 'scale(' + (scale / 2) + ')';
}, 1); }, 1);
setTimeout(() => { window.setTimeout(() => {
ripple.style.transition = 'all 1s ease'; ripple.style.transition = 'all 1s ease';
ripple.style.opacity = '0'; ripple.style.opacity = '0';
}, 1000); }, 1000);
setTimeout(() => { window.setTimeout(() => {
if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple); if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple);
}, 2000); }, 2000);
} }

View file

@ -211,7 +211,7 @@ export default defineComponent({
contentClicking = true; contentClicking = true;
window.addEventListener('mouseup', e => { window.addEventListener('mouseup', e => {
// click mouseup // click mouseup
setTimeout(() => { window.setTimeout(() => {
contentClicking = false; contentClicking = false;
}, 100); }, 100);
}, { passive: true, once: true }); }, { passive: true, once: true });

View file

@ -90,7 +90,6 @@ const init = async (): Promise<void> => {
}).then(res => { }).then(res => {
for (let i = 0; i < res.length; i++) { for (let i = 0; i < res.length; i++) {
const item = res[i]; const item = res[i];
markRaw(item);
if (props.pagination.reversed) { if (props.pagination.reversed) {
if (i === res.length - 2) item._shouldInsertAd_ = true; if (i === res.length - 2) item._shouldInsertAd_ = true;
} else { } else {
@ -134,7 +133,6 @@ const fetchMore = async (): Promise<void> => {
}).then(res => { }).then(res => {
for (let i = 0; i < res.length; i++) { for (let i = 0; i < res.length; i++) {
const item = res[i]; const item = res[i];
markRaw(item);
if (props.pagination.reversed) { if (props.pagination.reversed) {
if (i === res.length - 9) item._shouldInsertAd_ = true; if (i === res.length - 9) item._shouldInsertAd_ = true;
} else { } else {
@ -169,9 +167,6 @@ const fetchMoreAhead = async (): Promise<void> => {
sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
}), }),
}).then(res => { }).then(res => {
for (const item of res) {
markRaw(item);
}
if (res.length > SECOND_FETCH_LIMIT) { if (res.length > SECOND_FETCH_LIMIT) {
res.pop(); res.pop();
items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res); items.value = props.pagination.reversed ? [...res].reverse().concat(items.value) : items.value.concat(res);

View file

@ -4,7 +4,7 @@
<iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen />
</div> </div>
<div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter"> <div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter">
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', left: `${tweetLeft}px`, width: `${tweetLeft < 0 ? 'auto' : '100%'}`, height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe> <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
</div> </div>
<div v-else v-size="{ max: [400, 350] }" class="mk-url-preview"> <div v-else v-size="{ max: [400, 350] }" class="mk-url-preview">
<transition name="zoom" mode="out-in"> <transition name="zoom" mode="out-in">
@ -32,110 +32,80 @@
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { onMounted, onUnmounted } from 'vue';
import { url as local, lang } from '@/config'; import { url as local, lang } from '@/config';
import * as os from '@/os';
export default defineComponent({ const props = withDefaults(defineProps<{
props: { url: string;
url: { detail?: boolean;
type: String, compact?: boolean;
require: true }>(), {
}, detail: false,
compact: false,
});
detail: { const self = props.url.startsWith(local);
type: Boolean, const attr = self ? 'to' : 'href';
required: false, const target = self ? null : '_blank';
default: false let fetching = $ref(true);
}, let title = $ref<string | null>(null);
let description = $ref<string | null>(null);
let thumbnail = $ref<string | null>(null);
let icon = $ref<string | null>(null);
let sitename = $ref<string | null>(null);
let player = $ref({
url: null,
width: null,
height: null
});
let playerEnabled = $ref(false);
let tweetId = $ref<string | null>(null);
let tweetExpanded = $ref(props.detail);
const embedId = `embed${Math.random().toString().replace(/\D/,'')}`;
let tweetHeight = $ref(150);
compact: { const requestUrl = new URL(props.url);
type: Boolean,
required: false,
default: false
},
},
data() { if (requestUrl.hostname == 'twitter.com') {
const self = this.url.startsWith(local); const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
return { if (m) tweetId = m[1];
local, }
fetching: true,
title: null,
description: null,
thumbnail: null,
icon: null,
sitename: null,
player: {
url: null,
width: null,
height: null
},
tweetId: null,
tweetExpanded: this.detail,
embedId: `embed${Math.random().toString().replace(/\D/,'')}`,
tweetHeight: 150,
tweetLeft: 0,
playerEnabled: false,
self: self,
attr: self ? 'to' : 'href',
target: self ? null : '_blank',
};
},
created() { if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
const requestUrl = new URL(this.url); requestUrl.hostname = 'www.youtube.com';
}
if (requestUrl.hostname == 'twitter.com') { const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP');
const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
if (m) this.tweetId = m[1];
}
if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { requestUrl.hash = '';
requestUrl.hostname = 'www.youtube.com';
}
const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP'); fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
res.json().then(info => {
if (info.url == null) return;
title = info.title;
description = info.description;
thumbnail = info.thumbnail;
icon = info.icon;
sitename = info.sitename;
fetching = false;
player = info.player;
})
});
requestUrl.hash = ''; function adjustTweetHeight(message: any) {
if (message.origin !== 'https://platform.twitter.com') return;
const embed = message.data?.['twttr.embed'];
if (embed?.method !== 'twttr.private.resize') return;
if (embed?.id !== embedId) return;
const height = embed?.params[0]?.height;
if (height) tweetHeight = height;
}
fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => { (window as any).addEventListener('message', adjustTweetHeight);
res.json().then(info => {
if (info.url == null) return;
this.title = info.title;
this.description = info.description;
this.thumbnail = info.thumbnail;
this.icon = info.icon;
this.sitename = info.sitename;
this.fetching = false;
this.player = info.player;
})
});
(window as any).addEventListener('message', this.adjustTweetHeight); onUnmounted(() => {
}, (window as any).removeEventListener('message', adjustTweetHeight);
mounted() {
// 300px
const areaWidth = (this.$el as any)?.clientWidth;
if (areaWidth && areaWidth < 300) this.tweetLeft = areaWidth - 241;
},
beforeUnmount() {
(window as any).removeEventListener('message', this.adjustTweetHeight);
},
methods: {
adjustTweetHeight(message: any) {
if (message.origin !== 'https://platform.twitter.com') return;
const embed = message.data?.['twttr.embed'];
if (embed?.method !== 'twttr.private.resize') return;
if (embed?.id !== this.embedId) return;
const height = embed?.params[0]?.height;
if (height) this.tweetHeight = height;
},
},
}); });
</script> </script>

View file

@ -2,26 +2,21 @@
<div v-tooltip="text" class="fzgwjkgc" :class="user.onlineStatus"></div> <div v-tooltip="text" class="fzgwjkgc" :class="user.onlineStatus"></div>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import * as misskey from 'misskey-js';
import { i18n } from '@/i18n';
export default defineComponent({ const props = defineProps<{
props: { user: misskey.entities.User;
user: { }>();
type: Object,
required: true
},
},
computed: { const text = $computed(() => {
text(): string { switch (props.user.onlineStatus) {
switch (this.user.onlineStatus) { case 'online': return i18n.locale.online;
case 'online': return this.$ts.online; case 'active': return i18n.locale.active;
case 'active': return this.$ts.active; case 'offline': return i18n.locale.offline;
case 'offline': return this.$ts.offline; case 'unknown': return i18n.locale.unknown;
case 'unknown': return this.$ts.unknown;
}
}
} }
}); });
</script> </script>

View file

@ -1,28 +1,28 @@
<template> <template>
<MkModal ref="modal" :z-priority="'high'" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')"> <MkModal ref="modal" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')">
<div class="gqyayizv _popup"> <div class="gqyayizv _popup">
<button key="public" class="_button" :class="{ active: v == 'public' }" data-index="1" @click="choose('public')"> <button key="public" class="_button" :class="{ active: v === 'public' }" data-index="1" @click="choose('public')">
<div><i class="fas fa-globe"></i></div> <div><i class="fas fa-globe"></i></div>
<div> <div>
<span>{{ $ts._visibility.public }}</span> <span>{{ $ts._visibility.public }}</span>
<span>{{ $ts._visibility.publicDescription }}</span> <span>{{ $ts._visibility.publicDescription }}</span>
</div> </div>
</button> </button>
<button key="home" class="_button" :class="{ active: v == 'home' }" data-index="2" @click="choose('home')"> <button key="home" class="_button" :class="{ active: v === 'home' }" data-index="2" @click="choose('home')">
<div><i class="fas fa-home"></i></div> <div><i class="fas fa-home"></i></div>
<div> <div>
<span>{{ $ts._visibility.home }}</span> <span>{{ $ts._visibility.home }}</span>
<span>{{ $ts._visibility.homeDescription }}</span> <span>{{ $ts._visibility.homeDescription }}</span>
</div> </div>
</button> </button>
<button key="followers" class="_button" :class="{ active: v == 'followers' }" data-index="3" @click="choose('followers')"> <button key="followers" class="_button" :class="{ active: v === 'followers' }" data-index="3" @click="choose('followers')">
<div><i class="fas fa-unlock"></i></div> <div><i class="fas fa-unlock"></i></div>
<div> <div>
<span>{{ $ts._visibility.followers }}</span> <span>{{ $ts._visibility.followers }}</span>
<span>{{ $ts._visibility.followersDescription }}</span> <span>{{ $ts._visibility.followersDescription }}</span>
</div> </div>
</button> </button>
<button key="specified" :disabled="localOnly" class="_button" :class="{ active: v == 'specified' }" data-index="4" @click="choose('specified')"> <button key="specified" :disabled="localOnly" class="_button" :class="{ active: v === 'specified' }" data-index="4" @click="choose('specified')">
<div><i class="fas fa-envelope"></i></div> <div><i class="fas fa-envelope"></i></div>
<div> <div>
<span>{{ $ts._visibility.specified }}</span> <span>{{ $ts._visibility.specified }}</span>
@ -42,49 +42,40 @@
</MkModal> </MkModal>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { nextTick, watch } from 'vue';
import * as misskey from 'misskey-js';
import MkModal from '@/components/ui/modal.vue'; import MkModal from '@/components/ui/modal.vue';
export default defineComponent({ const modal = $ref<InstanceType<typeof MkModal>>();
components: {
MkModal, const props = withDefaults(defineProps<{
}, currentVisibility: typeof misskey.noteVisibilities[number];
props: { currentLocalOnly: boolean;
currentVisibility: { src?: HTMLElement;
type: String, }>(), {
required: true
},
currentLocalOnly: {
type: Boolean,
required: true
},
src: {
required: false
},
},
emits: ['change-visibility', 'change-local-only', 'closed'],
data() {
return {
v: this.currentVisibility,
localOnly: this.currentLocalOnly,
}
},
watch: {
localOnly() {
this.$emit('change-local-only', this.localOnly);
}
},
methods: {
choose(visibility) {
this.v = visibility;
this.$emit('change-visibility', visibility);
this.$nextTick(() => {
this.$refs.modal.close();
});
},
}
}); });
const emit = defineEmits<{
(e: 'changeVisibility', v: typeof misskey.noteVisibilities[number]): void;
(e: 'changeLocalOnly', v: boolean): void;
(e: 'closed'): void;
}>();
let v = $ref(props.currentVisibility);
let localOnly = $ref(props.currentLocalOnly);
watch($$(localOnly), () => {
emit('changeLocalOnly', localOnly);
});
function choose(visibility: typeof misskey.noteVisibilities[number]): void {
v = visibility;
emit('changeVisibility', visibility);
nextTick(() => {
modal.close();
});
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View file

@ -1,5 +1,5 @@
<template> <template>
<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="success ? done() : () => {}" @closed="$emit('closed')"> <MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="success ? done() : () => {}" @closed="emit('closed')">
<div class="iuyakobc" :class="{ iconOnly: (text == null) || success }"> <div class="iuyakobc" :class="{ iconOnly: (text == null) || success }">
<i v-if="success" class="fas fa-check icon success"></i> <i v-if="success" class="fas fa-check icon success"></i>
<i v-else class="fas fa-spinner fa-pulse icon waiting"></i> <i v-else class="fas fa-spinner fa-pulse icon waiting"></i>
@ -8,49 +8,30 @@
</MkModal> </MkModal>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { watch, ref } from 'vue';
import MkModal from '@/components/ui/modal.vue'; import MkModal from '@/components/ui/modal.vue';
export default defineComponent({ const modal = ref<InstanceType<typeof MkModal>>();
components: {
MkModal,
},
props: { const props = defineProps<{
success: { success: boolean;
type: Boolean, showing: boolean;
required: true, text?: string;
}, }>();
showing: {
type: Boolean,
required: true,
},
text: {
type: String,
required: false,
},
},
emits: ['done', 'closed'], const emit = defineEmits<{
(e: 'done');
(e: 'closed');
}>();
data() { function done() {
return { emit('done');
}; modal.value.close();
}, }
watch: { watch(() => props.showing, () => {
showing() { if (!props.showing) done();
if (!this.showing) this.done();
}
},
methods: {
done() {
this.$emit('done');
this.$refs.modal.close();
},
}
}); });
</script> </script>

View file

@ -10,7 +10,7 @@ export default {
}, },
mounted(src, binding, vn) { mounted(src, binding, vn) {
setTimeout(() => { window.setTimeout(() => {
src.style.opacity = '1'; src.style.opacity = '1';
src.style.transform = 'none'; src.style.transform = 'none';
}, 1); }, 1);

View file

@ -21,7 +21,7 @@ export default {
self.close = () => { self.close = () => {
if (self._close) { if (self._close) {
clearInterval(self.checkTimer); window.clearInterval(self.checkTimer);
self._close(); self._close();
self._close = null; self._close = null;
} }
@ -61,19 +61,19 @@ export default {
}); });
el.addEventListener(start, () => { el.addEventListener(start, () => {
clearTimeout(self.showTimer); window.clearTimeout(self.showTimer);
clearTimeout(self.hideTimer); window.clearTimeout(self.hideTimer);
self.showTimer = setTimeout(self.show, delay); self.showTimer = window.setTimeout(self.show, delay);
}, { passive: true }); }, { passive: true });
el.addEventListener(end, () => { el.addEventListener(end, () => {
clearTimeout(self.showTimer); window.clearTimeout(self.showTimer);
clearTimeout(self.hideTimer); window.clearTimeout(self.hideTimer);
self.hideTimer = setTimeout(self.close, delay); self.hideTimer = window.setTimeout(self.close, delay);
}, { passive: true }); }, { passive: true });
el.addEventListener('click', () => { el.addEventListener('click', () => {
clearTimeout(self.showTimer); window.clearTimeout(self.showTimer);
self.close(); self.close();
}); });
}, },
@ -85,6 +85,6 @@ export default {
unmounted(el, binding, vn) { unmounted(el, binding, vn) {
const self = el._tooltipDirective_; const self = el._tooltipDirective_;
clearInterval(self.checkTimer); window.clearInterval(self.checkTimer);
}, },
} as Directive; } as Directive;

View file

@ -30,11 +30,11 @@ export class UserPreview {
source: this.el source: this.el
}, { }, {
mouseover: () => { mouseover: () => {
clearTimeout(this.hideTimer); window.clearTimeout(this.hideTimer);
}, },
mouseleave: () => { mouseleave: () => {
clearTimeout(this.showTimer); window.clearTimeout(this.showTimer);
this.hideTimer = setTimeout(this.close, 500); this.hideTimer = window.setTimeout(this.close, 500);
}, },
}, 'closed'); }, 'closed');
@ -44,10 +44,10 @@ export class UserPreview {
} }
}; };
this.checkTimer = setInterval(() => { this.checkTimer = window.setInterval(() => {
if (!document.body.contains(this.el)) { if (!document.body.contains(this.el)) {
clearTimeout(this.showTimer); window.clearTimeout(this.showTimer);
clearTimeout(this.hideTimer); window.clearTimeout(this.hideTimer);
this.close(); this.close();
} }
}, 1000); }, 1000);
@ -56,7 +56,7 @@ export class UserPreview {
@autobind @autobind
private close() { private close() {
if (this.promise) { if (this.promise) {
clearInterval(this.checkTimer); window.clearInterval(this.checkTimer);
this.promise.cancel(); this.promise.cancel();
this.promise = null; this.promise = null;
} }
@ -64,21 +64,21 @@ export class UserPreview {
@autobind @autobind
private onMouseover() { private onMouseover() {
clearTimeout(this.showTimer); window.clearTimeout(this.showTimer);
clearTimeout(this.hideTimer); window.clearTimeout(this.hideTimer);
this.showTimer = setTimeout(this.show, 500); this.showTimer = window.setTimeout(this.show, 500);
} }
@autobind @autobind
private onMouseleave() { private onMouseleave() {
clearTimeout(this.showTimer); window.clearTimeout(this.showTimer);
clearTimeout(this.hideTimer); window.clearTimeout(this.hideTimer);
this.hideTimer = setTimeout(this.close, 500); this.hideTimer = window.setTimeout(this.close, 500);
} }
@autobind @autobind
private onClick() { private onClick() {
clearTimeout(this.showTimer); window.clearTimeout(this.showTimer);
this.close(); this.close();
} }
@ -94,7 +94,7 @@ export class UserPreview {
this.el.removeEventListener('mouseover', this.onMouseover); this.el.removeEventListener('mouseover', this.onMouseover);
this.el.removeEventListener('mouseleave', this.onMouseleave); this.el.removeEventListener('mouseleave', this.onMouseleave);
this.el.removeEventListener('click', this.onClick); this.el.removeEventListener('click', this.onClick);
clearInterval(this.checkTimer); window.clearInterval(this.checkTimer);
} }
} }

View file

@ -163,11 +163,6 @@ export const menuDef = reactive({
icon: 'fas fa-laugh', icon: 'fas fa-laugh',
to: '/emojis', to: '/emojis',
}, },
games: {
title: 'games',
icon: 'fas fa-gamepad',
to: '/games/reversi',
},
scratchpad: { scratchpad: {
title: 'scratchpad', title: 'scratchpad',
icon: 'fas fa-terminal', icon: 'fas fa-terminal',

View file

@ -83,7 +83,7 @@ export function promiseDialog<T extends Promise<any>>(
onSuccess(res); onSuccess(res);
} else { } else {
success.value = true; success.value = true;
setTimeout(() => { window.setTimeout(() => {
showing.value = false; showing.value = false;
}, 1000); }, 1000);
} }
@ -139,7 +139,7 @@ export async function popup(component: Component | typeof import('*.vue') | Prom
const id = ++popupIdCount; const id = ++popupIdCount;
const dispose = () => { const dispose = () => {
// このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ // このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ
setTimeout(() => { window.setTimeout(() => {
popups.value = popups.value.filter(popup => popup.id !== id); popups.value = popups.value.filter(popup => popup.id !== id);
}, 0); }, 0);
}; };
@ -329,7 +329,7 @@ export function select(props: {
export function success() { export function success() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const showing = ref(true); const showing = ref(true);
setTimeout(() => { window.setTimeout(() => {
showing.value = false; showing.value = false;
}, 1000); }, 1000);
popup(import('@/components/waiting-dialog.vue'), { popup(import('@/components/waiting-dialog.vue'), {

View file

@ -1,68 +1,61 @@
<template> <template>
<MkLoading v-if="!loaded" /> <MkLoading v-if="!loaded"/>
<transition :name="$store.state.animation ? 'zoom' : ''" appear> <transition :name="$store.state.animation ? 'zoom' : ''" appear>
<div v-show="loaded" class="mjndxjch"> <div v-show="loaded" class="mjndxjch">
<img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
<p><b><i class="fas fa-exclamation-triangle"></i> {{ $ts.pageLoadError }}</b></p> <p><b><i class="fas fa-exclamation-triangle"></i> {{ i18n.locale.pageLoadError }}</b></p>
<p v-if="version === meta.version">{{ $ts.pageLoadErrorDescription }}</p> <p v-if="meta && (version === meta.version)">{{ i18n.locale.pageLoadErrorDescription }}</p>
<p v-else-if="serverIsDead">{{ $ts.serverIsDead }}</p> <p v-else-if="serverIsDead">{{ i18n.locale.serverIsDead }}</p>
<template v-else> <template v-else>
<p>{{ $ts.newVersionOfClientAvailable }}</p> <p>{{ i18n.locale.newVersionOfClientAvailable }}</p>
<p>{{ $ts.youShouldUpgradeClient }}</p> <p>{{ i18n.locale.youShouldUpgradeClient }}</p>
<MkButton class="button primary" @click="reload">{{ $ts.reload }}</MkButton> <MkButton class="button primary" @click="reload">{{ i18n.locale.reload }}</MkButton>
</template> </template>
<p><MkA to="/docs/general/troubleshooting" class="_link">{{ $ts.troubleshooting }}</MkA></p> <p><MkA to="/docs/general/troubleshooting" class="_link">{{ i18n.locale.troubleshooting }}</MkA></p>
<p v-if="error" class="error">ERROR: {{ error }}</p> <p v-if="error" class="error">ERROR: {{ error }}</p>
</div> </div>
</transition> </transition>
</template> </template>
<script lang="ts"> <script lang="ts" setup>
import { defineComponent } from 'vue'; import { } from 'vue';
import * as misskey from 'misskey-js';
import MkButton from '@/components/ui/button.vue'; import MkButton from '@/components/ui/button.vue';
import * as symbols from '@/symbols'; import * as symbols from '@/symbols';
import { version } from '@/config'; import { version } from '@/config';
import * as os from '@/os'; import * as os from '@/os';
import { unisonReload } from '@/scripts/unison-reload'; import { unisonReload } from '@/scripts/unison-reload';
import { i18n } from '@/i18n';
export default defineComponent({ const props = withDefaults(defineProps<{
components: { error?: Error;
MkButton, }>(), {
}, });
props: {
error: { let loaded = $ref(false);
required: false, let serverIsDead = $ref(false);
} let meta = $ref<misskey.entities.LiteInstanceMetadata | null>(null);
},
data() { os.api('meta', {
return { detail: false,
[symbols.PAGE_INFO]: { }).then(res => {
title: this.$ts.error, loaded = true;
icon: 'fas fa-exclamation-triangle' serverIsDead = false;
}, meta = res;
loaded: false, localStorage.setItem('v', res.version);
serverIsDead: false, }, () => {
meta: {} as any, loaded = true;
version, serverIsDead = true;
}; });
},
created() { function reload() {
os.api('meta', { unisonReload();
detail: false }
}).then(meta => {
this.loaded = true; defineExpose({
this.serverIsDead = false; [symbols.PAGE_INFO]: {
this.meta = meta; title: i18n.locale.error,
localStorage.setItem('v', meta.version); icon: 'fas fa-exclamation-triangle',
}, () => {
this.loaded = true;
this.serverIsDead = true;
});
},
methods: {
reload() {
unisonReload();
},
}, },
}); });
</script> </script>

View file

@ -95,7 +95,7 @@ export default defineComponent({
reporterOrigin: 'combined', reporterOrigin: 'combined',
targetUserOrigin: 'combined', targetUserOrigin: 'combined',
pagination: { pagination: {
endpoint: 'admin/abuse-user-reports', endpoint: 'admin/abuse-user-reports' as const,
limit: 10, limit: 10,
params: computed(() => ({ params: computed(() => ({
state: this.state, state: this.state,
@ -106,10 +106,6 @@ export default defineComponent({
} }
}, },
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
acct, acct,

View file

@ -87,10 +87,6 @@ export default defineComponent({
}); });
}, },
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
add() { add() {
this.ads.unshift({ this.ads.unshift({

View file

@ -61,10 +61,6 @@ export default defineComponent({
}); });
}, },
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
add() { add() {
this.announcements.unshift({ this.announcements.unshift({

View file

@ -82,10 +82,6 @@ export default defineComponent({
} }
}, },
async mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
async init() { async init() {
const meta = await os.api('meta', { detail: true }); const meta = await os.api('meta', { detail: true });

View file

@ -37,10 +37,6 @@ export default defineComponent({
} }
}, },
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: { methods: {
bytes, number, bytes, number,
} }

Some files were not shown because too many files have changed in this diff Show more