Compare commits

..

44 Commits

Author SHA1 Message Date
95ce8dce3d 8.28.1 2018-09-07 05:32:18 +09:00
0b5eec4ca8 Fix bug 2018-09-07 05:32:09 +09:00
6d9716f90e 8.28.0 2018-09-07 04:24:08 +09:00
aa31061d90 fix(package): update node-sass-json-importer to version 4.0.1 (#2645) 2018-09-07 04:23:26 +09:00
acc7797dff New Crowdin translations (#2615)
* New translations ja-JP.yml (Catalan)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Polish)

* New translations ja-JP.yml (Portuguese)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Dutch)

* New translations ja-JP.yml (Norwegian)

* New translations ja-JP.yml (Catalan)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Polish)

* New translations ja-JP.yml (Portuguese)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Dutch)

* New translations ja-JP.yml (Norwegian)

* New translations ja-JP.yml (English)
2018-09-07 04:22:16 +09:00
7959196dc7 Add sum function (#2653) 2018-09-07 04:21:04 +09:00
c6ff6939a5 Add capitalize function (#2651) 2018-09-07 03:22:55 +09:00
769960f29e Encode fetch URI if needed (#2649) 2018-09-07 02:26:31 +09:00
d92e9759f3 Refactor analog clock widget (#2648) 2018-09-07 01:20:23 +09:00
bf7e19b288 🎨 2018-09-07 01:18:47 +09:00
98954cd6d4 Trim image 2018-09-07 01:02:31 +09:00
538bb978ed Merge branch 'develop' of https://github.com/syuilo/misskey into develop 2018-09-07 00:52:21 +09:00
10232c5866 Fix bug & some refactor 2018-09-07 00:52:13 +09:00
5cd6a0db16 Fix typo: serive -> service (#2647) 2018-09-07 00:44:57 +09:00
ff0a05a2d6 Add unique function (#2644) 2018-09-07 00:10:03 +09:00
e34b264af2 Fix bug (#2643) 2018-09-07 00:03:44 +09:00
00d79487cd Add erase function (#2641) 2018-09-07 00:02:55 +09:00
3cace734c7 Add concat function (#2640) 2018-09-06 21:31:15 +09:00
f428372869 Refactor effects function (#2639) 2018-09-06 20:06:16 +09:00
5dd2feba9b fix(package): update @types/minio to version 7.0.0 (#2626) 2018-09-06 19:55:29 +09:00
a1b026239e fix(package): update @types/ws to version 6.0.1 (#2636) 2018-09-06 19:55:20 +09:00
40735ce76b Fix bug (#2638) 2018-09-06 19:28:52 +09:00
4a00c13b33 🎨 2018-09-06 16:03:00 +09:00
8e359d54bd if elimination (#2635) 2018-09-06 06:06:22 +09:00
2448bf4e4e 8.27.0 2018-09-06 04:57:21 +09:00
91e0fc8c62 Improve welcome page 2018-09-06 04:52:42 +09:00
b4f86feddb 🎨 2018-09-06 04:38:07 +09:00
ccf8e44acc Merge branch 'develop' of https://github.com/syuilo/misskey into develop 2018-09-06 04:28:42 +09:00
451acb77df Improve welcome page 2018-09-06 04:28:39 +09:00
e2c6227f47 Improve local timeline API 2018-09-06 04:28:22 +09:00
ebd1c877ad Show host in local user detail (#2634) 2018-09-06 03:21:11 +09:00
498094b3c7 Refactor reversi engine (#2633)
* Refactor reversi engine

* Add puttablePlaces
2018-09-06 03:02:52 +09:00
1cc183ecdb Resolve #2631 (#2632) 2018-09-06 02:44:01 +09:00
e8948452fd Resolve #2629 (#2630) 2018-09-06 02:28:04 +09:00
ade7e62836 Add README.md for prelude (#2628) 2018-09-06 02:24:39 +09:00
395cfa6108 Resolve #2625 (#2627) 2018-09-06 02:16:08 +09:00
b5ff2abdb9 互換性のためのコードを追加 & #2623 2018-09-05 23:55:51 +09:00
229e85b2c5 [WIP] Update welcome page 2018-09-05 21:51:31 +09:00
37058e3480 Fix parameter name 2018-09-05 19:35:57 +09:00
a1b82e9723 #2620 2018-09-05 19:32:46 +09:00
db943df0c8 🎨 2018-09-05 18:09:31 +09:00
ff8d300ea8 モバイル版のメニューにお知らせを表示するように 2018-09-05 17:43:31 +09:00
8b490b9b94 #2607 2018-09-05 16:48:59 +09:00
f83f8631ac fix(package): update systeminformation to version 3.45.1 (#2616) 2018-09-05 13:56:59 +09:00
91 changed files with 1113 additions and 701 deletions

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey Drive"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "スマート"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey Drive"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "スマート"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "Logging in..."
signup-button: "Sign up"
timeline: "Timeline"
announcements: "Announcements"
photos: "Recent uploaded"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey storage"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "Post design"
post-style-standard: "Standard"
post-style-smart: "Smart"
notification-position: "Notification style"
notification-position-bottom: "Bottom"
notification-position-top: "Top"
behavior: "Behavior"
fetch-on-scroll: "Endless loading on scroll"
disable-via-mobile: "Don't mark the post as 'from mobile'"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey Drive"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "スマート"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "Se connecter"
signup-button: "S'inscrire"
timeline: "Fil d'actualité"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Propulsé par <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Lecteur de Misskey"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "Style de la publication"
post-style-standard: "Standard"
post-style-smart: "Intelligent"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "Comportement"
fetch-on-scroll: "Chargement lors du défilement"
disable-via-mobile: "Ne pas mentionner que ma publication provient d'un 'périphérique mobile'"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey Drive"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "スマート"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -990,6 +990,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "サインイン中…"
signup-button: "サインアップ"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "ドライブ"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "べっぴんさん"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey Drive"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "スマート"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "Inloggen"
signup-button: "Registreren"
timeline: "Tijdlijn"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey Drive"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "Berichtontwerp"
post-style-standard: "Standaard"
post-style-smart: "Slim"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "Gedrag"
fetch-on-scroll: "Ophalen bij scrollen"
disable-via-mobile: "Zonder 'mobiele berichten'"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey Drive"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "スマート"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "Zaloguj się"
signup-button: "Zarejestruj się"
timeline: "Oś czasu"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Oparto o <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Dysk Misskey"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "Styl wpisów"
post-style-standard: "Standardowy"
post-style-smart: "Inteligentny"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "Zachowanie"
fetch-on-scroll: "Automatycznie ładuj po przeciągnięciu w dół"
disable-via-mobile: "Nie oznaczaj wpisów jako „wysłane z telefonu”"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "Timeline"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Desenvolvido por <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Drive Misskey"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "スマート"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey Drive"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "スマート"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -865,6 +865,8 @@ desktop/views/pages/welcome.vue:
signin-button: "やってる"
signup-button: "やる"
timeline: "タイムライン"
announcements: "お知らせ"
photos: "最近の画像"
powered-by-misskey: "Powered by <b>Misskey</b>."
desktop/views/pages/drive.vue:
title: "Misskey Drive"
@ -1161,6 +1163,9 @@ mobile/views/pages/settings.vue:
post-style: "投稿の表示スタイル"
post-style-standard: "標準"
post-style-smart: "スマート"
notification-position: "通知の表示"
notification-position-bottom: "下"
notification-position-top: "上"
behavior: "動作"
fetch-on-scroll: "スクロールで自動読み込み"
disable-via-mobile: "「モバイルからの投稿」フラグを付けない"

View File

@ -1,8 +1,8 @@
{
"name": "misskey",
"author": "syuilo <i@syuilo.com>",
"version": "8.26.0",
"clientVersion": "1.0.9358",
"version": "8.28.1",
"clientVersion": "1.0.9400",
"codename": "nighthike",
"main": "./built/index.js",
"private": true,
@ -55,7 +55,7 @@
"@types/koa-send": "4.1.1",
"@types/koa-views": "2.0.3",
"@types/koa__cors": "2.2.3",
"@types/minio": "6.0.2",
"@types/minio": "7.0.0",
"@types/mkdirp": "0.5.2",
"@types/mocha": "5.2.3",
"@types/mongodb": "3.1.4",
@ -80,7 +80,7 @@
"@types/webpack": "4.4.11",
"@types/webpack-stream": "3.2.10",
"@types/websocket": "0.0.40",
"@types/ws": "6.0.0",
"@types/ws": "6.0.1",
"animejs": "2.2.0",
"autosize": "4.0.2",
"autwh": "0.1.0",
@ -161,7 +161,7 @@
"nan": "2.11.0",
"nested-property": "0.0.7",
"node-sass": "4.9.3",
"node-sass-json-importer": "4.0.0",
"node-sass-json-importer": "4.0.1",
"nprogress": "0.2.0",
"object-assign-deep": "0.4.0",
"on-build-webpack": "0.1.0",
@ -194,7 +194,7 @@
"stylus": "0.54.5",
"stylus-loader": "3.0.2",
"summaly": "2.2.0",
"systeminformation": "3.45.0",
"systeminformation": "3.45.1",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
"tmp": "0.0.33",

View File

@ -140,7 +140,7 @@
// Random
localStorage.setItem('salt', Math.random().toString());
// Clear cache (serive worker)
// Clear cache (service worker)
try {
navigator.serviceWorker.controller.postMessage('clear');

View File

@ -9,7 +9,7 @@ export default async function(mios: MiOS, force = false, silent = false) {
localStorage.setItem('should-refresh', 'true');
localStorage.setItem('v', newer);
// Clear cache (serive worker)
// Clear cache (service worker)
try {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage('clear');

View File

@ -1,2 +0,0 @@
const gcd = (a, b) => !b ? a : gcd(b, a % b);
export default gcd;

View File

@ -1,53 +0,0 @@
export default function(qs: string) {
const q = {
text: ''
};
qs.split(' ').forEach(x => {
if (/^([a-z_]+?):(.+?)$/.test(x)) {
const [key, value] = x.split(':');
switch (key) {
case 'user':
q['includeUserUsernames'] = value.split(',');
break;
case 'exclude_user':
q['excludeUserUsernames'] = value.split(',');
break;
case 'follow':
q['following'] = value == 'null' ? null : value == 'true';
break;
case 'reply':
q['reply'] = value == 'null' ? null : value == 'true';
break;
case 'renote':
q['renote'] = value == 'null' ? null : value == 'true';
break;
case 'media':
q['media'] = value == 'null' ? null : value == 'true';
break;
case 'poll':
q['poll'] = value == 'null' ? null : value == 'true';
break;
case 'until':
case 'since':
// YYYY-MM-DD
if (/^[0-9]+\-[0-9]+\-[0-9]+$/) {
const [yyyy, mm, dd] = value.split('-');
q[`${key}_date`] = (new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))).getTime();
}
break;
default:
q[key] = value;
break;
}
} else {
q.text += x + ' ';
}
});
if (q.text) {
q.text = q.text.trim();
}
return q;
}

View File

@ -1,6 +1,7 @@
import { EventEmitter } from 'eventemitter3';
import * as uuid from 'uuid';
import Connection from './stream';
import { erase } from '../../../../../prelude/array';
/**
* ストリーム接続を管理するクラス
@ -89,7 +90,7 @@ export default abstract class StreamManager<T extends Connection> extends EventE
* @param userId use で発行したユーザーID
*/
public dispose(userId) {
this.users = this.users.filter(id => id != userId);
this.users = erase(userId, this.users);
this._connection.user = `Managed (${ this.users.length })`;

View File

@ -1,14 +1,20 @@
<template>
<span class="mk-acct">
<span class="name">@{{ user.username }}</span>
<span class="host" v-if="user.host">@{{ user.host }}</span>
<span class="host" v-if="user.host || detail">@{{ user.host || host }}</span>
</span>
</template>
<script lang="ts">
import Vue from 'vue';
import { host } from '../../../config';
export default Vue.extend({
props: ['user']
props: ['user', 'detail'],
data() {
return {
host
};
}
});
</script>

View File

@ -20,6 +20,7 @@
<script lang="ts">
import Vue from 'vue';
import { erase } from '../../../../../prelude/array';
export default Vue.extend({
data() {
return {
@ -53,7 +54,7 @@ export default Vue.extend({
get() {
return {
choices: this.choices.filter(choice => choice != '')
choices: erase('', this.choices)
}
},

View File

@ -21,6 +21,7 @@
<script lang="ts">
import Vue from 'vue';
import { sum } from '../../../../../prelude/array';
export default Vue.extend({
props: ['note'],
data() {
@ -33,7 +34,7 @@ export default Vue.extend({
return this.note.poll;
},
total(): number {
return this.poll.choices.reduce((a, b) => a + b.votes, 0);
return sum(this.poll.choices.map(x => x.votes));
},
isVoted(): boolean {
return this.poll.choices.some(c => c.isVoted);

View File

@ -63,7 +63,7 @@ export default Vue.extend({
local: true,
reply: false,
renote: false,
media: false,
file: false,
poll: false
}).then(notes => {
this.notes = notes;

View File

@ -1,8 +1,8 @@
<template>
<div class="mkw-analog-clock">
<mk-widget-container :naked="!(props.design % 2)" :show-header="false">
<mk-widget-container :naked="props.style % 2 === 0" :show-header="false">
<div class="mkw-analog-clock--body">
<mk-analog-clock :dark="$store.state.device.darkmode" :smooth="!(props.design && ~props.design)"/>
<mk-analog-clock :dark="$store.state.device.darkmode" :smooth="props.style < 2"/>
</div>
</mk-widget-container>
</div>
@ -13,13 +13,12 @@ import define from '../../../common/define-widget';
export default define({
name: 'analog-clock',
props: () => ({
design: -1
style: 0
})
}).extend({
methods: {
func() {
if (++this.props.design > 2)
this.props.design = -1;
this.props.style = (this.props.style + 1) % 4;
this.save();
}
}

View File

@ -1,6 +1,6 @@
<template>
<div class="anltbovirfeutcigvwgmgxipejaeozxi"
:data-found="broadcasts.length != 0"
:data-found="announcements && announcements.length != 0"
:data-melt="props.design == 1"
:data-mobile="platform == 'mobile'"
>
@ -14,12 +14,12 @@
</svg>
</div>
<p class="fetching" v-if="fetching">%i18n:@fetching%<mk-ellipsis/></p>
<h1 v-if="!fetching">{{ broadcasts.length == 0 ? '%i18n:@no-broadcasts%' : broadcasts[i].title }}</h1>
<h1 v-if="!fetching">{{ announcements.length == 0 ? '%i18n:@no-broadcasts%' : announcements[i].title }}</h1>
<p v-if="!fetching">
<span v-if="broadcasts.length != 0" v-html="broadcasts[i].text"></span>
<template v-if="broadcasts.length == 0">%i18n:@have-a-nice-day%</template>
<span v-if="announcements.length != 0" v-html="announcements[i].text"></span>
<template v-if="announcements.length == 0">%i18n:@have-a-nice-day%</template>
</p>
<a v-if="broadcasts.length > 1" @click="next">%i18n:@next% &gt;&gt;</a>
<a v-if="announcements.length > 1" @click="next">%i18n:@next% &gt;&gt;</a>
</div>
</template>
@ -36,18 +36,18 @@ export default define({
return {
i: 0,
fetching: true,
broadcasts: []
announcements: []
};
},
mounted() {
(this as any).os.getMeta().then(meta => {
this.broadcasts = meta.broadcasts;
this.announcements = meta.broadcasts;
this.fetching = false;
});
},
methods: {
next() {
if (this.i == this.broadcasts.length - 1) {
if (this.i == this.announcements.length - 1) {
this.i = 0;
} else {
this.i++;

View File

@ -42,8 +42,8 @@
<span v-if="p.deletedAt" style="opacity: 0.5">%i18n:@deleted%</span>
<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
</div>
<div class="media" v-if="p.media.length > 0">
<mk-media-list :media-list="p.media" :raw="true"/>
<div class="files" v-if="p.files.length > 0">
<mk-media-list :media-list="p.files" :raw="true"/>
</div>
<mk-poll v-if="p.poll" :note="p"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/>
@ -86,6 +86,7 @@ import MkRenoteFormWindow from './renote-form-window.vue';
import MkNoteMenu from '../../../common/views/components/note-menu.vue';
import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
import XSub from './notes.note.sub.vue';
import { sum } from '../../../../../prelude/array';
export default Vue.extend({
components: {
@ -114,7 +115,7 @@ export default Vue.extend({
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.mediaIds.length == 0 &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
p(): any {
@ -122,9 +123,7 @@ export default Vue.extend({
},
reactionsCount(): number {
return this.p.reactionCounts
? Object.keys(this.p.reactionCounts)
.map(key => this.p.reactionCounts[key])
.reduce((a, b) => a + b)
? sum(Object.values(this.p.reactionCounts))
: 0;
},
title(): string {

View File

@ -28,8 +28,8 @@
<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/>
<a class="rp" v-if="p.renote">RP:</a>
</div>
<div class="media" v-if="p.media.length > 0">
<mk-media-list :media-list="p.media"/>
<div class="files" v-if="p.files.length > 0">
<mk-media-list :media-list="p.files"/>
</div>
<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a>
@ -78,6 +78,7 @@ import MkRenoteFormWindow from './renote-form-window.vue';
import MkNoteMenu from '../../../common/views/components/note-menu.vue';
import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
import XSub from './notes.note.sub.vue';
import { sum } from '../../../../../prelude/array';
function focus(el, fn) {
const target = fn(el);
@ -110,7 +111,7 @@ export default Vue.extend({
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.mediaIds.length == 0 &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
@ -120,9 +121,7 @@ export default Vue.extend({
reactionsCount(): number {
return this.p.reactionCounts
? Object.keys(this.p.reactionCounts)
.map(key => this.p.reactionCounts[key])
.reduce((a, b) => a + b)
? sum(Object.values(this.p.reactionCounts))
: 0;
},

View File

@ -122,7 +122,7 @@ export default Vue.extend({
prepend(note, silent = false) {
//#region 弾く
const isMyNote = note.userId == this.$store.state.i.id;
const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null;
if (this.$store.state.settings.showMyRenotes === false) {
if (isMyNote && isPureRenote) {

View File

@ -4,7 +4,7 @@
<span class="icon" v-if="geo">%fa:map-marker-alt%</span>
<span v-if="!reply">%i18n:@note%</span>
<span v-if="reply">%i18n:@reply%</span>
<span class="count" v-if="media.length != 0">{{ '%i18n:@attaches%'.replace('{}', media.length) }}</span>
<span class="count" v-if="files.length != 0">{{ '%i18n:@attaches%'.replace('{}', files.length) }}</span>
<span class="count" v-if="uploadings.length != 0">{{ '%i18n:@uploading-media%'.replace('{}', uploadings.length) }}<mk-ellipsis/></span>
</span>
@ -14,7 +14,7 @@
:reply="reply"
@posted="onPosted"
@change-uploadings="onChangeUploadings"
@change-attached-media="onChangeMedia"
@change-attached-files="onChangeFiles"
@geo-attached="onGeoAttached"
@geo-dettached="onGeoDettached"/>
</div>
@ -29,7 +29,7 @@ export default Vue.extend({
data() {
return {
uploadings: [],
media: [],
files: [],
geo: null
};
},
@ -42,8 +42,8 @@ export default Vue.extend({
onChangeUploadings(files) {
this.uploadings = files;
},
onChangeMedia(media) {
this.media = media;
onChangeFiles(files) {
this.files = files;
},
onGeoAttached(geo) {
this.geo = geo;

View File

@ -20,7 +20,7 @@
@keydown="onKeydown" @paste="onPaste" :placeholder="placeholder"
v-autocomplete="'text'"
></textarea>
<div class="medias" :class="{ with: poll }" v-show="files.length != 0">
<div class="files" :class="{ with: poll }" v-show="files.length != 0">
<x-draggable :list="files" :options="{ animation: 150 }">
<div v-for="file in files" :key="file.id">
<div class="img" :style="{ backgroundImage: `url(${file.thumbnailUrl})` }" :title="file.name"></div>
@ -62,6 +62,7 @@ import getFace from '../../../common/scripts/get-face';
import MkVisibilityChooser from '../../../common/views/components/visibility-chooser.vue';
import parse from '../../../../../mfm/parse';
import { host } from '../../../config';
import { erase } from '../../../../../prelude/array';
export default Vue.extend({
components: {
@ -188,7 +189,7 @@ export default Vue.extend({
(this.$refs.poll as any).set(draft.data.poll);
});
}
this.$emit('change-attached-media', this.files);
this.$emit('change-attached-files', this.files);
}
}
@ -225,12 +226,12 @@ export default Vue.extend({
attachMedia(driveFile) {
this.files.push(driveFile);
this.$emit('change-attached-media', this.files);
this.$emit('change-attached-files', this.files);
},
detachMedia(id) {
this.files = this.files.filter(x => x.id != id);
this.$emit('change-attached-media', this.files);
this.$emit('change-attached-files', this.files);
},
onChangeFile() {
@ -249,7 +250,7 @@ export default Vue.extend({
this.text = '';
this.files = [];
this.poll = false;
this.$emit('change-attached-media', this.files);
this.$emit('change-attached-files', this.files);
},
onKeydown(e) {
@ -297,7 +298,7 @@ export default Vue.extend({
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.files.push(file);
this.$emit('change-attached-media', this.files);
this.$emit('change-attached-files', this.files);
e.preventDefault();
}
//#endregion
@ -346,7 +347,7 @@ export default Vue.extend({
},
removeVisibleUser(user) {
this.visibleUsers = this.visibleUsers.filter(u => u != user);
this.visibleUsers = erase(user, this.visibleUsers);
},
post() {
@ -354,7 +355,7 @@ export default Vue.extend({
(this as any).api('notes/create', {
text: this.text == '' ? undefined : this.text,
mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
replyId: this.reply ? this.reply.id : undefined,
renoteId: this.renote ? this.renote.id : undefined,
poll: this.poll ? (this.$refs.poll as any).get() : undefined,
@ -514,7 +515,7 @@ root(isDark)
margin-right 8px
white-space nowrap
> .medias
> .files
margin 0
padding 0
background isDark ? #181b23 : lighten($theme-color, 98%)

View File

@ -7,9 +7,9 @@
<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/>
<a class="rp" v-if="note.renoteId" :href="`/notes/${note.renoteId}`">RP: ...</a>
</div>
<details v-if="note.media.length > 0">
<summary>({{ '%i18n:@media-count%'.replace('{}', note.media.length) }})</summary>
<mk-media-list :media-list="note.media"/>
<details v-if="note.files.length > 0">
<summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary>
<mk-media-list :media-list="note.files"/>
</details>
<details v-if="note.poll">
<summary>%i18n:@poll%</summary>

View File

@ -28,6 +28,7 @@
import Vue from 'vue';
import Menu from '../../../../common/views/components/menu.vue';
import contextmenu from '../../../api/contextmenu';
import { countIf } from '../../../../../../prelude/array';
export default Vue.extend({
props: {
@ -117,7 +118,7 @@ export default Vue.extend({
toggleActive() {
if (!this.isStacked) return;
const vms = this.$store.state.settings.deck.layout.find(ids => ids.indexOf(this.column.id) != -1).map(id => this.getColumnVm(id));
if (this.active && vms.filter(vm => vm.$el.classList.contains('active')).length == 1) return;
if (this.active && countIf(vm => vm.$el.classList.contains('active'), vms) == 1) return;
this.active = !this.active;
},

View File

@ -68,7 +68,7 @@ export default Vue.extend({
(this as any).api('notes/user-list-timeline', {
listId: this.list.id,
limit: fetchLimit + 1,
mediaOnly: this.mediaOnly,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
@ -90,7 +90,7 @@ export default Vue.extend({
listId: this.list.id,
limit: fetchLimit + 1,
untilId: (this.$refs.timeline as any).tail().id,
mediaOnly: this.mediaOnly,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
@ -109,7 +109,7 @@ export default Vue.extend({
return promise;
},
onNote(note) {
if (this.mediaOnly && note.media.length == 0) return;
if (this.mediaOnly && note.files.length == 0) return;
// Prepend a note
(this.$refs.timeline as any).prepend(note);

View File

@ -28,8 +28,8 @@
<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
<a class="rp" v-if="p.renote != null">RP:</a>
</div>
<div class="media" v-if="p.media.length > 0">
<mk-media-list :media-list="p.media"/>
<div class="files" v-if="p.files.length > 0">
<mk-media-list :media-list="p.files"/>
</div>
<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
<a class="location" v-if="p.geo" :href="`https://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% %i18n:@location%</a>
@ -54,11 +54,11 @@
</article>
</div>
<div v-else class="srwrkujossgfuhrbnvqkybtzxpblgchi">
<div v-if="note.media.length > 0">
<mk-media-list :media-list="note.media"/>
<div v-if="note.files.length > 0">
<mk-media-list :media-list="note.files"/>
</div>
<div v-if="note.renote && note.renote.media.length > 0">
<mk-media-list :media-list="note.renote.media"/>
<div v-if="note.renote && note.renote.files.length > 0">
<mk-media-list :media-list="note.renote.files"/>
</div>
</div>
</template>
@ -100,7 +100,7 @@ export default Vue.extend({
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.mediaIds.length == 0 &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
@ -371,7 +371,7 @@ root(isDark)
.mk-url-preview
margin-top 8px
> .media
> .files
> img
display block
max-width 100%

View File

@ -127,7 +127,7 @@ export default Vue.extend({
prepend(note, silent = false) {
//#region 弾く
const isMyNote = note.userId == this.$store.state.i.id;
const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null;
if (this.$store.state.settings.showMyRenotes === false) {
if (isMyNote && isPureRenote) {

View File

@ -96,7 +96,7 @@ export default Vue.extend({
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
(this as any).api(this.endpoint, {
limit: fetchLimit + 1,
mediaOnly: this.mediaOnly,
withFiles: this.mediaOnly,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
@ -117,7 +117,7 @@ export default Vue.extend({
const promise = (this as any).api(this.endpoint, {
limit: fetchLimit + 1,
mediaOnly: this.mediaOnly,
withFiles: this.mediaOnly,
untilId: (this.$refs.timeline as any).tail().id,
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
@ -138,7 +138,7 @@ export default Vue.extend({
},
onNote(note) {
if (this.mediaOnly && note.media.length == 0) return;
if (this.mediaOnly && note.files.length == 0) return;
// Prepend a note
(this.$refs.timeline as any).prepend(note);

View File

@ -6,7 +6,7 @@
<div class="title">
<p class="name">{{ user | userName }}</p>
<div>
<span class="username"><mk-acct :user="user"/></span>
<span class="username"><mk-acct :user="user" :detail="true" /></span>
<span v-if="user.isBot" title="%i18n:@is-bot%">%fa:robot%</span>
<span class="location" v-if="user.host === null && user.profile.location">%fa:map-marker% {{ user.profile.location }}</span>
<span class="birthday" v-if="user.host === null && user.profile.birthday">%fa:birthday-cake% {{ user.profile.birthday.replace('-', '').replace('-', '') + '' }} ({{ age }})</span>

View File

@ -24,12 +24,12 @@ export default Vue.extend({
mounted() {
(this as any).api('users/notes', {
userId: this.user.id,
withMedia: true,
withFiles: true,
limit: 9
}).then(notes => {
notes.forEach(note => {
note.media.forEach(media => {
if (this.images.length < 9) this.images.push(media);
note.files.forEach(file => {
if (this.images.length < 9) this.images.push(file);
});
});
this.fetching = false;

View File

@ -66,7 +66,7 @@ export default Vue.extend({
limit: fetchLimit + 1,
untilDate: this.date ? this.date.getTime() : undefined,
includeReplies: this.mode == 'with-replies',
withMedia: this.mode == 'with-media'
withFiles: this.mode == 'with-media'
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
@ -86,7 +86,7 @@ export default Vue.extend({
userId: this.user.id,
limit: fetchLimit + 1,
includeReplies: this.mode == 'with-replies',
withMedia: this.mode == 'with-media',
withFiles: this.mode == 'with-media',
untilId: (this.$refs.timeline as any).tail().id
});

View File

@ -9,41 +9,66 @@
<div class="body">
<div class="main block">
<h1 v-if="name != 'Misskey'">{{ name }}</h1>
<h1 v-else><img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name"></h1>
<div>
<h1 v-if="name != 'Misskey'">{{ name }}</h1>
<h1 v-else><img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name"></h1>
<div class="info">
<span><b>{{ host }}</b> - <span v-html="'%i18n:@powered-by-misskey%'"></span></span>
<span class="stats" v-if="stats">
<span>%fa:user% {{ stats.originalUsersCount | number }}</span>
<span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span>
</span>
<div class="info">
<span><b>{{ host }}</b> - <span v-html="'%i18n:@powered-by-misskey%'"></span></span>
<span class="stats" v-if="stats">
<span>%fa:user% {{ stats.originalUsersCount | number }}</span>
<span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span>
</span>
</div>
<p class="desc" v-html="description || '%i18n:common.about%'"></p>
<p class="sign">
<span class="signup" @click="signup">%i18n:@signup%</span>
<span class="divider">|</span>
<span class="signin" @click="signin">%i18n:@signin%</span>
</p>
<img src="/assets/pointer.png" alt="" class="char">
</div>
<p class="desc" v-html="description || '%i18n:common.about%'"></p>
<p class="sign">
<span class="signup" @click="signup">%i18n:@signup%</span>
<span class="divider">|</span>
<span class="signin" @click="signin">%i18n:@signin%</span>
</p>
</div>
<div class="broadcasts block">
<div v-for="broadcast in broadcasts">
<h1 v-html="broadcast.title"></h1>
<div v-html="broadcast.text"></div>
<div class="announcements block">
<header>%fa:broadcast-tower% %i18n:@announcements%</header>
<div v-if="announcements && announcements.length > 0">
<div v-for="announcement in announcements">
<h1 v-html="announcement.title"></h1>
<div v-html="announcement.text"></div>
</div>
</div>
</div>
<div class="photos block">
<header>%fa:images% %i18n:@photos%</header>
<div>
<div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div>
</div>
</div>
<div class="nav block">
<mk-nav class="nav"/>
<div>
<mk-nav class="nav"/>
</div>
</div>
<div class="side">
<mk-trends class="trends block"/>
<div class="trends block">
<div>
<mk-trends/>
</div>
</div>
<mk-welcome-timeline class="tl block" :max="20"/>
<div class="tl block">
<header>%fa:comment-alt R% %i18n:@timeline%</header>
<div>
<mk-welcome-timeline class="tl" :max="20"/>
</div>
</div>
</div>
</div>
@ -62,6 +87,7 @@
<script lang="ts">
import Vue from 'vue';
import { host, copyright } from '../../../config';
import { concat } from '../../../../../prelude/array';
export default Vue.extend({
data() {
@ -71,28 +97,46 @@ export default Vue.extend({
host,
name: 'Misskey',
description: '',
broadcasts: []
announcements: [],
photos: []
};
},
created() {
(this as any).os.getMeta().then(meta => {
this.name = meta.name;
this.description = meta.description;
this.broadcasts = meta.broadcasts;
this.announcements = meta.broadcasts;
});
(this as any).api('stats').then(stats => {
this.stats = stats;
});
const image = [
'image/jpeg',
'image/png',
'image/gif'
];
(this as any).api('notes/local-timeline', {
fileType: image,
limit: 6
}).then((notes: any[]) => {
const files = concat(notes.map((n: any): any[] => n.files));
this.photos = files.filter(f => image.includes(f.type)).slice(0, 6);
});
},
methods: {
signup() {
this.$modal.show('signup');
},
signin() {
this.$modal.show('signin');
},
dark() {
this.$store.commit('device/set', {
key: 'darkmode',
@ -166,99 +210,136 @@ root(isDark)
> .body
display grid
grid-template-rows 0.5fr 0.5fr 64px
grid-template-columns 1fr 350px
grid-template-rows 1fr 1fr 64px
grid-template-columns 1fr 1fr 350px
gap 16px
width 100%
max-width 1200px
height 100vh
min-height 800px
min-height 950px
margin 0 auto
padding 64px
.block
color isDark ? #fff : #444
background isDark ? #313543 : #fff
background isDark ? #282C37 : #fff
box-shadow 0 3px 8px rgba(0, 0, 0, 0.2)
//border-radius 8px
overflow auto
> .main
grid-row 1
grid-column 1
padding 32px
border-top solid 5px $theme-color
> header
z-index 1
padding 0 16px
line-height 48px
background isDark ? #313543 : #fff
> h1
margin 0
if !isDark
box-shadow 0 1px 0px rgba(0, 0, 0, 0.1)
> img
margin -8px 0 0 -16px
max-width 280px
> .info
margin 0 auto 16px auto
width $width
font-size 14px
> .stats
margin-left 16px
padding-left 16px
border-left solid 1px isDark ? #fff : #444
> *
margin-right 16px
> .sign
font-size 120%
> .divider
margin 0 16px
> .signin
> .signup
cursor pointer
&:hover
color $theme-color
> .hashtags
margin 16px auto
width $width
font-size 14px
background rgba(#000, 0.3)
border-radius 8px
> *
display inline-block
margin 14px
> .broadcasts
grid-row 2
grid-column 1
padding 32px
& + div
max-height calc(100% - 48px)
> div
padding 0 0 16px 0
margin 0 0 16px 0
border-bottom 1px solid isDark ? rgba(#000, 0.2) : rgba(#000, 0.05)
overflow auto
> .main
grid-row 1
grid-column 1 / 3
border-top solid 5px $theme-color
> div
padding 32px
min-height 100%
> h1
margin 0
font-size 1.5em
> img
margin -8px 0 0 -16px
max-width 280px
> .info
margin 0 auto 16px auto
width $width
font-size 14px
> .stats
margin-left 16px
padding-left 16px
border-left solid 1px isDark ? #fff : #444
> *
margin-right 16px
> .sign
font-size 120%
> .divider
margin 0 16px
> .signin
> .signup
cursor pointer
&:hover
color $theme-color
> .char
display block
position absolute
right 0
bottom 0
width 180px
opacity 0.3
> *:not(.char)
z-index 1
> .announcements
grid-row 2
grid-column 1
> div
padding 32px
> div
padding 0 0 16px 0
margin 0 0 16px 0
border-bottom 1px solid isDark ? rgba(#000, 0.2) : rgba(#000, 0.05)
> h1
margin 0
font-size 1.25em
> .photos
grid-row 2
grid-column 2
> div
display grid
grid-template-rows 1fr 1fr 1fr
grid-template-columns 1fr 1fr
gap 8px
height 100%
padding 16px
> div
//border-radius 4px
background-position center center
background-size cover
> .nav
display flex
justify-content center
align-items center
grid-row 3
grid-column 1
grid-column 1 / 3
font-size 14px
> .side
display grid
grid-row 1 / 4
grid-column 2
grid-column 3
grid-template-rows 1fr 350px
grid-template-columns 1fr
gap 16px
@ -266,8 +347,6 @@ root(isDark)
> .tl
grid-row 1
grid-column 1
text-align left
max-height 100%
overflow auto
> .trends

View File

@ -49,7 +49,7 @@ export default define({
offset: this.offset,
renote: false,
reply: false,
media: false,
file: false,
poll: false
}).then(notes => {
const note = notes ? notes[0] : null;

View File

@ -17,6 +17,7 @@ import Err from './common/views/components/connect-failed.vue';
import { LocalTimelineStreamManager } from './common/scripts/streaming/local-timeline';
import { HybridTimelineStreamManager } from './common/scripts/streaming/hybrid-timeline';
import { GlobalTimelineStreamManager } from './common/scripts/streaming/global-timeline';
import { erase } from '../../prelude/array';
//#region api requests
let spinner = null;
@ -537,7 +538,7 @@ export default class MiOS extends EventEmitter {
}
public unregisterStreamConnection(connection: Connection) {
this.connections = this.connections.filter(c => c != connection);
this.connections = erase(connection, this.connections);
}
}

View File

@ -67,7 +67,7 @@
import Vue from 'vue';
import * as EXIF from 'exif-js';
import * as hljs from 'highlight.js';
import gcd from '../../../common/scripts/gcd';
import { gcd } from '../../../../../prelude/math';
export default Vue.extend({
props: ['file'],

View File

@ -40,8 +40,8 @@
<span v-if="p.deletedAt" style="opacity: 0.5">(%i18n:@deleted%)</span>
<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i"/>
</div>
<div class="media" v-if="p.media.length > 0">
<mk-media-list :media-list="p.media" :raw="true"/>
<div class="files" v-if="p.files.length > 0">
<mk-media-list :media-list="p.files" :raw="true"/>
</div>
<mk-poll v-if="p.poll" :note="p"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url" :detail="true"/>
@ -85,6 +85,7 @@ import parse from '../../../../../mfm/parse';
import MkNoteMenu from '../../../common/views/components/note-menu.vue';
import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
import XSub from './note.sub.vue';
import { sum } from '../../../../../prelude/array';
export default Vue.extend({
components: {
@ -113,7 +114,7 @@ export default Vue.extend({
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.mediaIds.length == 0 &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
@ -123,9 +124,7 @@ export default Vue.extend({
reactionsCount(): number {
return this.p.reactionCounts
? Object.keys(this.p.reactionCounts)
.map(key => this.p.reactionCounts[key])
.reduce((a, b) => a + b)
? sum(Object.values(this.p.reactionCounts))
: 0;
},
@ -369,7 +368,7 @@ root(isDark)
> .mk-url-preview
margin-top 8px
> .media
> .files
> img
display block
max-width 100%

View File

@ -28,8 +28,8 @@
<misskey-flavored-markdown v-if="p.text" :text="p.text" :i="$store.state.i" :class="$style.text"/>
<a class="rp" v-if="p.renote != null">RP:</a>
</div>
<div class="media" v-if="p.media.length > 0">
<mk-media-list :media-list="p.media"/>
<div class="files" v-if="p.files.length > 0">
<mk-media-list :media-list="p.files"/>
</div>
<mk-poll v-if="p.poll" :note="p" ref="pollViewer"/>
<mk-url-preview v-for="url in urls" :url="url" :key="url"/>
@ -70,6 +70,7 @@ import parse from '../../../../../mfm/parse';
import MkNoteMenu from '../../../common/views/components/note-menu.vue';
import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
import XSub from './note.sub.vue';
import { sum } from '../../../../../prelude/array';
export default Vue.extend({
components: {
@ -90,7 +91,7 @@ export default Vue.extend({
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.mediaIds.length == 0 &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
@ -100,9 +101,7 @@ export default Vue.extend({
reactionsCount(): number {
return this.p.reactionCounts
? Object.keys(this.p.reactionCounts)
.map(key => this.p.reactionCounts[key])
.reduce((a, b) => a + b)
? sum(Object.values(this.p.reactionCounts))
: 0;
},
@ -414,7 +413,7 @@ root(isDark)
.mk-url-preview
margin-top 8px
> .media
> .files
> img
display block
max-width 100%

View File

@ -125,7 +125,7 @@ export default Vue.extend({
prepend(note, silent = false) {
//#region 弾く
const isMyNote = note.userId == this.$store.state.i.id;
const isPureRenote = note.renoteId != null && note.text == null && note.mediaIds.length == 0 && note.poll == null;
const isPureRenote = note.renoteId != null && note.text == null && note.fileIds.length == 0 && note.poll == null;
if (this.$store.state.settings.showMyRenotes === false) {
if (isMyNote && isPureRenote) {

View File

@ -59,6 +59,7 @@ import MkVisibilityChooser from '../../../common/views/components/visibility-cho
import getFace from '../../../common/scripts/get-face';
import parse from '../../../../../mfm/parse';
import { host } from '../../../config';
import { erase } from '../../../../../prelude/array';
export default Vue.extend({
components: {
@ -200,12 +201,12 @@ export default Vue.extend({
attachMedia(driveFile) {
this.files.push(driveFile);
this.$emit('change-attached-media', this.files);
this.$emit('change-attached-files', this.files);
},
detachMedia(file) {
this.files = this.files.filter(x => x.id != file.id);
this.$emit('change-attached-media', this.files);
this.$emit('change-attached-files', this.files);
},
onChangeFile() {
@ -262,14 +263,14 @@ export default Vue.extend({
},
removeVisibleUser(user) {
this.visibleUsers = this.visibleUsers.filter(u => u != user);
this.visibleUsers = erase(user, this.visibleUsers);
},
clear() {
this.text = '';
this.files = [];
this.poll = false;
this.$emit('change-attached-media');
this.$emit('change-attached-files');
},
post() {
@ -277,7 +278,7 @@ export default Vue.extend({
const viaMobile = this.$store.state.settings.disableViaMobile !== true;
(this as any).api('notes/create', {
text: this.text == '' ? undefined : this.text,
mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
replyId: this.reply ? this.reply.id : undefined,
renoteId: this.renote ? this.renote.id : undefined,
poll: this.poll ? (this.$refs.poll as any).get() : undefined,

View File

@ -7,9 +7,9 @@
<misskey-flavored-markdown v-if="note.text" :text="note.text" :i="$store.state.i"/>
<a class="rp" v-if="note.renoteId">RP: ...</a>
</div>
<details v-if="note.media.length > 0">
<summary>({{ '%i18n:@media-count%'.replace('{}', note.media.length) }})</summary>
<mk-media-list :media-list="note.media"/>
<details v-if="note.files.length > 0">
<summary>({{ '%i18n:@media-count%'.replace('{}', note.files.length) }})</summary>
<mk-media-list :media-list="note.files"/>
</details>
<details v-if="note.poll">
<summary>%i18n:@poll%</summary>

View File

@ -34,6 +34,12 @@
<li @click="dark"><p><template v-if="$store.state.device.darkmode">%fa:moon%</template><template v-else>%fa:R moon%</template><span>%i18n:@darkmode%</span></p></li>
</ul>
</div>
<div class="announcements" v-if="announcements && announcements.length > 0">
<article v-for="announcement in announcements">
<span v-html="announcement.title" class="title"></span>
<div v-html="announcement.text"></div>
</article>
</div>
<a :href="aboutUrl"><p class="about">%i18n:@about%</p></a>
</div>
</transition>
@ -46,23 +52,32 @@ import { lang } from '../../../config';
export default Vue.extend({
props: ['isOpen'],
data() {
return {
hasGameInvitation: false,
connection: null,
connectionId: null,
aboutUrl: `/docs/${lang}/about`
aboutUrl: `/docs/${lang}/about`,
announcements: []
};
},
computed: {
hasUnreadNotification(): boolean {
return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification;
},
hasUnreadMessagingMessage(): boolean {
return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadMessagingMessage;
}
},
mounted() {
(this as any).os.getMeta().then(meta => {
this.announcements = meta.broadcasts;
});
if (this.$store.getters.isSignedIn) {
this.connection = (this as any).os.stream.getConnection();
this.connectionId = (this as any).os.stream.use();
@ -71,6 +86,7 @@ export default Vue.extend({
this.connection.on('reversi_no_invites', this.onReversiNoInvites);
}
},
beforeDestroy() {
if (this.$store.getters.isSignedIn) {
this.connection.off('reversi_invited', this.onReversiInvited);
@ -78,18 +94,22 @@ export default Vue.extend({
(this as any).os.stream.dispose(this.connectionId);
}
},
methods: {
search() {
const query = window.prompt('%i18n:@search%');
if (query == null || query == '') return;
this.$router.push(`/search?q=${encodeURIComponent(query)}`);
},
onReversiInvited() {
this.hasGameInvitation = true;
},
onReversiNoInvites() {
this.hasGameInvitation = false;
},
dark() {
this.$store.commit('device/set', {
key: 'darkmode',
@ -204,6 +224,17 @@ root(isDark)
color $color
opacity 0.5
.announcements
> article
background isDark ? rgba(30, 129, 216, 0.2) : rgba(155, 196, 232, 0.2)
color isDark ? #fff : #3f4967
padding 16px
margin 8px 0
font-size 12px
> .title
font-weight bold
.about
margin 0 0 8px 0
padding 1em 0

View File

@ -41,7 +41,7 @@ export default Vue.extend({
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
(this as any).api('users/notes', {
userId: this.user.id,
withMedia: this.withMedia,
withFiles: this.withMedia,
limit: fetchLimit + 1
}).then(notes => {
if (notes.length == fetchLimit + 1) {
@ -62,7 +62,7 @@ export default Vue.extend({
const promise = (this as any).api('users/notes', {
userId: this.user.id,
withMedia: this.withMedia,
withFiles: this.withMedia,
limit: fetchLimit + 1,
untilId: (this.$refs.timeline as any).tail().id
});

View File

@ -16,7 +16,7 @@
</div>
<div class="title">
<h1>{{ user | userName }}</h1>
<span class="username"><mk-acct :user="user"/></span>
<span class="username"><mk-acct :user="user" :detail="true" /></span>
<span class="followed" v-if="user.isFollowed">%i18n:@follows-you%</span>
</div>
<div class="description">

View File

@ -26,7 +26,7 @@ export default Vue.extend({
mounted() {
(this as any).api('users/notes', {
userId: this.user.id,
withMedia: true,
withFiles: true,
limit: 6
}).then(notes => {
notes.forEach(note => {

View File

@ -1,5 +1,5 @@
<template>
<div class="welcome">
<div class="wgwfgvvimdjvhjfwxropcwksnzftjqes">
<div>
<img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name">
<p class="host">{{ host }}</p>
@ -17,10 +17,19 @@
<div class="hashtags">
<router-link v-for="tag in tags" :key="tag" :to="`/tags/${ tag }`" :title="tag">#{{ tag }}</router-link>
</div>
<div class="photos">
<div v-for="photo in photos" :style="`background-image: url(${photo.thumbnailUrl})`"></div>
</div>
<div class="stats" v-if="stats">
<span>%fa:user% {{ stats.originalUsersCount | number }}</span>
<span>%fa:pencil-alt% {{ stats.originalNotesCount | number }}</span>
</div>
<div class="announcements" v-if="announcements && announcements.length > 0">
<article v-for="announcement in announcements">
<span class="title" v-html="announcement.title"></span>
<div v-html="announcement.text"></div>
</article>
</div>
<footer>
<small>{{ copyright }}</small>
</footer>
@ -31,6 +40,7 @@
<script lang="ts">
import Vue from 'vue';
import { apiUrl, copyright, host } from '../../../config';
import { concat } from '../../../../../prelude/array';
export default Vue.extend({
data() {
@ -41,13 +51,16 @@ export default Vue.extend({
host,
name: 'Misskey',
description: '',
tags: []
tags: [],
photos: [],
announcements: []
};
},
created() {
(this as any).os.getMeta().then(meta => {
this.name = meta.name;
this.description = meta.description;
this.announcements = meta.broadcasts;
});
(this as any).api('stats').then(stats => {
@ -57,12 +70,26 @@ export default Vue.extend({
(this as any).api('hashtags/trend').then(stats => {
this.tags = stats.map(x => x.tag);
});
const image = [
'image/jpeg',
'image/png',
'image/gif'
];
(this as any).api('notes/local-timeline', {
fileType: image,
limit: 6
}).then((notes: any[]) => {
const files = concat(notes.map((n: any): any[] => n.files));
this.photos = files.filter(f => image.includes(f.type)).slice(0, 6);
});
}
});
</script>
<style lang="stylus" scoped>
.welcome
root(isDark)
text-align center
//background #fff
@ -145,6 +172,19 @@ export default Vue.extend({
> *
margin 0 16px
> .photos
display grid
grid-template-rows 1fr 1fr 1fr
grid-template-columns 1fr 1fr
gap 8px
height 300px
margin-top 16px
> div
border-radius 4px
background-position center center
background-size cover
> .stats
margin 16px 0
padding 8px
@ -156,6 +196,20 @@ export default Vue.extend({
> *
margin 0 8px
> .announcements
margin 16px 0
> article
background isDark ? rgba(30, 129, 216, 0.2) : rgba(155, 196, 232, 0.2)
border-radius 6px
color isDark ? #fff : #3f4967
padding 16px
margin 8px 0
font-size 12px
> .title
font-weight bold
> footer
text-align center
color #444
@ -165,4 +219,10 @@ export default Vue.extend({
margin 16px 0 0 0
opacity 0.7
.wgwfgvvimdjvhjfwxropcwksnzftjqes[data-darkmode]
root(true)
.wgwfgvvimdjvhjfwxropcwksnzftjqes:not([data-darkmode])
root(false)
</style>

View File

@ -4,6 +4,7 @@ import * as nestedProperty from 'nested-property';
import MiOS from './mios';
import { hostname } from './config';
import { erase } from '../../prelude/array';
const defaultSettings = {
home: null,
@ -195,7 +196,7 @@ export default (os: MiOS) => new Vuex.Store({
removeDeckColumn(state, id) {
state.deck.columns = state.deck.columns.filter(c => c.id != id);
state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id));
state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
},
@ -266,7 +267,7 @@ export default (os: MiOS) => new Vuex.Store({
stackLeftDeckColumn(state, id) {
const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id));
state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
const left = state.deck.layout[i - 1];
if (left) state.deck.layout[i - 1].push(id);
state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
@ -274,7 +275,7 @@ export default (os: MiOS) => new Vuex.Store({
popRightDeckColumn(state, id) {
const i = state.deck.layout.findIndex(ids => ids.indexOf(id) != -1);
state.deck.layout = state.deck.layout.map(ids => ids.filter(x => x != id));
state.deck.layout = state.deck.layout.map(ids => erase(id, ids));
state.deck.layout.splice(i + 1, 0, [id]);
state.deck.layout = state.deck.layout.filter(ids => ids.length > 0);
},

View File

@ -3,6 +3,7 @@
*/
import composeNotification from './common/scripts/compose-notification';
import { erase } from '../../prelude/array';
// キャッシュするリソース
const cachee = [
@ -24,8 +25,7 @@ self.addEventListener('activate', ev => {
// Clean up old caches
ev.waitUntil(
caches.keys().then(keys => Promise.all(
keys
.filter(key => key != _VERSION_)
erase(_VERSION_, keys)
.map(key => caches.delete(key))
))
);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 274 KiB

View File

@ -4,6 +4,12 @@ import config from '../config';
const index = {
settings: {
analysis: {
normalizer: {
lowercase_normalizer: {
type: 'custom',
filter: ['lowercase']
}
},
analyzer: {
bigram: {
tokenizer: 'bigram_tokenizer'
@ -24,7 +30,8 @@ const index = {
text: {
type: 'text',
index: true,
analyzer: 'bigram'
analyzer: 'bigram',
normalizer: 'lowercase_normalizer'
}
}
}

View File

@ -33,19 +33,19 @@ props:
ja-JP: "投稿の本文"
en-US: "The text of this note"
mediaIds:
fileIds:
type: "id(DriveFile)[]"
optional: true
desc:
ja-JP: "添付されているメディアのID (なければレスポンスでは空配列)"
en-US: "The IDs of the attached media (empty array for response if no media is attached)"
ja-JP: "添付されているファイルのID (なければレスポンスでは空配列)"
en-US: "The IDs of the attached files (empty array for response if no files is attached)"
media:
files:
type: "entity(DriveFile)[]"
optional: true
desc:
ja-JP: "添付されているメディア"
en-US: "The attached media"
ja-JP: "添付されているファイル"
en-US: "The attached files"
userId:
type: "id(User)"

View File

@ -1,3 +1,5 @@
import { count, concat } from "../../prelude/array";
// MISSKEY REVERSI ENGINE
/**
@ -88,8 +90,8 @@ export default class Reversi {
//#endregion
// ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある
if (this.canPutSomewhere(BLACK).length == 0) {
if (this.canPutSomewhere(WHITE).length == 0) {
if (!this.canPutSomewhere(BLACK)) {
if (!this.canPutSomewhere(WHITE)) {
this.turn = null;
} else {
this.turn = WHITE;
@ -101,14 +103,14 @@ export default class Reversi {
* 黒石の数
*/
public get blackCount() {
return this.board.filter(x => x === BLACK).length;
return count(BLACK, this.board);
}
/**
* 白石の数
*/
public get whiteCount() {
return this.board.filter(x => x === WHITE).length;
return count(WHITE, this.board);
}
/**
@ -170,9 +172,9 @@ export default class Reversi {
private calcTurn() {
// ターン計算
if (this.canPutSomewhere(!this.prevColor).length > 0) {
if (this.canPutSomewhere(!this.prevColor)) {
this.turn = !this.prevColor;
} else if (this.canPutSomewhere(this.prevColor).length > 0) {
} else if (this.canPutSomewhere(this.prevColor)) {
this.turn = this.prevColor;
} else {
this.turn = null;
@ -204,10 +206,17 @@ export default class Reversi {
/**
* 打つことができる場所を取得します
*/
public canPutSomewhere(color: Color): number[] {
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 自分の色
@ -229,87 +238,55 @@ export default class Reversi {
/**
* 指定のマスに石を置いた時の、反転させられる石を取得します
* @param color 自分の色
* @param pos 位置
* @param initPos 位置
*/
public effects(color: Color, pos: number): number[] {
public effects(color: Color, initPos: number): number[] {
const enemyColor = !color;
// ひっくり返せる石(の位置)リスト
let stones: number[] = [];
const diffVectors: [number, number][] = [
[ 0, -1], // 上
[ +1, -1], // 右上
[ +1, 0], // 右
[ +1, +1], // 右下
[ 0, +1], // 下
[ -1, +1], // 左下
[ -1, 0], // 左
[ -1, -1] // 左上
];
const initPos = pos;
// 走査
const iterate = (fn: (i: number) => number[]) => {
let i = 1;
const found = [];
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) {
let [x, y] = fn(i);
[x, y] = nextPos(x, y);
// 座標が指し示す位置がボード外に出たとき
if (this.opts.loopedBoard) {
if (x < 0 ) x = this.mapWidth - ((-x) % this.mapWidth);
if (y < 0 ) y = this.mapHeight - ((-y) % this.mapHeight);
if (x >= this.mapWidth ) x = x % this.mapWidth;
if (y >= this.mapHeight) y = y % this.mapHeight;
x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth;
y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight;
// for debug
//if (x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight) {
// console.log(x, y);
//}
// 一周して自分に帰ってきたら
if (this.transformXyToPos(x, y) == initPos) {
// ↓のコメントアウトを外すと、「現時点で自分の石が隣接していないが、
// そこに置いたとするとループして最終的に挟んだことになる」というケースを有効化します。(Test4のマップで違いが分かります)
// このケースを有効にした方が良いのか無効にした方が良いのか判断がつかなかったためとりあえず無効としておきます
// (あと無効な方がゲームとしておもしろそうだった)
stones = stones.concat(found);
break;
// 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ)
return found;
}
} else {
if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) break;
if (x == -1 || y == -1 || x == this.mapWidth || y == this.mapHeight) {
return []; // 挟めないことが確定 (盤面外に到達)
}
}
const pos = this.transformXyToPos(x, y);
//#region 「配置不能」マスに当たった場合走査終了
const pixel = this.mapDataGet(pos);
if (pixel == 'null') break;
//#endregion
// 石取得
if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達)
const stone = this.board[pos];
// 石が置かれていないマスなら走査終了
if (stone === null) break;
// 相手の石なら「ひっくり返せるかもリスト」に入れておく
if (stone === enemyColor) found.push(pos);
// 自分の石なら「ひっくり返せるかもリスト」を「ひっくり返せるリスト」に入れ、走査終了
if (stone === color) {
stones = stones.concat(found);
break;
}
i++;
if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達)
if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見)
if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見)
}
};
const [x, y] = this.transformPosToXy(pos);
iterate(i => [x , y - i]); // 上
iterate(i => [x + i, y - i]); // 右上
iterate(i => [x + i, y ]); // 右
iterate(i => [x + i, y + i]); // 右下
iterate(i => [x , y + i]); // 下
iterate(i => [x - i, y + i]); // 左下
iterate(i => [x - i, y ]); // 左
iterate(i => [x - i, y - i]); // 左上
return stones;
return concat(diffVectors.map(effectsInLine));
}
/**

View File

@ -4,10 +4,7 @@ const { JSDOM } = jsdom;
import config from '../config';
import { INote } from '../models/note';
import { TextElement } from './parse';
function intersperse<T>(sep: T, xs: T[]): T[] {
return [].concat(...xs.map(x => [sep, x])).slice(1);
}
import { intersperse } from '../prelude/array';
const handlers: { [key: string]: (window: any, token: any, mentionedRemoteUsers: INote['mentionedRemoteUsers']) => void } = {
bold({ document }, { bold }) {

View File

@ -1,3 +1,5 @@
import { capitalize } from "../../../prelude/string";
function escape(text: string) {
return text
.replace(/>/g, '&gt;')
@ -89,7 +91,7 @@ const _keywords = [
];
const keywords = _keywords
.concat(_keywords.map(k => k[0].toUpperCase() + k.substr(1)))
.concat(_keywords.map(capitalize))
.concat(_keywords.map(k => k.toUpperCase()))
.sort((a, b) => b.length - a.length);

View File

@ -16,9 +16,9 @@ const summarize = (note: any): string => {
// 本文
summary += note.text ? note.text : '';
// メディアが添付されているとき
if (note.media.length != 0) {
summary += ` (${note.media.length}つのメディア)`;
// ファイルが添付されているとき
if (note.files.length != 0) {
summary += ` (${note.files.length}つのファイル)`;
}
// 投票が添付されているとき

View File

@ -1,5 +1,5 @@
import { INote } from '../models/note';
export default function(note: INote): boolean {
return note.renoteId != null && (note.text != null || note.poll != null || (note.mediaIds != null && note.mediaIds.length > 0));
return note.renoteId != null && (note.text != null || note.poll != null || (note.fileIds != null && note.fileIds.length > 0));
}

View File

@ -92,7 +92,7 @@ export async function deleteDriveFile(driveFile: string | mongo.ObjectID | IDriv
// このDriveFileを添付しているNoteをすべて削除
await Promise.all((
await Note.find({ mediaIds: d._id })
await Note.find({ fileIds: d._id })
).map(x => deleteNote(x)));
// このDriveFileを添付しているMessagingMessageをすべて削除

View File

@ -6,7 +6,7 @@ import { IUser, pack as packUser } from './user';
import { pack as packApp } from './app';
import PollVote, { deletePollVote } from './poll-vote';
import Reaction, { deleteNoteReaction } from './note-reaction';
import { pack as packFile } from './drive-file';
import { pack as packFile, IDriveFile } from './drive-file';
import NoteWatching, { deleteNoteWatching } from './note-watching';
import NoteReaction from './note-reaction';
import Favorite, { deleteFavorite } from './favorite';
@ -17,9 +17,20 @@ const Note = db.get<INote>('notes');
Note.createIndex('uri', { sparse: true, unique: true });
Note.createIndex('userId');
Note.createIndex('tagsLower');
Note.createIndex('_files.contentType');
Note.createIndex({
createdAt: -1
});
// 後方互換性のため
Note.update({}, {
$rename: {
mediaIds: 'fileIds'
}
}, {
multi: true
});
export default Note;
export function isValidText(text: string): boolean {
@ -34,7 +45,7 @@ export type INote = {
_id: mongo.ObjectID;
createdAt: Date;
deletedAt: Date;
mediaIds: mongo.ObjectID[];
fileIds: mongo.ObjectID[];
replyId: mongo.ObjectID;
renoteId: mongo.ObjectID;
poll: {
@ -92,6 +103,7 @@ export type INote = {
inbox?: string;
};
_replyIds?: mongo.ObjectID[];
_files?: IDriveFile[];
};
/**
@ -271,11 +283,15 @@ export const pack = async (
_note.app = packApp(_note.appId);
}
// Populate media
_note.media = hide ? [] : Promise.all(_note.mediaIds.map((fileId: mongo.ObjectID) =>
// Populate files
_note.files = hide ? [] : Promise.all(_note.fileIds.map((fileId: mongo.ObjectID) =>
packFile(fileId)
));
// 後方互換性のため
_note.mediaIds = _note.fileIds;
_note.media = _note.files;
// When requested a detailed note data
if (opts.detail) {
//#region 重いので廃止
@ -344,7 +360,7 @@ export const pack = async (
}
if (hide) {
_note.mediaIds = [];
_note.fileIds = [];
_note.text = null;
_note.poll = null;
_note.cw = null;

3
src/prelude/README.md Normal file
View File

@ -0,0 +1,3 @@
# Prelude
このディレクトリのコードはJavaScriptの表現能力を補うためのコードです。
Misskey固有の処理とは独立したコードの集まりですが、Misskeyのコードを読みやすくすることを目的としています。

27
src/prelude/array.ts Normal file
View File

@ -0,0 +1,27 @@
export function countIf<T>(f: (x: T) => boolean, xs: T[]): number {
return xs.filter(f).length;
}
export function count<T>(x: T, xs: T[]): number {
return countIf(y => x === y, xs);
}
export function concat<T>(xss: T[][]): T[] {
return ([] as T[]).concat(...xss);
}
export function intersperse<T>(sep: T, xs: T[]): T[] {
return concat(xs.map(x => [sep, x])).slice(1);
}
export function erase<T>(x: T, xs: T[]): T[] {
return xs.filter(y => x !== y);
}
export function unique<T>(xs: T[]): T[] {
return [...new Set(xs)];
}
export function sum(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0);
}

3
src/prelude/math.ts Normal file
View File

@ -0,0 +1,3 @@
export function gcd(a: number, b: number): number {
return b === 0 ? a : gcd(b, a % b);
}

3
src/prelude/string.ts Normal file
View File

@ -0,0 +1,3 @@
export function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
}

View File

@ -78,11 +78,11 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
}
//#endergion
// 添付メディア
// 添付ファイル
// TODO: attachmentは必ずしもImageではない
// TODO: attachmentは必ずしも配列ではない
// Noteがsensitiveなら添付もsensitiveにする
const media = note.attachment
const files = note.attachment
.map(attach => attach.sensitive = note.sensitive)
? await Promise.all(note.attachment.map(x => resolveImage(actor, x)))
: [];
@ -100,7 +100,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
return await post(actor, {
createdAt: new Date(note.published),
media,
files: files,
reply,
renote: undefined,
cw: note.summary,

View File

@ -8,8 +8,8 @@ import User from '../../../models/user';
import toHtml from '../misc/get-note-html';
export default async function renderNote(note: INote, dive = true): Promise<any> {
const promisedFiles: Promise<IDriveFile[]> = note.mediaIds
? DriveFile.find({ _id: { $in: note.mediaIds } })
const promisedFiles: Promise<IDriveFile[]> = note.fileIds
? DriveFile.find({ _id: { $in: note.fileIds } })
: Promise.resolve([]);
let inReplyTo;

View File

@ -10,6 +10,7 @@ import { setResponseType } from '../activitypub';
import Note from '../../models/note';
import renderNote from '../../remote/activitypub/renderer/note';
import { countIf } from '../../prelude/array';
export default async (ctx: Router.IRouterContext) => {
const userId = new mongo.ObjectID(ctx.params.user);
@ -25,7 +26,7 @@ export default async (ctx: Router.IRouterContext) => {
const page: boolean = ctx.request.query.page === 'true';
// Validate parameters
if (sinceIdErr || untilIdErr || pageErr || [sinceId, untilId].filter(x => x != null).length > 1) {
if (sinceIdErr || untilIdErr || pageErr || countIf(x => x != null, [sinceId, untilId]) > 1) {
ctx.status = 400;
return;
}
@ -58,7 +59,7 @@ export default async (ctx: Router.IRouterContext) => {
$or: [{
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}]
}]
} as any;

View File

@ -25,10 +25,8 @@ export default (endpoint: string, user: IUser, app: IApp, data: any, file?: any)
return rej('YOU_ARE_NOT_ADMIN');
}
if (app && ep.meta.kind) {
if (!app.permission.some(p => p === ep.meta.kind)) {
return rej('PERMISSION_DENIED');
}
if (app && ep.meta.kind && !app.permission.some(p => p === ep.meta.kind)) {
return rej('PERMISSION_DENIED');
}
if (ep.meta.requireCredential && ep.meta.limit) {

View File

@ -1,4 +1,5 @@
import Note from '../../../../models/note';
import { erase } from '../../../../prelude/array';
/*
トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要
@ -85,8 +86,7 @@ export default () => new Promise(async (res, rej) => {
//#endregion
// タグを人気順に並べ替え
let hots = (await Promise.all(hotsPromises))
.filter(x => x != null)
let hots = erase(null, await Promise.all(hotsPromises))
.sort((a, b) => b.count - a.count)
.map(tag => tag.name)
.slice(0, max);

View File

@ -1,51 +1,65 @@
/**
* Module dependencies
*/
import $ from 'cafy'; import ID from '../../../misc/cafy-id';
import Note, { pack } from '../../../models/note';
import getParams from '../get-params';
export const meta = {
desc: {
'ja-JP': '投稿を取得します。'
},
params: {
local: $.bool.optional.note({
desc: {
'ja-JP': 'ローカルの投稿に限定するか否か'
}
}),
reply: $.bool.optional.note({
desc: {
'ja-JP': '返信に限定するか否か'
}
}),
renote: $.bool.optional.note({
desc: {
'ja-JP': 'Renoteに限定するか否か'
}
}),
withFiles: $.bool.optional.note({
desc: {
'ja-JP': 'ファイルが添付された投稿に限定するか否か'
}
}),
media: $.bool.optional.note({
desc: {
'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
}
}),
poll: $.bool.optional.note({
desc: {
'ja-JP': 'アンケートが添付された投稿に限定するか否か'
}
}),
limit: $.num.optional.range(1, 100).note({
default: 10
}),
sinceId: $.type(ID).optional.note({}),
untilId: $.type(ID).optional.note({}),
}
};
/**
* Get all notes
*/
export default (params: any) => new Promise(async (res, rej) => {
// Get 'local' parameter
const [local, localErr] = $.bool.optional.get(params.local);
if (localErr) return rej('invalid local param');
// Get 'reply' parameter
const [reply, replyErr] = $.bool.optional.get(params.reply);
if (replyErr) return rej('invalid reply param');
// Get 'renote' parameter
const [renote, renoteErr] = $.bool.optional.get(params.renote);
if (renoteErr) return rej('invalid renote param');
// Get 'media' parameter
const [media, mediaErr] = $.bool.optional.get(params.media);
if (mediaErr) return rej('invalid media param');
// Get 'poll' parameter
const [poll, pollErr] = $.bool.optional.get(params.poll);
if (pollErr) return rej('invalid poll param');
// Get 'bot' parameter
//const [bot, botErr] = $.bool.optional.get(params.bot);
//if (botErr) return rej('invalid bot param');
// Get 'limit' parameter
const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
const [ps, psErr] = getParams(meta, params);
if (psErr) throw psErr;
// Check if both of sinceId and untilId is specified
if (sinceId && untilId) {
if (ps.sinceId && ps.untilId) {
return rej('cannot set sinceId and untilId');
}
@ -56,35 +70,37 @@ export default (params: any) => new Promise(async (res, rej) => {
const query = {
visibility: 'public'
} as any;
if (sinceId) {
if (ps.sinceId) {
sort._id = 1;
query._id = {
$gt: sinceId
$gt: ps.sinceId
};
} else if (untilId) {
} else if (ps.untilId) {
query._id = {
$lt: untilId
$lt: ps.untilId
};
}
if (local) {
if (ps.local) {
query['_user.host'] = null;
}
if (reply != undefined) {
query.replyId = reply ? { $exists: true, $ne: null } : null;
if (ps.reply != undefined) {
query.replyId = ps.reply ? { $exists: true, $ne: null } : null;
}
if (renote != undefined) {
query.renoteId = renote ? { $exists: true, $ne: null } : null;
if (ps.renote != undefined) {
query.renoteId = ps.renote ? { $exists: true, $ne: null } : null;
}
if (media != undefined) {
query.mediaIds = media ? { $exists: true, $ne: null } : [];
const withFiles = ps.withFiles != undefined ? ps.withFiles : ps.media;
if (withFiles) {
query.fileIds = withFiles ? { $exists: true, $ne: null } : [];
}
if (poll != undefined) {
query.poll = poll ? { $exists: true, $ne: null } : null;
if (ps.poll != undefined) {
query.poll = ps.poll ? { $exists: true, $ne: null } : null;
}
// TODO
@ -95,7 +111,7 @@ export default (params: any) => new Promise(async (res, rej) => {
// Issue query
const notes = await Note
.find(query, {
limit: limit,
limit: ps.limit,
sort: sort
});

View File

@ -71,9 +71,15 @@ export const meta = {
ref: 'geo'
}),
fileIds: $.arr($.type(ID)).optional.unique().range(1, 4).note({
desc: {
'ja-JP': '添付するファイル'
}
}),
mediaIds: $.arr($.type(ID)).optional.unique().range(1, 4).note({
desc: {
'ja-JP': '添付するメディア'
'ja-JP': '添付するファイル (このパラメータは廃止予定です。代わりに fileIds を使ってください。)'
}
}),
@ -124,15 +130,16 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
}
let files: IDriveFile[] = [];
if (ps.mediaIds !== undefined) {
const fileIds = ps.fileIds != null ? ps.fileIds : ps.mediaIds != null ? ps.mediaIds : null;
if (fileIds != null) {
// Fetch files
// forEach だと途中でエラーなどがあっても return できないので
// 敢えて for を使っています。
for (const mediaId of ps.mediaIds) {
for (const fileId of fileIds) {
// Fetch file
// SELECT _id
const entity = await DriveFile.findOne({
_id: mediaId,
_id: fileId,
'metadata.userId': user._id
});
@ -155,7 +162,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
if (renote == null) {
return rej('renoteee is not found');
} else if (renote.renoteId && !renote.text && !renote.mediaIds) {
} else if (renote.renoteId && !renote.text && !renote.fileIds) {
return rej('cannot renote to renote');
}
}
@ -176,7 +183,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
}
// 返信対象が引用でないRenoteだったらエラー
if (reply.renoteId && !reply.text && !reply.mediaIds) {
if (reply.renoteId && !reply.text && !reply.fileIds) {
return rej('cannot reply to renote');
}
}
@ -191,13 +198,13 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
// テキストが無いかつ添付ファイルが無いかつRenoteも無いかつ投票も無かったらエラー
if ((ps.text === undefined || ps.text === null) && files === null && renote === null && ps.poll === undefined) {
return rej('text, mediaIds, renoteId or poll is required');
return rej('text, fileIds, renoteId or poll is required');
}
// 投稿を作成
const note = await create(user, {
createdAt: new Date(),
media: files,
files: files,
poll: ps.poll,
text: ps.text,
reply,

View File

@ -3,40 +3,50 @@ import Note from '../../../../models/note';
import Mute from '../../../../models/mute';
import { pack } from '../../../../models/note';
import { ILocalUser } from '../../../../models/user';
import getParams from '../../get-params';
import { countIf } from '../../../../prelude/array';
export const meta = {
desc: {
'ja-JP': 'グローバルタイムラインを取得します。'
},
params: {
withFiles: $.bool.optional.note({
desc: {
'ja-JP': 'ファイルが添付された投稿に限定するか否か'
}
}),
mediaOnly: $.bool.optional.note({
desc: {
'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
}
}),
limit: $.num.optional.range(1, 100).note({
default: 10
}),
sinceId: $.type(ID).optional.note({}),
untilId: $.type(ID).optional.note({}),
sinceDate: $.num.optional.note({}),
untilDate: $.num.optional.note({}),
}
};
/**
* Get timeline of global
*/
export default async (params: any, user: ILocalUser) => {
// Get 'limit' parameter
const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit);
if (limitErr) throw 'invalid limit param';
// Get 'sinceId' parameter
const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId);
if (sinceIdErr) throw 'invalid sinceId param';
// Get 'untilId' parameter
const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId);
if (untilIdErr) throw 'invalid untilId param';
// Get 'sinceDate' parameter
const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate);
if (sinceDateErr) throw 'invalid sinceDate param';
// Get 'untilDate' parameter
const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate);
if (untilDateErr) throw 'invalid untilDate param';
const [ps, psErr] = getParams(meta, params);
if (psErr) throw psErr;
// Check if only one of sinceId, untilId, sinceDate, untilDate specified
if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
}
// Get 'mediaOnly' parameter
const [mediaOnly, mediaOnlyErr] = $.bool.optional.get(params.mediaOnly);
if (mediaOnlyErr) throw 'invalid mediaOnly param';
// ミュートしているユーザーを取得
const mutedUserIds = user ? (await Mute.find({
muterId: user._id
@ -68,27 +78,29 @@ export default async (params: any, user: ILocalUser) => {
};
}
if (mediaOnly) {
query.mediaIds = { $exists: true, $ne: [] };
const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
if (withFiles) {
query.fileIds = { $exists: true, $ne: [] };
}
if (sinceId) {
if (ps.sinceId) {
sort._id = 1;
query._id = {
$gt: sinceId
$gt: ps.sinceId
};
} else if (untilId) {
} else if (ps.untilId) {
query._id = {
$lt: untilId
$lt: ps.untilId
};
} else if (sinceDate) {
} else if (ps.sinceDate) {
sort._id = 1;
query.createdAt = {
$gt: new Date(sinceDate)
$gt: new Date(ps.sinceDate)
};
} else if (untilDate) {
} else if (ps.untilDate) {
query.createdAt = {
$lt: new Date(untilDate)
$lt: new Date(ps.untilDate)
};
}
//#endregion
@ -96,7 +108,7 @@ export default async (params: any, user: ILocalUser) => {
// Issue query
const timeline = await Note
.find(query, {
limit: limit,
limit: ps.limit,
sort: sort
});

View File

@ -5,10 +5,9 @@ import { getFriends } from '../../common/get-friends';
import { pack } from '../../../../models/note';
import { ILocalUser } from '../../../../models/user';
import getParams from '../../get-params';
import { countIf } from '../../../../prelude/array';
export const meta = {
name: 'notes/hybrid-timeline',
desc: {
'ja-JP': 'ハイブリッドタイムラインを取得します。'
},
@ -66,9 +65,15 @@ export const meta = {
}
}),
withFiles: $.bool.optional.note({
desc: {
'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
}
}),
mediaOnly: $.bool.optional.note({
desc: {
'ja-JP': 'true にすると、メディアが添付された投稿だけ取得します'
'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
}
}),
}
@ -82,7 +87,7 @@ export default async (params: any, user: ILocalUser) => {
if (psErr) throw psErr;
// Check if only one of sinceId, untilId, sinceDate, untilDate specified
if ([ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate].filter(x => x != null).length > 1) {
if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
}
@ -164,7 +169,7 @@ export default async (params: any, user: ILocalUser) => {
}, {
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}, {
poll: { $ne: null }
}]
@ -180,7 +185,7 @@ export default async (params: any, user: ILocalUser) => {
}, {
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}, {
poll: { $ne: null }
}]
@ -196,16 +201,16 @@ export default async (params: any, user: ILocalUser) => {
}, {
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}, {
poll: { $ne: null }
}]
});
}
if (ps.mediaOnly) {
if (ps.withFiles || ps.mediaOnly) {
query.$and.push({
mediaIds: { $exists: true, $ne: [] }
fileIds: { $exists: true, $ne: [] }
});
}

View File

@ -3,40 +3,56 @@ import Note from '../../../../models/note';
import Mute from '../../../../models/mute';
import { pack } from '../../../../models/note';
import { ILocalUser } from '../../../../models/user';
import getParams from '../../get-params';
import { countIf } from '../../../../prelude/array';
export const meta = {
desc: {
'ja-JP': 'ローカルタイムラインを取得します。'
},
params: {
withFiles: $.bool.optional.note({
desc: {
'ja-JP': 'ファイルが添付された投稿に限定するか否か'
}
}),
mediaOnly: $.bool.optional.note({
desc: {
'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
}
}),
fileType: $.arr($.str).optional.note({
desc: {
'ja-JP': '指定された種類のファイルが添付された投稿のみを取得します'
}
}),
limit: $.num.optional.range(1, 100).note({
default: 10
}),
sinceId: $.type(ID).optional.note({}),
untilId: $.type(ID).optional.note({}),
sinceDate: $.num.optional.note({}),
untilDate: $.num.optional.note({}),
}
};
/**
* Get timeline of local
*/
export default async (params: any, user: ILocalUser) => {
// Get 'limit' parameter
const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit);
if (limitErr) throw 'invalid limit param';
// Get 'sinceId' parameter
const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId);
if (sinceIdErr) throw 'invalid sinceId param';
// Get 'untilId' parameter
const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId);
if (untilIdErr) throw 'invalid untilId param';
// Get 'sinceDate' parameter
const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate);
if (sinceDateErr) throw 'invalid sinceDate param';
// Get 'untilDate' parameter
const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate);
if (untilDateErr) throw 'invalid untilDate param';
const [ps, psErr] = getParams(meta, params);
if (psErr) throw psErr;
// Check if only one of sinceId, untilId, sinceDate, untilDate specified
if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
}
// Get 'mediaOnly' parameter
const [mediaOnly, mediaOnlyErr] = $.bool.optional.get(params.mediaOnly);
if (mediaOnlyErr) throw 'invalid mediaOnly param';
// ミュートしているユーザーを取得
const mutedUserIds = user ? (await Mute.find({
muterId: user._id
@ -69,27 +85,37 @@ export default async (params: any, user: ILocalUser) => {
};
}
if (mediaOnly) {
query.mediaIds = { $exists: true, $ne: [] };
const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
if (withFiles) {
query.fileIds = { $exists: true, $ne: [] };
}
if (sinceId) {
if (ps.fileType) {
query.fileIds = { $exists: true, $ne: [] };
query['_files.contentType'] = {
$in: ps.fileType
};
}
if (ps.sinceId) {
sort._id = 1;
query._id = {
$gt: sinceId
$gt: ps.sinceId
};
} else if (untilId) {
} else if (ps.untilId) {
query._id = {
$lt: untilId
$lt: ps.untilId
};
} else if (sinceDate) {
} else if (ps.sinceDate) {
sort._id = 1;
query.createdAt = {
$gt: new Date(sinceDate)
$gt: new Date(ps.sinceDate)
};
} else if (untilDate) {
} else if (ps.untilDate) {
query.createdAt = {
$lt: new Date(untilDate)
$lt: new Date(ps.untilDate)
};
}
//#endregion
@ -97,7 +123,7 @@ export default async (params: any, user: ILocalUser) => {
// Issue query
const timeline = await Note
.find(query, {
limit: limit,
limit: ps.limit,
sort: sort
});

View File

@ -4,119 +4,153 @@ import User, { ILocalUser } from '../../../../models/user';
import Mute from '../../../../models/mute';
import { getFriendIds } from '../../common/get-friends';
import { pack } from '../../../../models/note';
import getParams from '../../get-params';
import { erase } from '../../../../prelude/array';
export const meta = {
desc: {
'ja-JP': '指定されたタグが付けられた投稿を取得します。'
},
params: {
tag: $.str.note({
desc: {
'ja-JP': 'タグ'
}
}),
includeUserIds: $.arr($.type(ID)).optional.note({
default: []
}),
excludeUserIds: $.arr($.type(ID)).optional.note({
default: []
}),
includeUserUsernames: $.arr($.str).optional.note({
default: []
}),
excludeUserUsernames: $.arr($.str).optional.note({
default: []
}),
following: $.bool.optional.nullable.note({
default: null
}),
mute: $.str.optional.note({
default: 'mute_all'
}),
reply: $.bool.optional.nullable.note({
default: null,
desc: {
'ja-JP': '返信に限定するか否か'
}
}),
renote: $.bool.optional.nullable.note({
default: null,
desc: {
'ja-JP': 'Renoteに限定するか否か'
}
}),
withFiles: $.bool.optional.nullable.note({
default: null,
desc: {
'ja-JP': 'ファイルが添付された投稿に限定するか否か'
}
}),
media: $.bool.optional.nullable.note({
default: null,
desc: {
'ja-JP': 'ファイルが添付された投稿に限定するか否か (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
}
}),
poll: $.bool.optional.nullable.note({
default: null,
desc: {
'ja-JP': 'アンケートが添付された投稿に限定するか否か'
}
}),
sinceDate: $.num.optional.note({
}),
untilDate: $.num.optional.note({
}),
offset: $.num.optional.min(0).note({
default: 0
}),
limit: $.num.optional.range(1, 30).note({
default: 10
}),
}
};
/**
* Search notes by tag
*/
export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => {
// Get 'tag' parameter
const [tag, tagError] = $.str.get(params.tag);
if (tagError) return rej('invalid tag param');
const [ps, psErr] = getParams(meta, params);
if (psErr) throw psErr;
// Get 'includeUserIds' parameter
const [includeUserIds = [], includeUserIdsErr] = $.arr($.type(ID)).optional.get(params.includeUserIds);
if (includeUserIdsErr) return rej('invalid includeUserIds param');
// Get 'excludeUserIds' parameter
const [excludeUserIds = [], excludeUserIdsErr] = $.arr($.type(ID)).optional.get(params.excludeUserIds);
if (excludeUserIdsErr) return rej('invalid excludeUserIds param');
// Get 'includeUserUsernames' parameter
const [includeUserUsernames = [], includeUserUsernamesErr] = $.arr($.str).optional.get(params.includeUserUsernames);
if (includeUserUsernamesErr) return rej('invalid includeUserUsernames param');
// Get 'excludeUserUsernames' parameter
const [excludeUserUsernames = [], excludeUserUsernamesErr] = $.arr($.str).optional.get(params.excludeUserUsernames);
if (excludeUserUsernamesErr) return rej('invalid excludeUserUsernames param');
// Get 'following' parameter
const [following = null, followingErr] = $.bool.optional.nullable.get(params.following);
if (followingErr) return rej('invalid following param');
// Get 'mute' parameter
const [mute = 'mute_all', muteErr] = $.str.optional.get(params.mute);
if (muteErr) return rej('invalid mute param');
// Get 'reply' parameter
const [reply = null, replyErr] = $.bool.optional.nullable.get(params.reply);
if (replyErr) return rej('invalid reply param');
// Get 'renote' parameter
const [renote = null, renoteErr] = $.bool.optional.nullable.get(params.renote);
if (renoteErr) return rej('invalid renote param');
// Get 'media' parameter
const [media = null, mediaErr] = $.bool.optional.nullable.get(params.media);
if (mediaErr) return rej('invalid media param');
// Get 'poll' parameter
const [poll = null, pollErr] = $.bool.optional.nullable.get(params.poll);
if (pollErr) return rej('invalid poll param');
// Get 'sinceDate' parameter
const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate);
if (sinceDateErr) throw 'invalid sinceDate param';
// Get 'untilDate' parameter
const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate);
if (untilDateErr) throw 'invalid untilDate param';
// Get 'offset' parameter
const [offset = 0, offsetErr] = $.num.optional.min(0).get(params.offset);
if (offsetErr) return rej('invalid offset param');
// Get 'limit' parameter
const [limit = 10, limitErr] = $.num.optional.range(1, 30).get(params.limit);
if (limitErr) return rej('invalid limit param');
if (includeUserUsernames != null) {
const ids = (await Promise.all(includeUserUsernames.map(async (username) => {
if (ps.includeUserUsernames != null) {
const ids = erase(null, await Promise.all(ps.includeUserUsernames.map(async (username) => {
const _user = await User.findOne({
usernameLower: username.toLowerCase()
});
return _user ? _user._id : null;
}))).filter(id => id != null);
})));
ids.forEach(id => includeUserIds.push(id));
ids.forEach(id => ps.includeUserIds.push(id));
}
if (excludeUserUsernames != null) {
const ids = (await Promise.all(excludeUserUsernames.map(async (username) => {
if (ps.excludeUserUsernames != null) {
const ids = erase(null, await Promise.all(ps.excludeUserUsernames.map(async (username) => {
const _user = await User.findOne({
usernameLower: username.toLowerCase()
});
return _user ? _user._id : null;
}))).filter(id => id != null);
})));
ids.forEach(id => excludeUserIds.push(id));
ids.forEach(id => ps.excludeUserIds.push(id));
}
let q: any = {
$and: [{
tagsLower: tag.toLowerCase()
tagsLower: ps.tag.toLowerCase()
}]
};
const push = (x: any) => q.$and.push(x);
if (includeUserIds && includeUserIds.length != 0) {
if (ps.includeUserIds && ps.includeUserIds.length != 0) {
push({
userId: {
$in: includeUserIds
$in: ps.includeUserIds
}
});
} else if (excludeUserIds && excludeUserIds.length != 0) {
} else if (ps.excludeUserIds && ps.excludeUserIds.length != 0) {
push({
userId: {
$nin: excludeUserIds
$nin: ps.excludeUserIds
}
});
}
if (following != null && me != null) {
if (ps.following != null && me != null) {
const ids = await getFriendIds(me._id, false);
push({
userId: following ? {
userId: ps.following ? {
$in: ids
} : {
$nin: ids.concat(me._id)
@ -131,7 +165,7 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
});
const mutedUserIds = mutes.map(m => m.muteeId);
switch (mute) {
switch (ps.mute) {
case 'mute_all':
push({
userId: {
@ -202,8 +236,8 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
}
}
if (reply != null) {
if (reply) {
if (ps.reply != null) {
if (ps.reply) {
push({
replyId: {
$exists: true,
@ -223,8 +257,8 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
}
}
if (renote != null) {
if (renote) {
if (ps.renote != null) {
if (ps.renote) {
push({
renoteId: {
$exists: true,
@ -244,10 +278,12 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
}
}
if (media != null) {
if (media) {
const withFiles = ps.withFiles != null ? ps.withFiles : ps.media;
if (withFiles != null) {
if (withFiles) {
push({
mediaIds: {
fileIds: {
$exists: true,
$ne: null
}
@ -255,18 +291,18 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
} else {
push({
$or: [{
mediaIds: {
fileIds: {
$exists: false
}
}, {
mediaIds: null
fileIds: null
}]
});
}
}
if (poll != null) {
if (poll) {
if (ps.poll != null) {
if (ps.poll) {
push({
poll: {
$exists: true,
@ -286,18 +322,18 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
}
}
if (sinceDate) {
if (ps.sinceDate) {
push({
createdAt: {
$gt: new Date(sinceDate)
$gt: new Date(ps.sinceDate)
}
});
}
if (untilDate) {
if (ps.untilDate) {
push({
createdAt: {
$lt: new Date(untilDate)
$lt: new Date(ps.untilDate)
}
});
}
@ -312,8 +348,8 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
sort: {
_id: -1
},
limit: limit,
skip: offset
limit: ps.limit,
skip: ps.offset
});
// Serialize

View File

@ -5,6 +5,7 @@ import { getFriends } from '../../common/get-friends';
import { pack } from '../../../../models/note';
import { ILocalUser } from '../../../../models/user';
import getParams from '../../get-params';
import { countIf } from '../../../../prelude/array';
export const meta = {
desc: {
@ -67,9 +68,15 @@ export const meta = {
}
}),
withFiles: $.bool.optional.note({
desc: {
'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
}
}),
mediaOnly: $.bool.optional.note({
desc: {
'ja-JP': 'true にすると、メディアが添付された投稿だけ取得します'
'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
}
}),
}
@ -80,7 +87,7 @@ export default async (params: any, user: ILocalUser) => {
if (psErr) throw psErr;
// Check if only one of sinceId, untilId, sinceDate, untilDate specified
if ([ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate].filter(x => x != null).length > 1) {
if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
}
@ -154,7 +161,7 @@ export default async (params: any, user: ILocalUser) => {
}, {
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}, {
poll: { $ne: null }
}]
@ -170,7 +177,7 @@ export default async (params: any, user: ILocalUser) => {
}, {
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}, {
poll: { $ne: null }
}]
@ -186,16 +193,18 @@ export default async (params: any, user: ILocalUser) => {
}, {
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}, {
poll: { $ne: null }
}]
});
}
if (ps.mediaOnly) {
const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
if (withFiles) {
query.$and.push({
mediaIds: { $exists: true, $ne: [] }
fileIds: { $exists: true, $ne: [] }
});
}

View File

@ -52,7 +52,7 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
}
if (media != undefined) {
query.mediaIds = media ? { $exists: true, $ne: null } : null;
query.fileIds = media ? { $exists: true, $ne: null } : null;
}
if (poll != undefined) {

View File

@ -73,9 +73,15 @@ export const meta = {
}
}),
withFiles: $.bool.optional.note({
desc: {
'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
}
}),
mediaOnly: $.bool.optional.note({
desc: {
'ja-JP': 'true にすると、メディアが添付された投稿だけ取得します'
'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
}
}),
}
@ -160,7 +166,7 @@ export default async (params: any, user: ILocalUser) => {
}, {
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}, {
poll: { $ne: null }
}]
@ -176,7 +182,7 @@ export default async (params: any, user: ILocalUser) => {
}, {
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}, {
poll: { $ne: null }
}]
@ -192,16 +198,18 @@ export default async (params: any, user: ILocalUser) => {
}, {
text: { $ne: null }
}, {
mediaIds: { $ne: [] }
fileIds: { $ne: [] }
}, {
poll: { $ne: null }
}]
});
}
if (ps.mediaOnly) {
const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
if (withFiles) {
query.$and.push({
mediaIds: { $exists: true, $ne: [] }
fileIds: { $exists: true, $ne: [] }
});
}

View File

@ -2,63 +2,122 @@ import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
import getHostLower from '../../common/get-host-lower';
import Note, { pack } from '../../../../models/note';
import User, { ILocalUser } from '../../../../models/user';
import getParams from '../../get-params';
import { countIf } from '../../../../prelude/array';
export const meta = {
desc: {
'ja-JP': '指定したユーザーのタイムラインを取得します。'
},
params: {
userId: $.type(ID).optional.note({
desc: {
'ja-JP': 'ユーザーID'
}
}),
username: $.str.optional.note({
desc: {
'ja-JP': 'ユーザー名'
}
}),
host: $.str.optional.note({
}),
includeReplies: $.bool.optional.note({
default: true,
desc: {
'ja-JP': 'リプライを含めるか否か'
}
}),
limit: $.num.optional.range(1, 100).note({
default: 10,
desc: {
'ja-JP': '最大数'
}
}),
sinceId: $.type(ID).optional.note({
desc: {
'ja-JP': '指定すると、この投稿を基点としてより新しい投稿を取得します'
}
}),
untilId: $.type(ID).optional.note({
desc: {
'ja-JP': '指定すると、この投稿を基点としてより古い投稿を取得します'
}
}),
sinceDate: $.num.optional.note({
desc: {
'ja-JP': '指定した時間を基点としてより新しい投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
}
}),
untilDate: $.num.optional.note({
desc: {
'ja-JP': '指定した時間を基点としてより古い投稿を取得します。数値は、1970年1月1日 00:00:00 UTC から指定した日時までの経過時間をミリ秒単位で表します。'
}
}),
includeMyRenotes: $.bool.optional.note({
default: true,
desc: {
'ja-JP': '自分の行ったRenoteを含めるかどうか'
}
}),
includeRenotedMyNotes: $.bool.optional.note({
default: true,
desc: {
'ja-JP': 'Renoteされた自分の投稿を含めるかどうか'
}
}),
includeLocalRenotes: $.bool.optional.note({
default: true,
desc: {
'ja-JP': 'Renoteされたローカルの投稿を含めるかどうか'
}
}),
withFiles: $.bool.optional.note({
default: false,
desc: {
'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
}
}),
mediaOnly: $.bool.optional.note({
default: false,
desc: {
'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します (このパラメータは廃止予定です。代わりに withFiles を使ってください。)'
}
}),
}
};
/**
* Get notes of a user
*/
export default (params: any, me: ILocalUser) => new Promise(async (res, rej) => {
// Get 'userId' parameter
const [userId, userIdErr] = $.type(ID).optional.get(params.userId);
if (userIdErr) return rej('invalid userId param');
const [ps, psErr] = getParams(meta, params);
if (psErr) throw psErr;
// Get 'username' parameter
const [username, usernameErr] = $.str.optional.get(params.username);
if (usernameErr) return rej('invalid username param');
if (userId === undefined && username === undefined) {
if (ps.userId === undefined && ps.username === undefined) {
return rej('userId or username is required');
}
// Get 'host' parameter
const [host, hostErr] = $.str.optional.get(params.host);
if (hostErr) return rej('invalid host param');
// Get 'includeReplies' parameter
const [includeReplies = true, includeRepliesErr] = $.bool.optional.get(params.includeReplies);
if (includeRepliesErr) return rej('invalid includeReplies param');
// Get 'withMedia' parameter
const [withMedia = false, withMediaErr] = $.bool.optional.get(params.withMedia);
if (withMediaErr) return rej('invalid withMedia param');
// Get 'limit' parameter
const [limit = 10, limitErr] = $.num.optional.range(1, 100).get(params.limit);
if (limitErr) return rej('invalid limit param');
// Get 'sinceId' parameter
const [sinceId, sinceIdErr] = $.type(ID).optional.get(params.sinceId);
if (sinceIdErr) return rej('invalid sinceId param');
// Get 'untilId' parameter
const [untilId, untilIdErr] = $.type(ID).optional.get(params.untilId);
if (untilIdErr) return rej('invalid untilId param');
// Get 'sinceDate' parameter
const [sinceDate, sinceDateErr] = $.num.optional.get(params.sinceDate);
if (sinceDateErr) throw 'invalid sinceDate param';
// Get 'untilDate' parameter
const [untilDate, untilDateErr] = $.num.optional.get(params.untilDate);
if (untilDateErr) throw 'invalid untilDate param';
// Check if only one of sinceId, untilId, sinceDate, untilDate specified
if ([sinceId, untilId, sinceDate, untilDate].filter(x => x != null).length > 1) {
if (countIf(x => x != null, [ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate]) > 1) {
throw 'only one of sinceId, untilId, sinceDate, untilDate can be specified';
}
const q = userId !== undefined
? { _id: userId }
: { usernameLower: username.toLowerCase(), host: getHostLower(host) } ;
const q = ps.userId !== undefined
? { _id: ps.userId }
: { usernameLower: ps.username.toLowerCase(), host: getHostLower(ps.host) } ;
// Lookup user
const user = await User.findOne(q, {
@ -80,32 +139,34 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
userId: user._id
} as any;
if (sinceId) {
if (ps.sinceId) {
sort._id = 1;
query._id = {
$gt: sinceId
$gt: ps.sinceId
};
} else if (untilId) {
} else if (ps.untilId) {
query._id = {
$lt: untilId
$lt: ps.untilId
};
} else if (sinceDate) {
} else if (ps.sinceDate) {
sort._id = 1;
query.createdAt = {
$gt: new Date(sinceDate)
$gt: new Date(ps.sinceDate)
};
} else if (untilDate) {
} else if (ps.untilDate) {
query.createdAt = {
$lt: new Date(untilDate)
$lt: new Date(ps.untilDate)
};
}
if (!includeReplies) {
if (!ps.includeReplies) {
query.replyId = null;
}
if (withMedia) {
query.mediaIds = {
const withFiles = ps.withFiles != null ? ps.withFiles : ps.mediaOnly;
if (withFiles) {
query.fileIds = {
$exists: true,
$ne: []
};
@ -115,12 +176,10 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
// Issue query
const notes = await Note
.find(query, {
limit: limit,
limit: ps.limit,
sort: sort
});
// Serialize
res(await Promise.all(notes.map(async (note) =>
await pack(note, me)
)));
res(await Promise.all(notes.map(note => pack(note, me))));
});

View File

@ -34,8 +34,9 @@ export default async (url: string, user: IUser, folderId: mongodb.ObjectID = nul
// write content at URL to temp file
await new Promise((res, rej) => {
const writable = fs.createWriteStream(path);
const requestUrl = URL.parse(url).pathname.match(/[^\u0021-\u00ff]/) ? encodeURI(url) : url;
request({
url,
url: requestUrl,
headers: {
'User-Agent': config.user_agent
}

View File

@ -24,6 +24,7 @@ import isQuote from '../../misc/is-quote';
import { TextElementMention } from '../../mfm/parse/elements/mention';
import { TextElementHashtag } from '../../mfm/parse/elements/hashtag';
import { updateNoteStats } from '../update-chart';
import { erase, unique } from '../../prelude/array';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -84,7 +85,7 @@ type Option = {
text?: string;
reply?: INote;
renote?: INote;
media?: IDriveFile[];
files?: IDriveFile[];
geo?: any;
poll?: any;
viaMobile?: boolean;
@ -103,7 +104,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
if (data.viaMobile == null) data.viaMobile = false;
if (data.visibleUsers) {
data.visibleUsers = data.visibleUsers.filter(x => x != null);
data.visibleUsers = erase(null, data.visibleUsers);
}
if (data.reply && data.reply.deletedAt != null) {
@ -135,7 +136,7 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
const mentionedUsers = await extractMentionedUsers(tokens);
const note = await insertNote(user, data, tokens, tags, mentionedUsers);
const note = await insertNote(user, data, tags, mentionedUsers);
res(note);
@ -309,10 +310,10 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren
publishToUserLists(note, noteObj);
}
async function insertNote(user: IUser, data: Option, tokens: ReturnType<typeof parse>, tags: string[], mentionedUsers: IUser[]) {
async function insertNote(user: IUser, data: Option, tags: string[], mentionedUsers: IUser[]) {
const insert: any = {
createdAt: data.createdAt,
mediaIds: data.media ? data.media.map(file => file._id) : [],
fileIds: data.files ? data.files.map(file => file._id) : [],
replyId: data.reply ? data.reply._id : null,
renoteId: data.renote ? data.renote._id : null,
text: data.text,
@ -347,7 +348,8 @@ async function insertNote(user: IUser, data: Option, tokens: ReturnType<typeof p
_user: {
host: user.host,
inbox: isRemoteUser(user) ? user.inbox : undefined
}
},
_files: data.files ? data.files : []
};
if (data.uri != null) insert.uri = data.uri;
@ -383,7 +385,7 @@ function extractHashtags(tokens: ReturnType<typeof parse>): string[] {
.map(t => (t as TextElementHashtag).hashtag)
.filter(tag => tag.length <= 100);
return [...new Set(hashtags)];
return unique(hashtags);
}
function index(note: INote) {
@ -540,20 +542,20 @@ function incNotesCount(user: IUser) {
async function extractMentionedUsers(tokens: ReturnType<typeof parse>): Promise<IUser[]> {
if (tokens == null) return [];
const mentionTokens = [...new Set(
const mentionTokens = unique(
tokens
.filter(t => t.type == 'mention') as TextElementMention[]
)];
);
const mentionedUsers = [...new Set(
(await Promise.all(mentionTokens.map(async m => {
const mentionedUsers = unique(
erase(null, await Promise.all(mentionTokens.map(async m => {
try {
return await resolveUser(m.username, m.host);
} catch (e) {
return null;
}
}))).filter(x => x != null)
)];
})))
);
return mentionedUsers;
}

View File

@ -23,7 +23,7 @@ export default async function(user: IUser, note: INote) {
deletedAt: new Date(),
text: null,
tags: [],
mediaIds: [],
fileIds: [],
poll: null,
geo: null
}

View File

@ -17,6 +17,7 @@
"no-empty":false,
"ordered-imports": [false],
"arrow-parens": false,
"array-type": false,
"object-literal-shorthand": false,
"object-literal-key-quotes": false,
"triple-equals": [false],

View File

@ -196,7 +196,7 @@ module.exports = {
}, {
loader: 'sass-loader',
options: {
importer: jsonImporter,
importer: jsonImporter(),
}
}]
}, {