Merge branch 'develop' into sw-notification-action

This commit is contained in:
tamaina
2021-05-21 19:56:52 +09:00
81 changed files with 2712 additions and 625 deletions

View File

@@ -1,5 +1,5 @@
<template>
<MkModal ref="modal" :manual-showing="manualShowing" :src="src" @click="$refs.modal.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')">
<MkModal ref="modal" :manual-showing="manualShowing" :src="src" :front="true" @click="$refs.modal.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')">
<MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen" ref="picker"/>
</MkModal>
</template>

View File

@@ -9,8 +9,9 @@
<div class="menu" v-else>
<div class="body">
<div>Ads by {{ host }}</div>
<!--<MkButton>{{ $ts.stopThisAd }}</MkButton>-->
<button class="_textButton" @click="toggleMenu">{{ $ts.close }}</button>
<!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>-->
<MkButton v-if="ad.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton>
<button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button>
</div>
</div>
</div>
@@ -19,9 +20,11 @@
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { instance } from '@client/instance';
import { Instance, instance } from '@client/instance';
import { host } from '@client/config';
import MkButton from '@client/components/ui/button.vue';
import { defaultStore } from '@client/store';
import * as os from '@client/os';
export default defineComponent({
components: {
@@ -45,35 +48,65 @@ export default defineComponent({
showMenu.value = !showMenu.value;
};
let ad = null;
const choseAd = (): Instance['ads'][number] | null => {
if (props.specify) {
return props.specify as Instance['ads'][number];
}
if (props.specify) {
ad = props.specify;
} else {
let ads = instance.ads.filter(ad => props.prefer.includes(ad.place));
const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {
...ad,
ratio: 0
} : ad);
let ads = allAds.filter(ad => props.prefer.includes(ad.place));
if (ads.length === 0) {
ads = instance.ads.filter(ad => ad.place === 'square');
ads = allAds.filter(ad => ad.place === 'square');
}
const high = ads.filter(ad => ad.priority === 'high');
const middle = ads.filter(ad => ad.priority === 'middle');
const low = ads.filter(ad => ad.priority === 'low');
const lowPriorityAds = ads.filter(ad => ad.ratio === 0);
ads = ads.filter(ad => ad.ratio !== 0);
if (high.length > 0) {
ad = high[Math.floor(Math.random() * high.length)];
} else if (middle.length > 0) {
ad = middle[Math.floor(Math.random() * middle.length)];
} else if (low.length > 0) {
ad = low[Math.floor(Math.random() * low.length)];
if (ads.length === 0) {
if (lowPriorityAds.length !== 0) {
return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)];
} else {
return null;
}
}
}
const totalFactor = ads.reduce((a, b) => a + b.ratio, 0);
const r = Math.random() * totalFactor;
let stackedFactor = 0;
for (const ad of ads) {
if (r >= stackedFactor && r <= stackedFactor + ad.ratio) {
return ad;
} else {
stackedFactor += ad.ratio;
}
}
return null;
};
const chosen = ref(choseAd());
const reduceFrequency = () => {
if (chosen.value == null) return;
if (defaultStore.state.mutedAds.includes(chosen.value.id)) return;
defaultStore.push('mutedAds', chosen.value.id);
os.success();
chosen.value = choseAd();
showMenu.value = false;
};
return {
ad,
ad: chosen,
showMenu,
toggleMenu,
host,
reduceFrequency,
};
}
});
@@ -157,6 +190,10 @@ export default defineComponent({
margin: 0 auto;
max-width: 400px;
border: solid 1px var(--divider);
> .button {
margin: 8px auto;
}
}
}
}

View File

@@ -9,7 +9,6 @@
</div>
</div>
<div class="gqnyydlz" :style="{ background: color }" v-else>
<i class="fas fa-eye-slash" @click="hide = true"></i>
<a
:href="image.url"
:title="image.name"
@@ -18,6 +17,7 @@
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.name" :title="image.name" :cover="false"/>
<div class="gif" v-if="image.type === 'image/gif'">GIF</div>
</a>
<i class="fas fa-eye-slash" @click="hide = true"></i>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<transition :name="$store.state.animation ? popup ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? popup ? 500 : 300 : 0" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered">
<div v-show="manualShowing != null ? manualShowing : showing" class="mk-modal" v-hotkey.global="keymap" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div v-show="manualShowing != null ? manualShowing : showing" class="qzhlnise" :class="{ front }" v-hotkey.global="keymap" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div class="bg _modalBg" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
<div class="content" :class="{ popup, fixed, top: position === 'top' }" @click.self="onBgClick" ref="content">
<slot></slot>
@@ -41,6 +41,11 @@ export default defineComponent({
},
position: {
required: false
},
front: {
type: Boolean,
required: false,
default: false,
}
},
emits: ['opening', 'click', 'esc', 'close', 'closed'],
@@ -224,7 +229,7 @@ export default defineComponent({
}
}
.mk-modal {
.qzhlnise {
> .bg {
z-index: 10000;
}
@@ -269,5 +274,19 @@ export default defineComponent({
position: fixed;
}
}
&.front {
> .bg {
z-index: 20000;
}
> .content:not(.popup) {
z-index: 20000;
}
> .content.popup {
z-index: 20000;
}
}
}
</style>

View File

@@ -3,10 +3,17 @@ import { api } from './os';
// TODO: 他のタブと永続化されたstateを同期
type Instance = {
export type Instance = {
emojis: {
category: string;
}[];
ads: {
id: string;
ratio: number;
place: string;
url: string;
imageUrl: string;
}[];
};
const data = localStorage.getItem('instance');

View File

@@ -15,12 +15,17 @@
<MkRadio v-model="ad.place" value="horizontal">horizontal</MkRadio>
<MkRadio v-model="ad.place" value="horizontal-big">horizontal-big</MkRadio>
</div>
<!--
<div style="margin: 32px 0;">
{{ $ts.priority }}
<MkRadio v-model="ad.priority" value="high">{{ $ts.high }}</MkRadio>
<MkRadio v-model="ad.priority" value="middle">{{ $ts.middle }}</MkRadio>
<MkRadio v-model="ad.priority" value="low">{{ $ts.low }}</MkRadio>
</div>
-->
<MkInput v-model:value="ad.ratio" type="number">
<span>{{ $ts.ratio }}</span>
</MkInput>
<MkInput v-model:value="ad.expiresAt" type="date">
<span>{{ $ts.expiration }}</span>
</MkInput>
@@ -82,6 +87,7 @@ export default defineComponent({
memo: '',
place: 'square',
priority: 'middle',
ratio: 1,
url: '',
imageUrl: null,
expiresAt: null,

View File

@@ -43,6 +43,7 @@
<FormGroup>
<template #label>{{ $ts.info }}</template>
<FormLink :active="page === 'database'" replace to="/instance/database"><template #icon><i class="fas fa-database"></i></template>{{ $ts.database }}</FormLink>
<FormLink :active="page === 'logs'" replace to="/instance/logs"><template #icon><i class="fas fa-stream"></i></template>{{ $ts.logs }}</FormLink>
</FormGroup>
</FormBase>
</div>
@@ -105,6 +106,7 @@ export default defineComponent({
case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
case 'ads': return defineAsyncComponent(() => import('./ads.vue'));
case 'database': return defineAsyncComponent(() => import('./database.vue'));
case 'logs': return defineAsyncComponent(() => import('./logs.vue'));
case 'abuses': return defineAsyncComponent(() => import('./abuses.vue'));
case 'settings': return defineAsyncComponent(() => import('./settings.vue'));
case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue'));

View File

@@ -123,7 +123,7 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, markRaw } from 'vue';
import Chart from 'chart.js';
import XModalWindow from '@client/components/ui/modal-window.vue';
import MkUsersDialog from '@client/components/users-dialog.vue';
@@ -280,7 +280,7 @@ export default defineComponent({
}
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
this.chartInstance = new Chart(this.canvas, {
this.chartInstance = markRaw(new Chart(this.canvas, {
type: 'line',
data: {
labels: new Array(chartLimit).fill(0).map((_, i) => this.getDate(i).toLocaleString()).slice().reverse(),
@@ -331,7 +331,7 @@ export default defineComponent({
mode: 'index',
}
}
});
}));
},
getDate(ago: number) {

View File

@@ -5,13 +5,13 @@
<span>{{ $ts.domain }}</span>
</MkInput>
<MkSelect v-model:value="logLevel">
<template #label>{{ $ts.level }}</template>
<option value="all">{{ $ts.levels.all }}</option>
<option value="info">{{ $ts.levels.info }}</option>
<option value="success">{{ $ts.levels.success }}</option>
<option value="warning">{{ $ts.levels.warning }}</option>
<option value="error">{{ $ts.levels.error }}</option>
<option value="debug">{{ $ts.levels.debug }}</option>
<template #label>Level</template>
<option value="all">All</option>
<option value="info">Info</option>
<option value="success">Success</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="debug">Debug</option>
</MkSelect>
</div>
@@ -45,6 +45,8 @@ export default defineComponent({
MkTextarea,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
@@ -72,6 +74,10 @@ export default defineComponent({
this.fetchLogs();
},
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
methods: {
fetchLogs() {
os.api('admin/logs', {

View File

@@ -27,7 +27,7 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, markRaw } from 'vue';
import Chart from 'chart.js';
import number from '../../filters/number';
@@ -69,7 +69,7 @@ export default defineComponent({
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
this.chart = new Chart(this.$refs.chart, {
this.chart = markRaw(new Chart(this.$refs.chart, {
type: 'line',
data: {
labels: [],
@@ -152,7 +152,7 @@ export default defineComponent({
mode: 'index',
}
}
});
}));
this.connection.on('stats', this.onStats);
this.connection.on('statsLog', this.onStatsLog);

View File

@@ -55,6 +55,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: []
},
mutedAds: {
where: 'account',
default: [] as string[]
},
menu: {
where: 'deviceAccount',

View File

@@ -64,11 +64,8 @@ export default defineComponent({
<style lang="scss" scoped>
.wtdtxvec {
--margin: 8px;
--panelShadow: none;
padding: 0 var(--margin);
::v-deep(._panel) {
box-shadow: none;
}
}
</style>

View File

@@ -114,6 +114,7 @@ export default defineComponent({
overflow: hidden;
font-size: 0.9em;
color: var(--fg);
padding-right: 8px;
> .a {
display: block;
@@ -129,6 +130,9 @@ export default defineComponent({
font-size: 75%;
opacity: 0.7;
line-height: $bodyInfoHieght;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}

View File

@@ -1,4 +1,4 @@
# AiScript
## 関数
デフォルトで値渡しです。
## Fungsi
Secara bawaan, berjalan sebagai pass dari value.

View File

@@ -1,19 +1,19 @@
# Misskey API
MisskeyAPIを使ってMisskeyクライアント、Misskey連携Webサービス、Bot等(以下「アプリケーション」と呼びます)を開発できます。 ストリーミングAPIもあるので、リアルタイム性のあるアプリケーションを作ることも可能です。
Dengan menggunakan Misskey API kamu dapat mengembangkan klien Misskey, Webservice yang terintegrasi dengan Misskey, Bots (nantinya akan disebut "Aplikasi" disini), dll. Terdapat juga streaming API, yang memungkinan untuk membuat aplikasi real-time.
APIを使い始めるには、まずアクセストークンを取得する必要があります。 このドキュメントでは、アクセストークンを取得する手順を説明した後、基本的なAPIの使い方を説明します。
Untuk memulai menggunakin API, kamu harus memiliki access token terlebih dahulu. Halaman ini akan menjelaskan bagaimana untuk mendapatkan access token dan menjelaskan instruksi penggunaan dasar dari Misskey API.
## アクセストークンの取得
基本的に、APIはリクエストにはアクセストークンが必要となります。 APIにリクエストするのが自分自身なのか、不特定の利用者に使ってもらうアプリケーションなのかによって取得手順は異なります。
## Mendapatkan access token
Pada dasarnya, semua request API membutuhkan access token. Metode untuk mendapatkan sebuah access token bermacam-macam bergantung pada kamu sendiri yang mengirim request API atau request tersebut dikirim melalui aplikasi yang dipakai oleh pengguna akhir.
* 前者の場合: [「自分自身のアクセストークンを手動発行する」](#自分自身のアクセストークンを手動発行する)に進む
* 後者の場合: [「アプリケーション利用者にアクセストークンの発行をリクエストする」](#アプリケーション利用者にアクセストークンの発行をリクエストする)に進む
* Apabila kamu pengguna lama: Langsung saja menuju [ "Menerbitkan access token untuk akun kamu sendiri secara manual" ](#自分自身のアクセストークンを手動発行する)
* Apabila kamu pengguna baru: Langsung saja menuju [ "Meminta aplikasi pengguna untuk menghasilkan access token" ](#アプリケーション利用者にアクセストークンの発行をリクエストする)
### 自分自身のアクセストークンを手動発行する
「設定 > API」で、自分のアクセストークンを発行できます。
### Menerbitkan access token untuk akun kamu sendiri secara manual
Kamu dapat membuat access token untuk akun milikmu di Pengaturan > API.
[「APIの使い方」へ進む](#APIの使い方)
[Lanjutkan untuk menggunakan API.](#APIの使い方)
### アプリケーション利用者にアクセストークンの発行をリクエストする
アプリケーション利用者のアクセストークンを取得するには、以下の手順で発行をリクエストします。
@@ -49,7 +49,7 @@ UUIDを生成する。以後これをセッションIDと呼びます。
* `token` ... ユーザーのアクセストークン
* `user` ... ユーザーの情報
[「APIの使い方」へ進む](#APIの使い方)
[Lanjutkan untuk menggunakan API.](#APIの使い方)
## APIの使い方
**APIはすべてPOSTで、リクエスト/レスポンスともにJSON形式です。RESTではありません。** アクセストークンは、`i`というパラメータ名でリクエストに含めます。

View File

@@ -1,7 +1,7 @@
# プラグインの作成
Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。
## メタデータ
## Metadata
プラグインは、AiScriptのメタデータ埋め込み機能を使って、デフォルトとしてプラグインのメタデータを定義する必要があります。 メタデータは次のプロパティを含むオブジェクトです。
### name

View File

@@ -1,2 +1,2 @@
# Emoji kustom
カスタム絵文字は、インスタンスで用意された画像を絵文字のように使える機能です。 ノート、リアクション、チャット、自己紹介、名前などの場所で使うことができます。 カスタム絵文字をそれらの場所で使うには、絵文字ピッカーボタン(ある場合)を押すか、`:`を入力して絵文字サジェストを表示します。 テキスト内に`:foo:`のような形式の文字列が見つかると、`foo`の部分がカスタム絵文字名と解釈され、表示時には対応したカスタム絵文字に置き換わります。
Emoji kustom merupakan fungsi yang menyediakan gambar terunggah ke server untuk digunakan seperti emoji. Emoji kustom ini dapt digunakan pada note, reaksi, obrolan, profil, dan bahkan username serta tempat-tempat lainnya. Untuk menggunakan emoji kustom pada tempat yang disebutkan di atas, tekan tombol pemilih Emoji (bila ada) atau ketik sebuah `:` dan jendela saran emoji akan muncul. Jika sebuah string yang terlihat seperti `:foo:` ditemukan dalam text apapun, maka sebagian dari `foo` diinterpretaskan sebagai nama emoji kustom dan akan digantikan dengan gambar emoji kustom ketika ditampilkan.

View File

@@ -1,4 +1,4 @@
# デッキ
# Dek
デッキは利用可能なUIのひとつです。「カラム」と呼ばれるビューを複数並べて表示させることで、カスタマイズ性が高く、情報量の多いUIが構築できることが特徴です。

View File

@@ -1,2 +1,2 @@
# Ikuti
ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。
Jika kamu mengikuti seorang pengguna, postingan dari pengguna tersebut akan muncul pada linimasa kamu.Akan tetapi, balasan dari mereka kepada pengguna lain tidak akan ditampilkan. Untuk mengikuti seorang pengguna, klik tombol "Ikuti" pada halaman pengguna mereka.Untuk berhenti mengikuti seorang pengguna, klik tombol tersebut sekali lagi.

View File

@@ -1,6 +1,6 @@
# キーボードショートカット
## グローバル
## Global
これらのショートカットは基本的にどこでも使えます。
<table>
<thead>

View File

@@ -1,6 +1,6 @@
# Pages
## 変数
## Variabel
変数を使うことで動的なページを作成できます。テキスト内で <b>{ 変数名 }</b> と書くとそこに変数の値を埋め込めます。例えば <b>Hello { thing } world!</b> というテキストで、変数(thing)の値が <b>ai</b> だった場合、テキストは <b>Hello ai world!</b> になります。
変数の評価(値を算出すること)は上から下に行われるので、ある変数の中で自分より下の変数を参照することはできません。例えば上から <b>A、B、C</b> と3つの変数を定義したとき、<b>C</b>の中で<b>A</b><b>B</b>を参照することはできますが、<b>A</b>の中で<b>B</b><b>C</b>を参照することはできません。

View File

@@ -110,7 +110,7 @@ y = Math.floor(pos / mapWidth)
```
### フォームコントロールの種類
#### スイッチ
#### Beralih
type: `switch` スイッチを表示します。何かの機能をオン/オフさせたい場合に有用です。
##### プロパティ

View File

@@ -31,7 +31,7 @@
**ストリームでのやり取りはすべてJSONです。**
## チャンネル
## Kanal
MisskeyのストリーミングAPIにはチャンネルという概念があります。これは、送受信する情報を分離するための仕組みです。 Misskeyのストリームに接続しただけでは、まだリアルタイムでタイムラインの投稿を受信したりはできません。 ストリーム上でチャンネルに接続することで、様々な情報を受け取ったり情報を送信したりすることができるようになります。
### チャンネルに接続する

View File

@@ -1,4 +1,4 @@
# テーマ
# Tema
テーマを設定して、Misskeyクライアントの見た目を変更できます。
@@ -61,8 +61,8 @@
* 関数(後述)
* `:{関数名}<{引数}<{色}`
#### 定数
#### Konstanta
「CSS変数として出力はしたくないが、他のCSS変数の値として使いまわしたい」値があるときは、定数を使うと便利です。 キー名を`$`で始めると、そのキーはCSS変数として出力されません。
#### 関数
#### Fungsi
wip

View File

@@ -1,15 +1,15 @@
# タイムラインの比較
# Perbandingan Linimasa
https://docs.google.com/spreadsheets/d/1lxQ2ugKrhz58Bg96HTDK_2F98BUritkMyIiBkOByjHA/edit?usp=sharing
## ホーム
自分のフォローしているユーザーの投稿
## Beranda
Postingan dari pengguna yang kamu ikuti
## ローカル
全てのローカルユーザーの「ホーム」指定されていない投稿
## Lokal
Seluruh postingan dari pengguna lokal yang tidak ditandai sebagai "Hanya Beranda"
## ソーシャル
自分のフォローしているユーザーの投稿と、全てのローカルユーザーの「ホーム」指定されていない投稿
## Sosial
Postingan dari pengguna yang kamu ikuti beserta dengan semua postingan dari pengguna lokal yang tidak ditandai sebagai "Hanya Beranda"
## グローバル
全てのローカルユーザーの「ホーム」指定されていない投稿と、サーバーに届いた全てのリモートユーザーの「ホーム」指定されていない投稿
## Global
Seluruh postingan dari pengguna lokal yang tidak ditandai sebagai "Hanya Beranda" dan juga pesan yang diterima oleh server yang tidak ditandai sebagai "Hanya Beranda"

View File

@@ -8,7 +8,7 @@ Wpisy użytkowników, których obserwujesz
## Lokalne
全てのローカルユーザーの「ホーム」指定されていない投稿
## ソーシャル
## Społeczność
自分のフォローしているユーザーの投稿と、全てのローカルユーザーの「ホーム」指定されていない投稿
## Globalne

View File

@@ -55,7 +55,8 @@ export async function getHtml(url: string, accept = 'text/html, */*', timeout =
const _http = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
});
lookup: cache.lookup,
} as http.AgentOptions);
/**
* Get https non-proxy agent
@@ -70,14 +71,14 @@ const _https = new https.Agent({
* Get http proxy or non-proxy agent
*/
export const httpAgent = config.proxy
? new HttpProxyAgent(config.proxy) as unknown as http.Agent
? new HttpProxyAgent(config.proxy)
: _http;
/**
* Get https proxy or non-proxy agent
*/
export const httpsAgent = config.proxy
? new HttpsProxyAgent(config.proxy) as unknown as https.Agent
? new HttpsProxyAgent(config.proxy)
: _https;
/**

View File

@@ -23,11 +23,17 @@ export class Ad {
})
public place: string;
// 今は使われていないが将来的に活用される可能性はある
@Column('varchar', {
length: 32, nullable: false
})
public priority: string;
@Column('integer', {
default: 1, nullable: false
})
public ratio: number;
@Column('varchar', {
length: 1024, nullable: false
})

View File

@@ -7,6 +7,8 @@ import { Channel } from './channel';
@Entity()
@Index('IDX_NOTE_TAGS', { synchronize: false })
@Index('IDX_NOTE_MENTIONS', { synchronize: false })
@Index('IDX_NOTE_VISIBLE_USER_IDS', { synchronize: false })
export class Note {
@PrimaryColumn(id())
public id: string;

View File

@@ -1,7 +1,6 @@
import * as httpSignature from 'http-signature';
import config from '@/config';
import { User } from '../models/entities/user';
import { program } from '../argv';
import processDeliver from './processors/deliver';
@@ -11,14 +10,9 @@ import procesObjectStorage from './processors/object-storage';
import { queueLogger } from './logger';
import { DriveFile } from '../models/entities/drive-file';
import { getJobInfo } from './get-job-info';
import { IActivity } from '../remote/activitypub/type';
import { dbQueue, deliverQueue, inboxQueue, objectStorageQueue } from './queues';
export type InboxJobData = {
activity: IActivity,
/** HTTP-Signature */
signature: httpSignature.IParsedSignature
};
import { ThinUser } from './types';
import { IActivity } from '@/remote/activitypub/type';
function renderError(e: Error): any {
return {
@@ -65,8 +59,9 @@ objectStorageQueue
.on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) }))
.on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`));
export function deliver(user: { id: User['id']; host: null; }, content: any, to: any) {
export function deliver(user: ThinUser, content: unknown, to: string | null) {
if (content == null) return null;
if (to == null) return null;
const data = {
user,
@@ -76,6 +71,7 @@ export function deliver(user: { id: User['id']; host: null; }, content: any, to:
return deliverQueue.add(data, {
attempts: config.deliverJobMaxAttempts || 12,
timeout: 1 * 60 * 1000, // 1min
backoff: {
type: 'exponential',
delay: 60 * 1000
@@ -85,7 +81,7 @@ export function deliver(user: { id: User['id']; host: null; }, content: any, to:
});
}
export function inbox(activity: any, signature: httpSignature.IParsedSignature) {
export function inbox(activity: IActivity, signature: httpSignature.IParsedSignature) {
const data = {
activity: activity,
signature
@@ -93,6 +89,7 @@ export function inbox(activity: any, signature: httpSignature.IParsedSignature)
return inboxQueue.add(data, {
attempts: config.inboxJobMaxAttempts || 8,
timeout: 5 * 60 * 1000, // 5min
backoff: {
type: 'exponential',
delay: 60 * 1000
@@ -102,7 +99,7 @@ export function inbox(activity: any, signature: httpSignature.IParsedSignature)
});
}
export function createDeleteDriveFilesJob(user: { id: User['id'] }) {
export function createDeleteDriveFilesJob(user: ThinUser) {
return dbQueue.add('deleteDriveFiles', {
user: user
}, {
@@ -111,7 +108,7 @@ export function createDeleteDriveFilesJob(user: { id: User['id'] }) {
});
}
export function createExportNotesJob(user: { id: User['id'] }) {
export function createExportNotesJob(user: ThinUser) {
return dbQueue.add('exportNotes', {
user: user
}, {
@@ -120,7 +117,7 @@ export function createExportNotesJob(user: { id: User['id'] }) {
});
}
export function createExportFollowingJob(user: { id: User['id'] }) {
export function createExportFollowingJob(user: ThinUser) {
return dbQueue.add('exportFollowing', {
user: user
}, {
@@ -129,7 +126,7 @@ export function createExportFollowingJob(user: { id: User['id'] }) {
});
}
export function createExportMuteJob(user: { id: User['id'] }) {
export function createExportMuteJob(user: ThinUser) {
return dbQueue.add('exportMute', {
user: user
}, {
@@ -138,7 +135,7 @@ export function createExportMuteJob(user: { id: User['id'] }) {
});
}
export function createExportBlockingJob(user: { id: User['id'] }) {
export function createExportBlockingJob(user: ThinUser) {
return dbQueue.add('exportBlocking', {
user: user
}, {
@@ -147,7 +144,7 @@ export function createExportBlockingJob(user: { id: User['id'] }) {
});
}
export function createExportUserListsJob(user: { id: User['id'] }) {
export function createExportUserListsJob(user: ThinUser) {
return dbQueue.add('exportUserLists', {
user: user
}, {
@@ -156,7 +153,7 @@ export function createExportUserListsJob(user: { id: User['id'] }) {
});
}
export function createImportFollowingJob(user: { id: User['id'] }, fileId: DriveFile['id']) {
export function createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
return dbQueue.add('importFollowing', {
user: user,
fileId: fileId
@@ -166,7 +163,7 @@ export function createImportFollowingJob(user: { id: User['id'] }, fileId: Drive
});
}
export function createImportUserListsJob(user: { id: User['id'] }, fileId: DriveFile['id']) {
export function createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) {
return dbQueue.add('importUserLists', {
user: user,
fileId: fileId

View File

@@ -1,8 +1,8 @@
import * as Queue from 'bull';
import * as Bull from 'bull';
import config from '@/config';
export function initialize(name: string, limitPerSec = -1) {
return new Queue(name, {
export function initialize<T>(name: string, limitPerSec = -1) {
return new Bull<T>(name, {
redis: {
port: config.redis.port,
host: config.redis.host,

View File

@@ -4,10 +4,11 @@ import { queueLogger } from '../../logger';
import { deleteFileSync } from '../../../services/drive/delete-file';
import { Users, DriveFiles } from '../../../models';
import { MoreThan } from 'typeorm';
import { DbUserJobData } from '@/queue/types';
const logger = queueLogger.createSubLogger('delete-drive-files');
export async function deleteDriveFiles(job: Bull.Job, done: any): Promise<void> {
export async function deleteDriveFiles(job: Bull.Job<DbUserJobData>, done: any): Promise<void> {
logger.info(`Deleting drive files of ${job.data.user.id} ...`);
const user = await Users.findOne(job.data.user.id);

View File

@@ -8,10 +8,11 @@ import dateFormat = require('dateformat');
import { getFullApAccount } from '@/misc/convert-host';
import { Users, Blockings } from '../../../models';
import { MoreThan } from 'typeorm';
import { DbUserJobData } from '@/queue/types';
const logger = queueLogger.createSubLogger('export-blocking');
export async function exportBlocking(job: Bull.Job, done: any): Promise<void> {
export async function exportBlocking(job: Bull.Job<DbUserJobData>, done: any): Promise<void> {
logger.info(`Exporting blocking of ${job.data.user.id} ...`);
const user = await Users.findOne(job.data.user.id);
@@ -61,7 +62,7 @@ export async function exportBlocking(job: Bull.Job, done: any): Promise<void> {
}
const content = getFullApAccount(u.username, u.host);
await new Promise((res, rej) => {
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);

View File

@@ -8,10 +8,11 @@ import dateFormat = require('dateformat');
import { getFullApAccount } from '@/misc/convert-host';
import { Users, Followings } from '../../../models';
import { MoreThan } from 'typeorm';
import { DbUserJobData } from '@/queue/types';
const logger = queueLogger.createSubLogger('export-following');
export async function exportFollowing(job: Bull.Job, done: any): Promise<void> {
export async function exportFollowing(job: Bull.Job<DbUserJobData>, done: any): Promise<void> {
logger.info(`Exporting following of ${job.data.user.id} ...`);
const user = await Users.findOne(job.data.user.id);
@@ -61,7 +62,7 @@ export async function exportFollowing(job: Bull.Job, done: any): Promise<void> {
}
const content = getFullApAccount(u.username, u.host);
await new Promise((res, rej) => {
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);

View File

@@ -8,10 +8,11 @@ import dateFormat = require('dateformat');
import { getFullApAccount } from '@/misc/convert-host';
import { Users, Mutings } from '../../../models';
import { MoreThan } from 'typeorm';
import { DbUserJobData } from '@/queue/types';
const logger = queueLogger.createSubLogger('export-mute');
export async function exportMute(job: Bull.Job, done: any): Promise<void> {
export async function exportMute(job: Bull.Job<DbUserJobData>, done: any): Promise<void> {
logger.info(`Exporting mute of ${job.data.user.id} ...`);
const user = await Users.findOne(job.data.user.id);
@@ -61,7 +62,7 @@ export async function exportMute(job: Bull.Job, done: any): Promise<void> {
}
const content = getFullApAccount(u.username, u.host);
await new Promise((res, rej) => {
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);

View File

@@ -9,10 +9,11 @@ import { Users, Notes, Polls } from '../../../models';
import { MoreThan } from 'typeorm';
import { Note } from '../../../models/entities/note';
import { Poll } from '../../../models/entities/poll';
import { DbUserJobData } from '@/queue/types';
const logger = queueLogger.createSubLogger('export-notes');
export async function exportNotes(job: Bull.Job, done: any): Promise<void> {
export async function exportNotes(job: Bull.Job<DbUserJobData>, done: any): Promise<void> {
logger.info(`Exporting notes of ${job.data.user.id} ...`);
const user = await Users.findOne(job.data.user.id);
@@ -33,7 +34,7 @@ export async function exportNotes(job: Bull.Job, done: any): Promise<void> {
const stream = fs.createWriteStream(path, { flags: 'a' });
await new Promise((res, rej) => {
await new Promise<void>((res, rej) => {
stream.write('[', err => {
if (err) {
logger.error(err);
@@ -72,7 +73,7 @@ export async function exportNotes(job: Bull.Job, done: any): Promise<void> {
poll = await Polls.findOneOrFail({ noteId: note.id });
}
const content = JSON.stringify(serialize(note, poll));
await new Promise((res, rej) => {
await new Promise<void>((res, rej) => {
stream.write(exportedNotesCount === 0 ? content : ',\n' + content, err => {
if (err) {
logger.error(err);
@@ -92,7 +93,7 @@ export async function exportNotes(job: Bull.Job, done: any): Promise<void> {
job.progress(exportedNotesCount / total);
}
await new Promise((res, rej) => {
await new Promise<void>((res, rej) => {
stream.write(']', err => {
if (err) {
logger.error(err);

View File

@@ -8,10 +8,11 @@ import dateFormat = require('dateformat');
import { getFullApAccount } from '@/misc/convert-host';
import { Users, UserLists, UserListJoinings } from '../../../models';
import { In } from 'typeorm';
import { DbUserJobData } from '@/queue/types';
const logger = queueLogger.createSubLogger('export-user-lists');
export async function exportUserLists(job: Bull.Job, done: any): Promise<void> {
export async function exportUserLists(job: Bull.Job<DbUserJobData>, done: any): Promise<void> {
logger.info(`Exporting user lists of ${job.data.user.id} ...`);
const user = await Users.findOne(job.data.user.id);
@@ -45,7 +46,7 @@ export async function exportUserLists(job: Bull.Job, done: any): Promise<void> {
for (const u of users) {
const acct = getFullApAccount(u.username, u.host);
const content = `${list.name},${acct}`;
await new Promise((res, rej) => {
await new Promise<void>((res, rej) => {
stream.write(content + '\n', err => {
if (err) {
logger.error(err);

View File

@@ -7,10 +7,11 @@ import { resolveUser } from '../../../remote/resolve-user';
import { downloadTextFile } from '@/misc/download-text-file';
import { isSelfHost, toPuny } from '@/misc/convert-host';
import { Users, DriveFiles } from '../../../models';
import { DbUserImportJobData } from '@/queue/types';
const logger = queueLogger.createSubLogger('import-following');
export async function importFollowing(job: Bull.Job, done: any): Promise<void> {
export async function importFollowing(job: Bull.Job<DbUserImportJobData>, done: any): Promise<void> {
logger.info(`Importing following of ${job.data.user.id} ...`);
const user = await Users.findOne(job.data.user.id);

View File

@@ -8,10 +8,11 @@ import { downloadTextFile } from '@/misc/download-text-file';
import { isSelfHost, toPuny } from '@/misc/convert-host';
import { DriveFiles, Users, UserLists, UserListJoinings } from '../../../models';
import { genId } from '@/misc/gen-id';
import { DbUserImportJobData } from '@/queue/types';
const logger = queueLogger.createSubLogger('import-user-lists');
export async function importUserLists(job: Bull.Job, done: any): Promise<void> {
export async function importUserLists(job: Bull.Job<DbUserImportJobData>, done: any): Promise<void> {
logger.info(`Importing user lists of ${job.data.user.id} ...`);
const user = await Users.findOne(job.data.user.id);

View File

@@ -1,4 +1,5 @@
import * as Bull from 'bull';
import { DbJobData } from '@/queue/types';
import { deleteDriveFiles } from './delete-drive-files';
import { exportNotes } from './export-notes';
import { exportFollowing } from './export-following';
@@ -17,10 +18,10 @@ const jobs = {
exportUserLists,
importFollowing,
importUserLists
} as any;
} as Record<string, Bull.ProcessCallbackFunction<DbJobData> | Bull.ProcessPromiseFunction<DbJobData>>;
export default function(dbQueue: Bull.Queue) {
export default function(dbQueue: Bull.Queue<DbJobData>) {
for (const [k, v] of Object.entries(jobs)) {
dbQueue.process(k, v as any);
dbQueue.process(k, v);
}
}

View File

@@ -10,6 +10,7 @@ import { fetchMeta } from '@/misc/fetch-meta';
import { toPuny } from '@/misc/convert-host';
import { Cache } from '@/misc/cache';
import { Instance } from '../../models/entities/instance';
import { DeliverJobData } from '../types';
const logger = new Logger('deliver');
@@ -17,7 +18,7 @@ let latest: string | null = null;
const suspendedHostsCache = new Cache<Instance[]>(1000 * 60 * 60);
export default async (job: Bull.Job) => {
export default async (job: Bull.Job<DeliverJobData>) => {
const { host } = new URL(job.data.to);
// ブロックしてたら中断

View File

@@ -10,7 +10,7 @@ import { fetchMeta } from '@/misc/fetch-meta';
import { toPuny, extractDbHost } from '@/misc/convert-host';
import { getApId } from '../../remote/activitypub/type';
import { fetchInstanceMetadata } from '../../services/fetch-instance-metadata';
import { InboxJobData } from '..';
import { InboxJobData } from '../types';
import DbResolver from '../../remote/activitypub/db-resolver';
import { resolvePerson } from '../../remote/activitypub/models/person';
import { LdSignature } from '../../remote/activitypub/misc/ld-signature';
@@ -23,7 +23,7 @@ export default async (job: Bull.Job<InboxJobData>): Promise<string> => {
const activity = job.data.activity;
//#region Log
const info = Object.assign({}, activity);
const info = Object.assign({}, activity) as any;
delete info['@context'];
logger.debug(JSON.stringify(info, null, 2));
//#endregion

View File

@@ -7,7 +7,7 @@ import { MoreThan, Not, IsNull } from 'typeorm';
const logger = queueLogger.createSubLogger('clean-remote-files');
export default async function cleanRemoteFiles(job: Bull.Job, done: any): Promise<void> {
export default async function cleanRemoteFiles(job: Bull.Job<{}>, done: any): Promise<void> {
logger.info(`Deleting cached remote files...`);
let deletedCount = 0;

View File

@@ -1,7 +1,8 @@
import { ObjectStorageFileJobData } from '@/queue/types';
import * as Bull from 'bull';
import { deleteObjectStorageFile } from '../../../services/drive/delete-file';
export default async (job: Bull.Job) => {
export default async (job: Bull.Job<ObjectStorageFileJobData>) => {
const key: string = job.data.key;
await deleteObjectStorageFile(key);

View File

@@ -1,14 +1,15 @@
import * as Bull from 'bull';
import { ObjectStorageJobData } from '@/queue/types';
import deleteFile from './delete-file';
import cleanRemoteFiles from './clean-remote-files';
const jobs = {
deleteFile,
cleanRemoteFiles,
} as any;
} as Record<string, Bull.ProcessCallbackFunction<ObjectStorageJobData> | Bull.ProcessPromiseFunction<ObjectStorageJobData>>;
export default function(q: Bull.Queue) {
for (const [k, v] of Object.entries(jobs)) {
q.process(k, 16, v as any);
q.process(k, 16, v);
}
}

View File

@@ -1,7 +1,8 @@
import config from '@/config';
import { initialize as initializeQueue } from './initialize';
import { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData } from './types';
export const deliverQueue = initializeQueue('deliver', config.deliverJobPerSec || 128);
export const inboxQueue = initializeQueue('inbox', config.inboxJobPerSec || 16);
export const dbQueue = initializeQueue('db');
export const objectStorageQueue = initializeQueue('objectStorage');
export const deliverQueue = initializeQueue<DeliverJobData>('deliver', config.deliverJobPerSec || 128);
export const inboxQueue = initializeQueue<InboxJobData>('inbox', config.inboxJobPerSec || 16);
export const dbQueue = initializeQueue<DbJobData>('db');
export const objectStorageQueue = initializeQueue<ObjectStorageJobData>('objectStorage');

39
src/queue/types.ts Normal file
View File

@@ -0,0 +1,39 @@
import { DriveFile } from '@/models/entities/drive-file';
import { User } from '@/models/entities/user';
import { IActivity } from '@/remote/activitypub/type';
import * as httpSignature from 'http-signature';
export type DeliverJobData = {
/** Actor */
user: ThinUser;
/** Activity */
content: unknown;
/** inbox URL to deliver */
to: string;
};
export type InboxJobData = {
activity: IActivity;
signature: httpSignature.IParsedSignature;
};
export type DbJobData = DbUserJobData | DbUserImportJobData;
export type DbUserJobData = {
user: ThinUser;
};
export type DbUserImportJobData = {
user: ThinUser;
fileId: DriveFile['id'];
};
export type ObjectStorageJobData = ObjectStorageFileJobData | {};
export type ObjectStorageFileJobData = {
key: string;
};
export type ThinUser = {
id: User['id'];
};

View File

@@ -11,6 +11,11 @@ export default async (actor: IRemoteUser, activity: ILike) => {
await extractEmojis(activity.tag || [], actor.host).catch(() => null);
await create(actor, note, activity._misskey_reaction || activity.content || activity.name);
return `ok`;
return await create(actor, note, activity._misskey_reaction || activity.content || activity.name).catch(e => {
if (e.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') {
return 'skip: already reacted';
} else {
throw e;
}
}).then(() => 'ok');
};

View File

@@ -511,6 +511,10 @@ const activitystreams = {
"shares": {
"@id": "as:shares",
"@type": "@id"
},
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
}
}
};

View File

@@ -15,7 +15,33 @@ export const renderActivity = (x: any): IActivity | null => {
return Object.assign({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1'
'https://w3id.org/security/v1',
{
// as non-standards
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
sensitive: 'as:sensitive',
Hashtag: 'as:Hashtag',
quoteUrl: 'as:quoteUrl',
// Mastodon
toot: 'http://joinmastodon.org/ns#',
Emoji: 'toot:Emoji',
featured: 'toot:featured',
discoverable: 'toot:discoverable',
// schema
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',
value: 'schema:value',
// Misskey
misskey: `${config.url}/ns#`,
'_misskey_content': 'misskey:_misskey_content',
'_misskey_quote': 'misskey:_misskey_quote',
'_misskey_reaction': 'misskey:_misskey_reaction',
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_talk': 'misskey:_misskey_talk',
'isCat': 'misskey:isCat',
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',
}
]
}, x);
};
@@ -25,35 +51,6 @@ export const attachLdSignature = async (activity: any, user: { id: User['id']; h
const keypair = await getUserKeypair(user.id);
const obj = {
// as non-standards
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
sensitive: 'as:sensitive',
Hashtag: 'as:Hashtag',
quoteUrl: 'as:quoteUrl',
// Mastodon
toot: 'http://joinmastodon.org/ns#',
Emoji: 'toot:Emoji',
featured: 'toot:featured',
discoverable: 'toot:discoverable',
// schema
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',
value: 'schema:value',
// Misskey
misskey: `${config.url}/ns#`,
'_misskey_content': 'misskey:_misskey_content',
'_misskey_quote': 'misskey:_misskey_quote',
'_misskey_reaction': 'misskey:_misskey_reaction',
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_talk': 'misskey:_misskey_talk',
'isCat': 'misskey:isCat',
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',
};
activity['@context'].push(obj);
const ldSignature = new LdSignature();
ldSignature.debug = false;
activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${config.url}/users/${user.id}#main-key`);

View File

@@ -1,8 +1,12 @@
import config from '@/config';
import { ILocalUser, User } from '../../../models/entities/user';
export default (object: any, user: { id: User['id'] }) => ({
type: 'Undo',
actor: `${config.url}/users/${user.id}`,
object
});
export default (object: any, user: { id: User['id'] }) => {
if (object == null) return null;
return {
type: 'Undo',
actor: `${config.url}/users/${user.id}`,
object
};
};

View File

@@ -22,7 +22,7 @@ export function generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: U
// または 自分自身
.orWhere('note.userId = :userId1', { userId1: me.id })
// または 自分宛て
.orWhere(':userId2 = ANY(note.visibleUserIds)', { userId2: me.id })
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`)
.orWhere(new Brackets(qb => { qb
// または フォロワー宛ての投稿であり、
.where('note.visibility = \'followers\'')

View File

@@ -22,6 +22,9 @@ export const meta = {
priority: {
validator: $.str
},
ratio: {
validator: $.num.int().min(0)
},
expiresAt: {
validator: $.num.int()
},
@@ -39,6 +42,7 @@ export default define(meta, async (ps) => {
url: ps.url,
imageUrl: ps.imageUrl,
priority: ps.priority,
ratio: ps.ratio,
place: ps.place,
memo: ps.memo,
});

View File

@@ -29,6 +29,9 @@ export const meta = {
priority: {
validator: $.str
},
ratio: {
validator: $.num.int().min(0)
},
expiresAt: {
validator: $.num.int()
},
@@ -52,6 +55,7 @@ export default define(meta, async (ps, me) => {
url: ps.url,
place: ps.place,
priority: ps.priority,
ratio: ps.ratio,
memo: ps.memo,
imageUrl: ps.imageUrl,
expiresAt: new Date(ps.expiresAt),

View File

@@ -0,0 +1,26 @@
import define from '../../define';
import { getConnection } from 'typeorm';
export const meta = {
requireCredential: true as const,
requireModerator: true,
tags: ['admin'],
params: {
},
};
export default define(meta, async () => {
const stats = await
getConnection().query(`SELECT * FROM pg_indexes;`)
.then(recs => {
const res = [] as { tablename: string; indexname: string; }[];
for (const rec of recs) {
res.push(rec);
}
return res;
});
return stats;
});

View File

@@ -509,9 +509,10 @@ export default define(meta, async (ps, me) => {
maxNoteTextLength: Math.min(instance.maxNoteTextLength, DB_MAX_NOTE_TEXT_LENGTH),
emojis: await Emojis.packMany(emojis),
ads: ads.map(ad => ({
id: ad.id,
url: ad.url,
place: ad.place,
priority: ad.priority,
ratio: ad.ratio,
imageUrl: ad.imageUrl,
})),
enableEmail: instance.enableEmail,

View File

@@ -60,8 +60,8 @@ export default define(meta, async (ps, user) => {
const query = makePaginationQuery(Notes.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => { qb
.where(`:meId = ANY(note.mentions)`, { meId: user.id })
.orWhere(`:meId = ANY(note.visibleUserIds)`, { meId: user.id });
.where(`'{"${user.id}"}' <@ note.mentions`)
.orWhere(`'{"${user.id}"}' <@ note.visibleUserIds`);
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')

View File

@@ -35,7 +35,7 @@ export default async function(user: User, note: Note, quiet = false) {
});
//#region ローカルの投稿なら削除アクティビティを配送
if (Users.isLocalUser(user)) {
if (Users.isLocalUser(user) && !note.localOnly) {
let renote: Note | undefined;
// if deletd note is renote

View File

@@ -13,12 +13,13 @@ import { createNotification } from '../../create-notification';
import deleteReaction from './delete';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error';
import { NoteReaction } from '../../../models/entities/note-reaction';
import { IdentifiableError } from '@/misc/identifiable-error';
export default async (user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) => {
// TODO: cache
reaction = await toDbReaction(reaction, user.host);
let record: NoteReaction = {
const record: NoteReaction = {
id: genId(),
createdAt: new Date(),
noteId: note.id,
@@ -31,17 +32,18 @@ export default async (user: { id: User['id']; host: User['host']; }, note: Note,
await NoteReactions.insert(record);
} catch (e) {
if (isDuplicateKeyValueError(e)) {
record = await NoteReactions.findOneOrFail({
const exists = await NoteReactions.findOneOrFail({
noteId: note.id,
userId: user.id,
});
if (record.reaction !== reaction) {
if (exists.reaction !== reaction) {
// 別のリアクションがすでにされていたら置き換える
await deleteReaction(user, note);
await NoteReactions.insert(record);
} else {
// 同じリアクションがすでにされていたら何もしない
return;
// 同じリアクションがすでにされていたらエラー
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
}
} else {
throw e;