Compare commits

..

39 Commits

Author SHA1 Message Date
01131e2606 8.57.0 2018-09-20 17:23:55 +09:00
6283b7668e fix(package): update @types/koa-router to version 7.0.32 (#2740) 2018-09-20 17:23:26 +09:00
d058ecc4ea Resolve #2698 2018-09-20 17:21:16 +09:00
77a0450b5d 🎨 2018-09-20 16:21:51 +09:00
1dd1b9084f Merge branch 'develop' of https://github.com/syuilo/misskey into develop 2018-09-20 16:16:08 +09:00
6341807d02 🎨 2018-09-20 16:16:01 +09:00
51a1f30225 fix(package): update @types/webpack to version 4.4.12 (#2739) 2018-09-20 06:29:03 +09:00
5422482696 Resolve #1153 2018-09-20 06:27:41 +09:00
cd7f8b080e Fix #2738 2018-09-20 04:56:24 +09:00
faf29b768f Make admin can delete any note 2018-09-19 17:29:03 +09:00
7576569dc9 8.56.0 2018-09-19 14:24:40 +09:00
ea3bcbbc37 キャッシュの設定を調整 2018-09-19 14:22:46 +09:00
d9f0e158a3 Implement #2736 2018-09-19 14:18:34 +09:00
195f676500 8.55.0 2018-09-19 08:38:18 +09:00
a9a2f4820b Add keyboard shortcut doc 2018-09-19 08:36:06 +09:00
8414db57f0 Specify AP Cache-Control (#2735) 2018-09-19 07:17:19 +09:00
609d68933e Add new shortcut 2018-09-19 02:51:06 +09:00
a23b8cebbc 8.54.0 2018-09-19 02:41:09 +09:00
89f6b03cd6 fix(package): update web-push to version 3.3.3 (#2733) 2018-09-19 02:39:57 +09:00
7bc9de03a6 fix(package): update webpack to version 4.19.1 (#2732) 2018-09-19 02:39:15 +09:00
3c865d6054 Add new shortcut 2018-09-19 02:35:32 +09:00
fd770b008e Add new shortcut 2018-09-19 02:32:44 +09:00
b0d60ef2c2 Add new shortcut 2018-09-19 02:27:19 +09:00
7b9cea06ef Fix 2018-09-19 02:26:06 +09:00
30608d3e22 8.53.0 2018-09-18 16:45:55 +09:00
8bf4e55338 Improve keyboard shortcuts 2018-09-18 16:45:20 +09:00
6ead1de383 Improve readability 2018-09-18 15:02:26 +09:00
3b628ec3c4 将来的にバグに繋がりかねない挙動を修正 2018-09-18 15:02:15 +09:00
0ed704d173 8.52.0 2018-09-18 14:54:01 +09:00
87b6ef0ec5 Improve keyboard shortcut 2018-09-18 14:53:17 +09:00
5184a07cf2 Improve usability 2018-09-18 14:50:13 +09:00
dba04cc59c Improve keyboard shortcuts 2018-09-18 14:43:54 +09:00
f4045fb5b3 Improve keyboard shortcuts 2018-09-18 14:39:18 +09:00
16c36163b4 Fix bug 2018-09-18 14:35:46 +09:00
1ac033ff18 Improve keyboard shortcut 2018-09-18 14:30:50 +09:00
ccfd48232a 8.51.0 2018-09-18 13:14:42 +09:00
429bf179dc Refactor: Better type annotations 2018-09-18 13:14:17 +09:00
8ba3fb13eb Fix bug 2018-09-18 13:12:41 +09:00
11496d887e Publish pinned notes (#2731) 2018-09-18 13:08:27 +09:00
63 changed files with 775 additions and 106 deletions

View File

@ -1,8 +1,8 @@
{
"name": "misskey",
"author": "syuilo <i@syuilo.com>",
"version": "8.50.0",
"clientVersion": "1.0.9883",
"version": "8.57.0",
"clientVersion": "1.0.9922",
"codename": "nighthike",
"main": "./built/index.js",
"private": true,
@ -51,7 +51,7 @@
"@types/koa-logger": "3.1.0",
"@types/koa-mount": "3.0.1",
"@types/koa-multer": "1.0.0",
"@types/koa-router": "7.0.31",
"@types/koa-router": "7.0.32",
"@types/koa-send": "4.1.1",
"@types/koa-views": "2.0.3",
"@types/koa__cors": "2.2.3",
@ -77,7 +77,7 @@
"@types/systeminformation": "3.23.0",
"@types/tmp": "0.0.33",
"@types/uuid": "3.4.4",
"@types/webpack": "4.4.11",
"@types/webpack": "4.4.12",
"@types/webpack-stream": "3.2.10",
"@types/websocket": "0.0.40",
"@types/ws": "6.0.1",
@ -217,9 +217,9 @@
"vuewordcloud": "18.7.11",
"vuex": "3.0.1",
"vuex-persistedstate": "2.5.4",
"web-push": "3.3.2",
"web-push": "3.3.3",
"webfinger.js": "2.6.6",
"webpack": "4.19.0",
"webpack": "4.19.1",
"webpack-cli": "3.1.0",
"websocket": "1.0.26",
"ws": "6.0.0",

View File

@ -1,3 +1,24 @@
<template>
<router-view id="app"></router-view>
<router-view id="app" v-hotkey.global="keymap"></router-view>
</template>
<script lang="ts">
import Vue from 'vue';
import { url, lang } from './config';
export default Vue.extend({
computed: {
keymap(): any {
return {
'h|slash': this.help
};
}
},
methods: {
help() {
window.open(`${url}/docs/${lang}/keyboard-shortcut`, '_blank');
}
}
});
</script>

View File

@ -22,7 +22,10 @@ const getKeyMap = keymap => Object.entries(keymap).map(([patterns, callback]): a
result.patterns = patterns.split('|').map(part => {
const pattern = {
which: []
which: [],
ctrl: false,
alt: false,
shift: false
} as pattern;
part.trim().split('+').forEach(key => {
@ -66,10 +69,10 @@ export default {
if (el._hotkey_global && targetReservedKeys.includes(`'${key}'`)) break;
const matched = action.patterns.some(pattern => {
let matched = pattern.which.includes(key);
if (pattern.ctrl && !e.ctrlKey) matched = false;
if (pattern.shift && !e.shiftKey) matched = false;
if (pattern.alt && !e.altKey) matched = false;
const matched = pattern.which.includes(key) &&
pattern.ctrl == e.ctrlKey &&
pattern.shift == e.shiftKey &&
pattern.alt == e.altKey;
if (matched) {
e.preventDefault();

View File

@ -50,6 +50,30 @@ export class HomeStream extends Stream {
});
});
this.on('unreadMention', () => {
os.store.dispatch('mergeMe', {
hasUnreadMentions: true
});
});
this.on('readAllUnreadMentions', () => {
os.store.dispatch('mergeMe', {
hasUnreadMentions: false
});
});
this.on('unreadSpecifiedNote', () => {
os.store.dispatch('mergeMe', {
hasUnreadSpecifiedNotes: true
});
});
this.on('readAllUnreadSpecifiedNotes', () => {
os.store.dispatch('mergeMe', {
hasUnreadSpecifiedNotes: false
});
});
this.on('clientSettingUpdated', x => {
os.store.commit('settings/set', {
key: x.key,

View File

@ -2,9 +2,9 @@
<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv">
<div class="backdrop" ref="backdrop" @click="close"></div>
<div class="popover" :class="{ hukidasi }" ref="popover">
<template v-for="item in items">
<template v-for="item, i in items">
<div v-if="item === null"></div>
<button v-if="item" @click="clicked(item.action)" v-html="item.icon ? item.icon + ' ' + item.text : item.text"></button>
<button v-if="item" @click="clicked(item.action)" v-html="item.icon ? item.icon + ' ' + item.text : item.text" :tabindex="i"></button>
</template>
</div>
</div>

View File

@ -33,12 +33,16 @@ export default Vue.extend({
text: '%i18n:@pin%',
action: this.pin
});
}
if (this.note.userId == this.$store.state.i.id || this.$store.state.i.isAdmin) {
items.push({
icon: '%fa:trash-alt R%',
text: '%i18n:@delete%',
action: this.del
});
}
if (this.note.uri) {
items.push({
icon: '%fa:external-link-square-alt%',
@ -48,6 +52,7 @@ export default Vue.extend({
}
});
}
return items;
}
},

View File

@ -77,10 +77,10 @@ export default Vue.extend({
return {
'esc': this.close,
'enter|space|plus': this.choose,
'up': this.focusUp,
'right': this.focusRight,
'down': this.focusDown,
'left': this.focusLeft,
'up|k': this.focusUp,
'left|h|shift+tab': this.focusLeft,
'right|l|tab': this.focusRight,
'down|j': this.focusDown,
'1': () => this.react('like'),
'2': () => this.react('love'),
'3': () => this.react('laugh'),
@ -97,10 +97,10 @@ export default Vue.extend({
watch: {
focus(i) {
this.$refs.buttons.childNodes[i].focus();
this.$refs.buttons.children[i].focus();
if (this.showFocus) {
this.title = this.$refs.buttons.childNodes[i].title;
this.title = this.$refs.buttons.children[i].title;
}
}
},

View File

@ -38,7 +38,7 @@
</div>
</div>
</div>
<div class="main">
<div class="main" :class="{ withBg: $store.state.i.wallpaperUrl != null }">
<template v-if="customize">
<x-draggable v-for="place in ['left', 'right']"
:list="widgets[place]"
@ -340,7 +340,10 @@ root(isDark)
display flex
justify-content center
margin 0 auto
max-width 1220px
max-width 1240px
&.withBg
background rgba(isDark ? #000 : #fff, 0.5)
> *
.customize-container
@ -355,7 +358,7 @@ root(isDark)
> .main
padding 16px
width calc(100% - 275px * 2)
width calc(100% - 280px * 2)
order 2
> .form
@ -371,7 +374,7 @@ root(isDark)
border-radius 0
> *:not(.main)
width 275px
width 280px
padding 16px 0 16px 0
> *:not(:last-child)

View File

@ -40,18 +40,18 @@
</div>
<footer>
<mk-reactions-viewer :note="p" ref="reactionsViewer"/>
<button class="replyButton" @click="reply" title="%i18n:@reply%">
<button class="replyButton" @click="reply()" title="%i18n:@reply%">
<template v-if="p.reply">%fa:reply-all%</template>
<template v-else>%fa:reply%</template>
<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
</button>
<button class="renoteButton" @click="renote" title="%i18n:@renote%">
<button class="renoteButton" @click="renote()" title="%i18n:@renote%">
%fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p>
</button>
<button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react()" ref="reactButton" title="%i18n:@add-reaction%">
%fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p>
</button>
<button @click="menu" ref="menuButton">
<button @click="menu()" ref="menuButton">
%fa:ellipsis-h%
</button>
<!-- <button title="%i18n:@detail">
@ -113,11 +113,25 @@ export default Vue.extend({
computed: {
keymap(): any {
return {
'r|left': this.reply,
'a|plus': () => this.react(true),
'n|right': this.renote,
'up|shift+tab': this.focusBefore,
'down|tab': this.focusAfter,
'r|left': () => this.reply(true),
'e|a|plus': () => this.react(true),
'q|right': () => this.renote(true),
'ctrl+q|ctrl+right': this.renoteDirectly,
'up|k|shift+tab': this.focusBefore,
'down|j|tab': this.focusAfter,
'esc': this.blur,
'm|o': () => this.menu(true),
's': this.toggleShowContent,
'1': () => this.reactDirectly('like'),
'2': () => this.reactDirectly('love'),
'3': () => this.reactDirectly('laugh'),
'4': () => this.reactDirectly('hmm'),
'5': () => this.reactDirectly('surprise'),
'6': () => this.reactDirectly('congrats'),
'7': () => this.reactDirectly('angry'),
'8': () => this.reactDirectly('confused'),
'9': () => this.reactDirectly('rip'),
'0': () => this.reactDirectly('pudding'),
};
},
@ -199,10 +213,14 @@ export default Vue.extend({
methods: {
capture(withHandler = false) {
if (this.$store.getters.isSignedIn) {
this.connection.send({
const data = {
type: 'capture',
id: this.p.id
});
} as any;
if ((this.p.visibleUserIds || []).includes(this.$store.state.i.id) || (this.p.mentions || []).includes(this.$store.state.i.id)) {
data.read = true;
}
this.connection.send(data);
if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated);
}
},
@ -230,18 +248,26 @@ export default Vue.extend({
}
},
reply() {
reply(viaKeyboard = false) {
(this as any).os.new(MkPostFormWindow, {
reply: this.p
reply: this.p,
animation: !viaKeyboard
}).$once('closed', this.focus);
},
renote() {
renote(viaKeyboard = false) {
(this as any).os.new(MkRenoteFormWindow, {
note: this.p
note: this.p,
animation: !viaKeyboard
}).$once('closed', this.focus);
},
renoteDirectly() {
(this as any).api('notes/create', {
renoteId: this.p.id
});
},
react(viaKeyboard = false) {
this.blur();
(this as any).os.new(MkReactionPicker, {
@ -252,13 +278,25 @@ export default Vue.extend({
}).$once('closed', this.focus);
},
menu() {
reactDirectly(reaction) {
(this as any).api('notes/reactions/create', {
noteId: this.p.id,
reaction: reaction
});
},
menu(viaKeyboard = false) {
(this as any).os.new(MkNoteMenu, {
source: this.$refs.menuButton,
note: this.p
note: this.p,
animation: !viaKeyboard
}).$once('closed', this.focus);
},
toggleShowContent() {
this.showContent = !this.showContent;
},
focus() {
this.$el.focus();
},

View File

@ -10,7 +10,7 @@
</div>
<!-- トランジションを有効にするとなぜかメモリリークする -->
<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="notes transition" tag="div">
<component :is="!$store.state.device.reduceMotion ? 'transition-group' : 'div'" name="mk-notes" class="notes transition" tag="div" ref="notes">
<template v-for="(note, i) in _notes">
<x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" ref="note"/>
<p class="date" :key="note.id + '_date'" v-if="i != notes.length - 1 && note._date != _notes[i + 1]._date">
@ -89,7 +89,7 @@ export default Vue.extend({
},
focus() {
(this.$refs.note as any)[0].focus();
(this.$refs.notes as any).children[0].focus ? (this.$refs.notes as any).children[0].focus() : (this.$refs.notes as any).$el.children[0].focus();
},
onNoteUpdated(i, note) {

View File

@ -1,5 +1,5 @@
<template>
<mk-window class="mk-post-form-window" ref="window" is-modal @closed="onWindowClosed">
<mk-window class="mk-post-form-window" ref="window" is-modal @closed="onWindowClosed" :animation="animation">
<span slot="header" class="mk-post-form-window--header">
<span class="icon" v-if="geo">%fa:map-marker-alt%</span>
<span v-if="!reply">%i18n:@note%</span>
@ -25,7 +25,19 @@
import Vue from 'vue';
export default Vue.extend({
props: ['reply'],
props: {
reply: {
type: Object,
required: false
},
animation: {
type: Boolean,
required: false,
default: true
}
},
data() {
return {
uploadings: [],
@ -33,11 +45,13 @@ export default Vue.extend({
geo: null
};
},
mounted() {
this.$nextTick(() => {
(this.$refs.form as any).focus();
});
},
methods: {
onChangeUploadings(files) {
this.uploadings = files;

View File

@ -1,5 +1,5 @@
<template>
<mk-window ref="window" is-modal @closed="onWindowClosed">
<mk-window ref="window" is-modal @closed="onWindowClosed" :animation="animation">
<span slot="header" :class="$style.header">%fa:retweet%%i18n:@title%</span>
<mk-renote-form ref="form" :note="note" @posted="onPosted" @canceled="onCanceled" v-hotkey.global="keymap"/>
</mk-window>
@ -9,13 +9,25 @@
import Vue from 'vue';
export default Vue.extend({
props: ['note'],
props: {
note: {
type: Object,
required: true
},
animation: {
type: Boolean,
required: false,
default: true
}
},
computed: {
keymap(): any {
return {
'esc': this.close,
'ctrl+enter': this.post
'enter': this.post,
'q': this.quote,
};
}
},
@ -24,6 +36,9 @@ export default Vue.extend({
post() {
(this.$refs.form as any).ok();
},
quote() {
(this.$refs.form as any).onQuote();
},
close() {
(this.$refs.window as any).close();
},

View File

@ -8,8 +8,8 @@
<span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl">%fa:hashtag% {{ tagTl.title }}</span>
<span :data-active="src == 'list'" @click="src = 'list'" v-if="list">%fa:list% {{ list.title }}</span>
<div class="buttons">
<button :data-active="src == 'mentions'" @click="src = 'mentions'" title="%i18n:@mentions%">%fa:at%</button>
<button :data-active="src == 'messages'" @click="src = 'messages'" title="%i18n:@messages%">%fa:envelope R%</button>
<button :data-active="src == 'mentions'" @click="src = 'mentions'" title="%i18n:@mentions%">%fa:at%<i class="badge" v-if="$store.state.i.hasUnreadMentions">%fa:circle%</i></button>
<button :data-active="src == 'messages'" @click="src = 'messages'" title="%i18n:@messages%">%fa:envelope R%<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i></button>
<button @click="chooseTag" title="%i18n:@hashtag%" ref="tagButton">%fa:hashtag%</button>
<button @click="chooseList" title="%i18n:@list%" ref="listButton">%fa:list%</button>
</div>
@ -202,6 +202,13 @@ root(isDark)
line-height 42px
color isDark ? #9baec8 : #ccc
> .badge
position absolute
top -4px
right 4px
font-size 10px
color $theme-color
&:hover
color isDark ? #b2c1d5 : #aaa

View File

@ -1,5 +1,5 @@
<template>
<div class="account">
<div class="account" v-hotkey.global="keymap">
<button class="header" :data-active="isOpen" @click="toggle">
<span class="username">{{ $store.state.i.username }}<template v-if="!isOpen">%fa:angle-down%</template><template v-if="isOpen">%fa:angle-up%</template></span>
<mk-avatar class="avatar" :user="$store.state.i"/>
@ -63,6 +63,13 @@ export default Vue.extend({
isOpen: false
};
},
computed: {
keymap(): any {
return {
'a|m': this.toggle
};
}
},
beforeDestroy() {
this.close();
},

View File

@ -1,5 +1,5 @@
<template>
<div class="notifications">
<div class="notifications" v-hotkey.global="keymap">
<button :data-active="isOpen" @click="toggle" title="%i18n:@title%">
%fa:R bell%<template v-if="hasUnreadNotification">%fa:circle%</template>
</button>
@ -19,11 +19,19 @@ export default Vue.extend({
isOpen: false
};
},
computed: {
hasUnreadNotification(): boolean {
return this.$store.getters.isSignedIn && this.$store.state.i.hasUnreadNotification;
},
keymap(): any {
return {
'shift+n': this.toggle
};
}
},
methods: {
toggle() {
this.isOpen ? this.close() : this.open();

View File

@ -76,6 +76,11 @@ export default Vue.extend({
name: {
type: String,
default: null
},
animation: {
type: Boolean,
required: false,
default: true
}
},
@ -142,7 +147,7 @@ export default Vue.extend({
anime({
targets: bg,
opacity: 1,
duration: 100,
duration: this.animation ? 100 : 0,
easing: 'linear'
});
}
@ -152,7 +157,7 @@ export default Vue.extend({
targets: main,
opacity: 1,
scale: [1.1, 1],
duration: 200,
duration: this.animation ? 200 : 0,
easing: 'easeOutQuad'
});
@ -160,7 +165,7 @@ export default Vue.extend({
setTimeout(() => {
this.$emit('opened');
}, 300);
}, this.animation ? 300 : 0);
},
close() {
@ -174,7 +179,7 @@ export default Vue.extend({
anime({
targets: bg,
opacity: 0,
duration: 300,
duration: this.animation ? 300 : 0,
easing: 'linear'
});
}
@ -185,14 +190,14 @@ export default Vue.extend({
targets: main,
opacity: 0,
scale: 0.8,
duration: 300,
duration: this.animation ? 300 : 0,
easing: [0.5, -0.5, 1, 0.5]
});
setTimeout(() => {
this.$emit('closed');
this.destroyDom();
}, 300);
}, this.animation ? 300 : 0);
},
popout() {

View File

@ -14,6 +14,14 @@
</div>
<div class="form">
<div>
<label>
<p>%i18n:@banner-url%</p>
<input v-model="bannerUrl">
</label>
<button class="ui" @click="updateMeta">%i18n:@save%</button>
</div>
<div>
<label>
<input type="checkbox" v-model="disableRegistration" @change="updateMeta">
@ -46,6 +54,7 @@ export default Vue.extend({
stats: null,
disableRegistration: false,
disableLocalTimeline: false,
bannerUrl: null,
inviteCode: null,
connection: null,
connectionId: null
@ -58,6 +67,7 @@ export default Vue.extend({
(this as any).os.getMeta().then(meta => {
this.disableRegistration = meta.disableRegistration;
this.disableLocalTimeline = meta.disableLocalTimeline;
this.bannerUrl = meta.bannerUrl;
});
(this as any).api('stats').then(stats => {
@ -76,7 +86,8 @@ export default Vue.extend({
updateMeta() {
(this as any).api('admin/update-meta', {
disableRegistration: this.disableRegistration,
disableLocalTimeline: this.disableLocalTimeline
disableLocalTimeline: this.disableLocalTimeline,
bannerUrl: this.bannerUrl
});
}
}
@ -114,6 +125,7 @@ export default Vue.extend({
> .form
> div
padding 16px
border-bottom solid 1px #eee
</style>

View File

@ -147,10 +147,14 @@ export default Vue.extend({
methods: {
capture(withHandler = false) {
if (this.$store.getters.isSignedIn) {
this.connection.send({
const data = {
type: 'capture',
id: this.p.id
});
} as any;
if ((this.p.visibleUserIds || []).includes(this.$store.state.i.id) || (this.p.mentions || []).includes(this.$store.state.i.id)) {
data.read = true;
}
this.connection.send(data);
if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated);
}
},

View File

@ -1,5 +1,7 @@
<template>
<div class="mk-welcome">
<div class="banner" :style="{ backgroundImage: banner ? `url(${banner})` : null }"></div>
<button @click="dark">
<template v-if="$store.state.device.darkmode">%fa:moon%</template>
<template v-else>%fa:R moon%</template>
@ -154,6 +156,7 @@ export default Vue.extend({
return {
meta: null,
stats: null,
banner: null,
copyright,
host,
name: 'Misskey',
@ -169,6 +172,7 @@ export default Vue.extend({
this.name = meta.name;
this.description = meta.description;
this.announcements = meta.broadcasts;
this.banner = meta.bannerUrl;
});
(this as any).api('stats').then(stats => {
@ -308,6 +312,26 @@ root(isDark)
//background-position center
//background-size cover
> .banner
position absolute
top 0
left 0
width 100%
height 400px
background-position center
background-size cover
opacity 0.7
&:after
content ""
display block
position absolute
bottom 0
left 0
width 100%
height 100px
background linear-gradient(transparent, isDark ? #191b22 : #f7f7f7)
> .forkit
position absolute
top 0

View File

@ -160,10 +160,14 @@ export default Vue.extend({
methods: {
capture(withHandler = false) {
if (this.$store.getters.isSignedIn) {
this.connection.send({
const data = {
type: 'capture',
id: this.p.id
});
} as any;
if ((this.p.visibleUserIds || []).includes(this.$store.state.i.id) || (this.p.mentions || []).includes(this.$store.state.i.id)) {
data.read = true;
}
this.connection.send(data);
if (withHandler) this.connection.on('note-updated', this.onStreamNoteUpdated);
}
},

View File

@ -188,9 +188,6 @@ root(isDark)
overflow hidden
text-overflow ellipsis
[data-fa], [data-icon]
margin-right 4px
> img
display inline-block
vertical-align bottom

View File

@ -1,9 +1,9 @@
<template>
<mk-ui>
<span slot="header">
<template v-if="folder">%fa:R folder-open%{{ folder.name }}</template>
<template v-if="file"><mk-file-type-icon data-icon :type="file.type"/>{{ file.name }}</template>
<template v-if="!folder && !file">%fa:cloud%%i18n:@drive%</template>
<template v-if="folder"><span style="margin-right:4px;">%fa:R folder-open%</span>{{ folder.name }}</template>
<template v-if="file"><mk-file-type-icon data-icon :type="file.type" style="margin-right:4px;"/>{{ file.name }}</template>
<template v-if="!folder && !file"><span style="margin-right:4px;">%fa:cloud%</span>%i18n:@drive%</template>
</span>
<template slot="func"><button @click="fn">%fa:ellipsis-h%</button></template>
<mk-drive

View File

@ -1,6 +1,6 @@
<template>
<mk-ui>
<span slot="header">%fa:star%%i18n:@title%</span>
<span slot="header"><span style="margin-right:4px;">%fa:star%</span>%i18n:@title%</span>
<main>
<template v-for="favorite in favorites">

View File

@ -1,6 +1,6 @@
<template>
<mk-ui>
<span slot="header">%fa:gamepad%%i18n:@reversi%</span>
<span slot="header"><span style="margin-right:4px;">%fa:gamepad%</span>%i18n:@reversi%</span>
<mk-reversi :game-id="$route.params.game" @nav="nav" :self-nav="false"/>
</mk-ui>
</template>

View File

@ -1,7 +1,7 @@
<template>
<mk-ui>
<span slot="header" @click="showNav = true">
<span>
<span :class="$style.title">
<span v-if="src == 'home'">%fa:home%%i18n:@home%</span>
<span v-if="src == 'local'">%fa:R comments%%i18n:@local%</span>
<span v-if="src == 'hybrid'">%fa:share-alt%%i18n:@hybrid%</span>
@ -15,6 +15,7 @@
<template v-if="!showNav">%fa:angle-down%</template>
<template v-else>%fa:angle-up%</template>
</span>
<i :class="$style.badge" v-if="$store.state.i.hasUnreadMentions || $store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i>
</span>
<template slot="func">
@ -32,10 +33,10 @@
<span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline">%fa:share-alt% %i18n:@hybrid%</span>
<span :data-active="src == 'global'" @click="src = 'global'">%fa:globe% %i18n:@global%</span>
<div class="hr"></div>
<span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%</span>
<span :data-active="src == 'messages'" @click="src = 'messages'">%fa:envelope R% %i18n:@messages%</span>
<span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%<i class="badge" v-if="$store.state.i.hasUnreadMentions">%fa:circle%</i></span>
<span :data-active="src == 'messages'" @click="src = 'messages'">%fa:envelope R% %i18n:@messages%<i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes">%fa:circle%</i></span>
<template v-if="lists">
<div class="hr"></div>
<div class="hr" v-if="lists.length > 0"></div>
<span v-for="l in lists" :data-active="src == 'list' && list == l" @click="src = 'list'; list = l" :key="l.id">%fa:list% {{ l.title }}</span>
</template>
<div class="hr" v-if="$store.state.settings.tagTimelines && $store.state.settings.tagTimelines.length > 0"></div>
@ -220,6 +221,11 @@ root(isDark)
&:not([data-active]):hover
background isDark ? #353e4a : #eee
> .badge
margin-left 6px
font-size 10px
color $theme-color
> .tl
max-width 680px
margin 0 auto
@ -238,3 +244,18 @@ main:not([data-darkmode])
root(false)
</style>
<style lang="stylus" module>
@import '~const.styl'
.title
i
margin-right 4px
.badge
margin-left 6px
font-size 10px
color $theme-color
vertical-align middle
</style>

View File

@ -1,7 +1,7 @@
<template>
<mk-ui>
<span slot="header">
<template v-if="user">%fa:R comments%{{ user | userName }}</template>
<template v-if="user"><span style="margin-right:4px;">%fa:R comments%</span>{{ user | userName }}</template>
<template v-else><mk-ellipsis/></template>
</span>
<mk-messaging-room v-if="!fetching" :user="user" :is-naked="true"/>

View File

@ -1,6 +1,6 @@
<template>
<mk-ui>
<span slot="header">%fa:R comments%%i18n:@messaging%</span>
<span slot="header"><span style="margin-right:4px;">%fa:R comments%</span>%i18n:@messaging%</span>
<mk-messaging @navigate="navigate" :header-top="48"/>
</mk-ui>
</template>

View File

@ -1,6 +1,6 @@
<template>
<mk-ui>
<span slot="header">%fa:R sticky-note%%i18n:@title%</span>
<span slot="header"><span style="margin-right:4px;">%fa:R sticky-note%</span>%i18n:@title%</span>
<main v-if="!fetching">
<div>
<mk-note-detail :note="note"/>

View File

@ -1,6 +1,6 @@
<template>
<mk-ui>
<span slot="header">%fa:R bell%%i18n:@notifications%</span>
<span slot="header"><span style="margin-right:4px;">%fa:R bell%</span>%i18n:@notifications%</span>
<template slot="func"><button @click="fn">%fa:check%</button></template>
<main>

View File

@ -1,6 +1,6 @@
<template>
<mk-ui>
<span slot="header">%fa:cog%%i18n:@settings%</span>
<span slot="header"><span style="margin-right:4px;">%fa:cog%</span>%i18n:@settings%</span>
<main :data-darkmode="$store.state.device.darkmode">
<div class="signin-as" v-html="'%i18n:@signed-in-as%'.replace('{}', `<b>${name}</b>`)"></div>

View File

@ -1,6 +1,6 @@
<template>
<mk-ui>
<span slot="header">%fa:hashtag%{{ $route.params.tag }}</span>
<span slot="header"><span style="margin-right:4px;">%fa:hashtag%</span>{{ $route.params.tag }}</span>
<main>
<p v-if="!fetching && empty">%fa:search% {{ '%i18n:no-posts-found%'.split('{}')[0] }}{{ q }}{{ '%i18n:no-posts-found%'.split('{}')[1] }}</p>

View File

@ -1,5 +1,7 @@
<template>
<div class="wgwfgvvimdjvhjfwxropcwksnzftjqes">
<div class="banner" :style="{ backgroundImage: banner ? `url(${banner})` : null }"></div>
<div>
<img :src="$store.state.device.darkmode ? 'assets/title.dark.svg' : 'assets/title.light.svg'" :alt="name">
<p class="host">{{ host }}</p>
@ -80,6 +82,7 @@ export default Vue.extend({
meta: null,
copyright,
stats: null,
banner: null,
host,
name: 'Misskey',
description: '',
@ -93,6 +96,7 @@ export default Vue.extend({
this.name = meta.name;
this.description = meta.description;
this.announcements = meta.broadcasts;
this.banner = meta.bannerUrl;
});
(this as any).api('stats').then(stats => {
@ -121,7 +125,27 @@ root(isDark)
text-align center
//background #fff
> div
> .banner
position absolute
top 0
left 0
width 100%
height 300px
background-position center
background-size cover
opacity 0.7
&:after
content ""
display block
position absolute
bottom 0
left 0
width 100%
height 100px
background linear-gradient(transparent, isDark ? #191b22 : #f7f7f7)
> div:not(.banner)
padding 32px
margin 0 auto
max-width 500px

View File

@ -1,6 +1,6 @@
<template>
<mk-ui>
<span slot="header">%fa:home%%i18n:@dashboard%</span>
<span slot="header"><span style="margin-right:4px;">%fa:home%</span>%i18n:@dashboard%</span>
<template slot="func">
<button @click="customizing = !customizing">%fa:cog%</button>
</template>

View File

@ -0,0 +1,96 @@
# Misskeyキーボードショートカットまとめ
## グローバル
これらのショートカットは基本的にどこでも使えます。
<table>
<thead>
<tr><th>ショートカット</th><th>効果</th><th>由来</th></tr>
</thead>
<tbody>
<tr><td><kbd class="key">P</kbd>, <kbd class="key">N</kbd></td><td>新規投稿</td><td><b>P</b>ost, <b>N</b>ew, <b>N</b>ote</td></tr>
<tr><td><kbd class="key">T</kbd></td><td>タイムラインの最も新しい投稿にフォーカス</td><td><b>T</b>imeline, <b>T</b>op</td></tr>
<tr><td><kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">N</kbd></kbd></td><td>通知を表示/隠す</td><td><b>N</b>otifications</td></tr>
<tr><td><kbd class="key">A</kbd>, <kbd class="key">M</kbd></td><td>アカウントメニューを表示/隠す</td><td><b>A</b>ccount, <b>M</b>y, <b>M</b>e, <b>M</b>enu</td></tr>
<tr><td><kbd class="key">Z</kbd></td><td>上部のバーを隠す</td><td><b>Z</b>en</td></tr>
<tr><td><kbd class="key">H</kbd>, <kbd class="key">?</kbd></td><td>ヘルプを表示</td><td><b>H</b>elp</td></tr>
</tbody>
</table>
## 投稿にフォーカスされた状態
<table>
<thead>
<tr><th>ショートカット</th><th>効果</th><th>由来</th></tr>
</thead>
<tbody>
<tr><td><kbd class="key"></kbd>, <kbd class="key">K</kbd>, <kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">Tab</kbd></kbd></td><td>上の投稿にフォーカスを移動</td><td>-</td></tr>
<tr><td><kbd class="key"></kbd>, <kbd class="key">J</kbd>, <kbd class="key">Tab</kbd></td><td>下の投稿にフォーカスを移動</td><td>-</td></tr>
<tr><td><kbd class="key"></kbd>, <kbd class="key">R</kbd></td><td>返信フォームを開く</td><td><b>R</b>eply</td></tr>
<tr><td><kbd class="key"></kbd>, <kbd class="key">Q</kbd></td><td>Renoteフォームを開く</td><td><b>Q</b>uote</td></tr>
<tr><td><kbd class="group"><kbd class="key">Ctrl</kbd> + <kbd class="key"></kbd></kbd>, <kbd class="group"><kbd class="key">Ctrl</kbd> + <kbd class="key">Q</kbd></kbd></td><td>即刻Renoteする(フォームを開かずに)</td><td>-</td></tr>
<tr><td><kbd class="key">E</kbd>, <kbd class="key">A</kbd>, <kbd class="key">+</kbd></td><td>リアクションフォームを開く</td><td><b>E</b>mote, re<b>A</b>ction</td></tr>
<tr><td><kbd class="key">0</kbd>~<kbd class="key">9</kbd></td><td>数字に対応したリアクションをする(対応については後述)</td><td>-</td></tr>
<tr><td><kbd class="key">M</kbd>, <kbd class="key">O</kbd></td><td>投稿に対するメニューを開く</td><td><b>M</b>ore, <b>O</b>ther</td></tr>
<tr><td><kbd class="key">S</kbd></td><td>CWで隠された部分を表示 or 隠す</td><td><b>S</b>how, <b>S</b>ee</td></tr>
<tr><td><kbd class="key">Esc</kbd></td><td>フォーカスを外す</td><td>-</td></tr>
</tbody>
</table>
## Renoteフォーム
<table>
<thead>
<tr><th>ショートカット</th><th>効果</th><th>由来</th></tr>
</thead>
<tbody>
<tr><td><kbd class="key">Enter</kbd></td><td>Renoteする</td><td>-</td></tr>
<tr><td><kbd class="key">Q</kbd></td><td>フォームを展開する</td><td><b>Q</b>uote</td></tr>
<tr><td><kbd class="key">Esc</kbd></td><td>フォームを閉じる</td><td>-</td></tr>
</tbody>
</table>
## リアクションフォーム
デフォルトで「👍」にフォーカスが当たっている状態です。
<table>
<thead>
<tr><th>ショートカット</th><th>効果</th><th>由来</th></tr>
</thead>
<tbody>
<tr><td><kbd class="key"></kbd>, <kbd class="key">K</kbd></td><td>上のリアクションにフォーカスを移動</td><td>-</td></tr>
<tr><td><kbd class="key"></kbd>, <kbd class="key">J</kbd></td><td>下のリアクションにフォーカスを移動</td><td>-</td></tr>
<tr><td><kbd class="key"></kbd>, <kbd class="key">H</kbd>, <kbd class="group"><kbd class="key">Shift</kbd> + <kbd class="key">Tab</kbd></kbd></td><td>左のリアクションにフォーカスを移動</td><td>-</td></tr>
<tr><td><kbd class="key"></kbd>, <kbd class="key">L</kbd>, <kbd class="key">Tab</kbd></td><td>右のリアクションにフォーカスを移動</td><td>-</td></tr>
<tr><td><kbd class="key">Enter</kbd>, <kbd class="key">Space</kbd>, <kbd class="key">+</kbd></td><td>リアクション確定</td><td>-</td></tr>
<tr><td><kbd class="key">0</kbd>~<kbd class="key">9</kbd></td><td>数字に対応したリアクションで確定(対応については後述)</td><td>-</td></tr>
<tr><td><kbd class="key">Esc</kbd></td><td>リアクションするのをやめる</td><td>-</td></tr>
</tbody>
</table>
## リアクションと数字キーの対応
<table>
<thead>
<tr><th>数字キー</th><th>リアクション</th></tr>
</thead>
<tbody>
<tr><td><kbd class="key">1</kbd></td><td>👍</td></tr>
<tr><td><kbd class="key">2</kbd></td><td>❤️</td></tr>
<tr><td><kbd class="key">3</kbd></td><td>😆</td></tr>
<tr><td><kbd class="key">4</kbd></td><td>🤔</td></tr>
<tr><td><kbd class="key">5</kbd></td><td>😮</td></tr>
<tr><td><kbd class="key">6</kbd></td><td>🎉</td></tr>
<tr><td><kbd class="key">7</kbd></td><td>💢</td></tr>
<tr><td><kbd class="key">8</kbd></td><td>😥</td></tr>
<tr><td><kbd class="key">9</kbd></td><td>😇</td></tr>
<tr><td><kbd class="key">0</kbd></td><td>🍮 or 🍣</td></tr>
</tbody>
</table>
# 例
<table>
<thead>
<tr><th>ショートカット</th><th>動作</th></tr>
</thead>
<tbody>
<tr><td><kbd class="key">t</kbd><kbd class="key">+</kbd><kbd class="key">+</kbd></td><td>タイムラインの最新の投稿に👍する</td></tr>
<tr><td><kbd class="key">t</kbd><kbd class="key">1</kbd></td><td>タイムラインの最新の投稿に👍する</td></tr>
<tr><td><kbd class="key">t</kbd><kbd class="key">0</kbd></td><td>タイムラインの最新の投稿に🍮する</td></tr>
</tbody>
</table>

View File

@ -128,3 +128,24 @@ pre
> code
display block
padding 16px
kbd.group
display inline-block
padding 4px
background #fbfbfb
border 1px solid #d6d6d6
border-radius 4px
box-shadow 0 1px 1px rgba(0, 0, 0, 0.1)
kbd.key
display inline-block
padding 6px 8px
background #fff
border solid 1px #cecece
border-radius 4px
box-shadow 0 1px 1px rgba(0, 0, 0, 0.1)
td
> kbd.group,
> kbd.key
margin 4px

View File

@ -9,7 +9,7 @@ export type TextElementQuote = {
};
export default function(text: string) {
const match = text.match(/^"([\s\S]+?)\n"/);
const match = text.match(/^"([\s\S]+?)\n"/) || text.match(/^>([\s\S]+?)\n\n/) || text.match(/^\n>([\s\S]+?)\n\n/) || text.match(/^>([\s\S]+?)$/);
if (!match) return null;
const quote = match[0];
return {

View File

@ -1,13 +1,19 @@
import * as mongo from 'mongodb';
function toString(id: any) {
return mongo.ObjectID.prototype.isPrototypeOf(id) ? (id as mongo.ObjectID).toHexString() : id;
}
export default function(note: any, mutedUserIds: string[]): boolean {
if (mutedUserIds.indexOf(note.userId) != -1) {
if (mutedUserIds.includes(toString(note.userId))) {
return true;
}
if (note.reply != null && mutedUserIds.indexOf(note.reply.userId) != -1) {
if (note.reply != null && mutedUserIds.includes(toString(note.reply.userId))) {
return true;
}
if (note.renote != null && mutedUserIds.indexOf(note.renote.userId) != -1) {
if (note.renote != null && mutedUserIds.includes(toString(note.renote.userId))) {
return true;
}

View File

@ -14,4 +14,5 @@ export type IMeta = {
disableRegistration?: boolean;
disableLocalTimeline?: boolean;
hidedTags?: string[];
bannerUrl?: string;
};

17
src/models/note-unread.ts Normal file
View File

@ -0,0 +1,17 @@
import * as mongo from 'mongodb';
import db from '../db/mongodb';
const NoteUnread = db.get<INoteUnread>('noteUnreads');
NoteUnread.createIndex(['userId', 'noteId'], { unique: true });
export default NoteUnread;
export interface INoteUnread {
_id: mongo.ObjectID;
noteId: mongo.ObjectID;
userId: mongo.ObjectID;
isSpecified: boolean;
_note: {
userId: mongo.ObjectID;
};
}

View File

@ -295,8 +295,8 @@ export const pack = async (
delete _note._user;
delete _note._reply;
delete _note.repost;
delete _note.mentions;
delete _note._renote;
delete _note._files;
if (_note.geo) delete _note.geo.type;
// Populate user

View File

@ -348,7 +348,8 @@ export const pack = (
me?: string | mongo.ObjectID | IUser,
options?: {
detail?: boolean,
includeSecrets?: boolean
includeSecrets?: boolean,
includeHasUnreadNotes?: boolean
}
) => new Promise<any>(async (resolve, reject) => {
@ -510,6 +511,11 @@ export const pack = (
}
}
if (!opts.includeHasUnreadNotes) {
delete _user.hasUnreadSpecifiedNotes;
delete _user.hasUnreadMentions;
}
// resolve promises in _user object
_user = await rap(_user);

View File

@ -0,0 +1,9 @@
import config from '../../../config';
import { ILocalUser } from '../../../models/user';
export default (user: ILocalUser, target: any, object: any) => ({
type: 'Add',
actor: `${config.url}/users/${user._id}`,
target,
object
});

View File

@ -4,8 +4,9 @@
* @param totalItems Total number of items
* @param first URL of first page (optional)
* @param last URL of last page (optional)
* @param orderedItems attached objects (optional)
*/
export default function(id: string, totalItems: any, first: string, last: string) {
export default function(id: string, totalItems: any, first?: string, last?: string, orderedItems?: object) {
const page: any = {
id,
type: 'OrderedCollection',
@ -14,6 +15,7 @@ export default function(id: string, totalItems: any, first: string, last: string
if (first) page.first = first;
if (last) page.last = last;
if (orderedItems) page.orderedItems = orderedItems;
return page;
}

View File

@ -21,6 +21,7 @@ export default async (user: ILocalUser) => {
outbox: `${id}/outbox`,
followers: `${id}/followers`,
following: `${id}/following`,
featured: `${id}/collections/featured`,
sharedInbox: `${config.url}/inbox`,
url: `${config.url}/@${user.username}`,
preferredUsername: user.username,

View File

@ -0,0 +1,9 @@
import config from '../../../config';
import { ILocalUser } from '../../../models/user';
export default (user: ILocalUser, target: any, object: any) => ({
type: 'Remove',
actor: `${config.url}/users/${user._id}`,
target,
object
});

View File

@ -53,6 +53,7 @@ export interface IPerson extends IObject {
publicKey: any;
followers: any;
following: any;
featured?: any;
outbox: any;
endpoints: string[];
}

View File

@ -13,6 +13,7 @@ import renderPerson from '../remote/activitypub/renderer/person';
import Outbox, { packActivity } from './activitypub/outbox';
import Followers from './activitypub/followers';
import Following from './activitypub/following';
import Featured from './activitypub/featured';
// Init router
const router = new Router();
@ -74,6 +75,7 @@ router.get('/notes/:note', async (ctx, next) => {
}
ctx.body = pack(await renderNote(note, false));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
});
@ -90,6 +92,7 @@ router.get('/notes/:note/activity', async ctx => {
}
ctx.body = pack(await packActivity(note));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
});
@ -102,6 +105,9 @@ router.get('/users/:user/followers', Followers);
// following
router.get('/users/:user/following', Following);
// featured
router.get('/users/:user/collections/featured', Featured);
// publickey
router.get('/users/:user/publickey', async ctx => {
const userId = new mongo.ObjectID(ctx.params.user);
@ -118,6 +124,7 @@ router.get('/users/:user/publickey', async ctx => {
if (isLocalUser(user)) {
ctx.body = pack(renderKey(user));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
} else {
ctx.status = 400;
@ -132,6 +139,7 @@ async function userInfo(ctx: Router.IRouterContext, user: IUser) {
}
ctx.body = pack(await renderPerson(user as ILocalUser));
ctx.set('Cache-Control', 'public, max-age=180');
setResponseType(ctx);
}

View File

@ -0,0 +1,39 @@
import * as mongo from 'mongodb';
import * as Router from 'koa-router';
import config from '../../config';
import User from '../../models/user';
import pack from '../../remote/activitypub/renderer';
import renderOrderedCollection from '../../remote/activitypub/renderer/ordered-collection';
import { setResponseType } from '../activitypub';
import Note from '../../models/note';
import renderNote from '../../remote/activitypub/renderer/note';
export default async (ctx: Router.IRouterContext) => {
const userId = new mongo.ObjectID(ctx.params.user);
// Verify user
const user = await User.findOne({
_id: userId,
host: null
});
if (user === null) {
ctx.status = 404;
return;
}
const pinnedNoteIds = user.pinnedNoteIds || [];
const pinnedNotes = await Promise.all(pinnedNoteIds.map(id => Note.findOne({ _id: id })));
const renderedNotes = await Promise.all(pinnedNotes.map(note => renderNote(note)));
const rendered = renderOrderedCollection(
`${config.url}/users/${userId}/collections/featured`,
renderedNotes.length, null, null, renderedNotes
);
ctx.body = pack(rendered);
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
setResponseType(ctx);
};

View File

@ -78,6 +78,7 @@ export default async (ctx: Router.IRouterContext) => {
// index page
const rendered = renderOrderedCollection(partOf, user.followersCount, `${partOf}?page=true`, null);
ctx.body = pack(rendered);
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
setResponseType(ctx);
}
};

View File

@ -78,6 +78,7 @@ export default async (ctx: Router.IRouterContext) => {
// index page
const rendered = renderOrderedCollection(partOf, user.followingCount, `${partOf}?page=true`, null);
ctx.body = pack(rendered);
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
setResponseType(ctx);
}
};

View File

@ -88,6 +88,7 @@ export default async (ctx: Router.IRouterContext) => {
);
ctx.body = pack(rendered);
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
setResponseType(ctx);
} else {
// index page
@ -96,6 +97,7 @@ export default async (ctx: Router.IRouterContext) => {
`${partOf}?page=true&since_id=000000000000000000000000`
);
ctx.body = pack(rendered);
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
setResponseType(ctx);
}
};

View File

@ -34,6 +34,12 @@ export const meta = {
'ja-JP': '統計などで無視するハッシュタグ'
}
}),
bannerUrl: $.str.optional.nullable.note({
desc: {
'ja-JP': 'インスタンスのバナー画像URL'
}
}),
}
};
@ -59,6 +65,10 @@ export default (params: any) => new Promise(async (res, rej) => {
set.hidedTags = ps.hidedTags;
}
if (ps.bannerUrl !== undefined) {
set.bannerUrl = ps.bannerUrl;
}
await Meta.update({}, {
$set: set
}, { upsert: true });

View File

@ -22,6 +22,7 @@ export default (params: any, user: ILocalUser, app: IApp) => new Promise(async (
// Serialize
res(await pack(user, user, {
detail: true,
includeHasUnreadNotes: true,
includeSecrets: isSecure
}));

View File

@ -1,7 +1,9 @@
import * as mongo from 'mongodb';
import $ from 'cafy'; import ID from '../../../../misc/cafy-id';
import User, { ILocalUser } from '../../../../models/user';
import Note from '../../../../models/note';
import { pack } from '../../../../models/user';
import { deliverPinnedChange } from '../../../../services/i/pin';
/**
* Pin note
@ -21,6 +23,9 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
return rej('note not found');
}
let addedId: mongo.ObjectID;
let removedId: mongo.ObjectID;
const pinnedNoteIds = user.pinnedNoteIds || [];
if (pinnedNoteIds.some(id => id.equals(note._id))) {
@ -28,9 +33,10 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
}
pinnedNoteIds.unshift(note._id);
addedId = note._id;
if (pinnedNoteIds.length > 5) {
pinnedNoteIds.pop();
removedId = pinnedNoteIds.pop();
}
await User.update(user._id, {
@ -44,6 +50,9 @@ export default async (params: any, user: ILocalUser) => new Promise(async (res,
detail: true
});
// Send Add/Remove to followers
deliverPinnedChange(user._id, removedId, addedId);
// Send response
res(iObj);
});

View File

@ -38,6 +38,7 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
driveCapacityPerLocalUserMb: config.localDriveCapacityMb,
recaptchaSitekey: config.recaptcha ? config.recaptcha.site_key : null,
swPublickey: config.sw ? config.sw.public_key : null,
hidedTags: (me && me.isAdmin) ? meta.hidedTags : undefined
hidedTags: (me && me.isAdmin) ? meta.hidedTags : undefined,
bannerUrl: meta.bannerUrl
});
});

View File

@ -21,14 +21,17 @@ export default (params: any, user: ILocalUser) => new Promise(async (res, rej) =
// Fetch note
const note = await Note.findOne({
_id: noteId,
userId: user._id
_id: noteId
});
if (note === null) {
return rej('note not found');
}
if (!user.isAdmin && !note.userId.equals(user._id)) {
return rej('access denied');
}
await deleteNote(user, note);
res();

View File

@ -9,6 +9,7 @@ import readNotification from '../common/read-notification';
import call from '../call';
import { IApp } from '../../../models/app';
import shouldMuteThisNote from '../../../misc/should-mute-this-note';
import readNote from '../../../services/note/read';
const log = debug('misskey');
@ -94,6 +95,9 @@ export default async function(
if (!msg.id) return;
log(`CAPTURE: ${msg.id} by @${user.username}`);
subscriber.on(`note-stream:${msg.id}`, onNoteStream);
if (msg.read) {
readNote(user._id, msg.id);
}
break;
case 'decapture':

View File

@ -162,8 +162,7 @@ const router = new Router();
router.get('/assets/*', async ctx => {
await send(ctx, ctx.params[0], {
root: `${__dirname}/../../docs/assets/`,
maxage: ms('7 days'),
immutable: true
maxage: ms('1 days')
});
});

61
src/services/i/pin.ts Normal file
View File

@ -0,0 +1,61 @@
import config from '../../config';
import * as mongo from 'mongodb';
import User, { isLocalUser, isRemoteUser, ILocalUser } from '../../models/user';
import Following from '../../models/following';
import renderAdd from '../../remote/activitypub/renderer/add';
import renderRemove from '../../remote/activitypub/renderer/remove';
import packAp from '../../remote/activitypub/renderer';
import { deliver } from '../../queue';
export async function deliverPinnedChange(userId: mongo.ObjectID, oldId?: mongo.ObjectID, newId?: mongo.ObjectID) {
const user = await User.findOne({
_id: userId
});
if (!isLocalUser(user)) return;
const queue = await CreateRemoteInboxes(user);
if (queue.length < 1) return;
const target = `${config.url}/users/${user._id}/collections/featured`;
if (oldId) {
const oldItem = `${config.url}/notes/${oldId}`;
const content = packAp(renderRemove(user, target, oldItem));
queue.forEach(inbox => {
deliver(user, content, inbox);
});
}
if (newId) {
const newItem = `${config.url}/notes/${newId}`;
const content = packAp(renderAdd(user, target, newItem));
queue.forEach(inbox => {
deliver(user, content, inbox);
});
}
}
/**
* ローカルユーザーのリモートフォロワーのinboxリストを作成する
* @param user ローカルユーザー
*/
async function CreateRemoteInboxes(user: ILocalUser): Promise<string[]> {
const followers = await Following.find({
followeeId: user._id
});
const queue: string[] = [];
followers.map(following => {
const follower = following._follower;
if (isRemoteUser(follower)) {
const inbox = follower.sharedInbox || follower.inbox;
if (!queue.includes(inbox)) queue.push(inbox);
}
});
return queue;
}

View File

@ -25,6 +25,7 @@ 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';
import insertNoteUnread from './unread';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -170,6 +171,17 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
// Increment notes count (user)
incNotesCountOfUser(user);
// 未読通知を作成
if (data.visibility == 'specified') {
data.visibleUsers.forEach(u => {
insertNoteUnread(u, note, true);
});
} else {
mentionedUsers.forEach(u => {
insertNoteUnread(u, note, false);
});
}
if (data.reply) {
saveReply(data.reply, note);
}
@ -314,16 +326,6 @@ async function publish(user: IUser, note: INote, noteObj: any, reply: INote, ren
publishGlobalTimelineStream(noteObj);
}
if (note.visibility == 'specified') {
visibleUsers.forEach(async (u) => {
const n = await pack(note, u, {
detail: true
});
publishUserStream(u._id, 'note', n);
publishHybridTimelineStream(u._id, n);
});
}
if (['public', 'home', 'followers'].includes(note.visibility)) {
// フォロワーに配信
publishToFollowers(note, user, noteActivity);

62
src/services/note/read.ts Normal file
View File

@ -0,0 +1,62 @@
import * as mongo from 'mongodb';
import { publishUserStream } from '../../stream';
import User from '../../models/user';
import NoteUnread from '../../models/note-unread';
/**
* Mark a note as read
*/
export default (
user: string | mongo.ObjectID,
note: string | mongo.ObjectID
) => new Promise<any>(async (resolve, reject) => {
const userId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(user)
? user as mongo.ObjectID
: new mongo.ObjectID(user);
const noteId: mongo.ObjectID = mongo.ObjectID.prototype.isPrototypeOf(note)
? note as mongo.ObjectID
: new mongo.ObjectID(note);
// Remove document
await NoteUnread.remove({
userId: userId,
noteId: noteId
});
const count1 = await NoteUnread
.count({
userId: userId,
isSpecified: false
}, {
limit: 1
});
const count2 = await NoteUnread
.count({
userId: userId,
isSpecified: true
}, {
limit: 1
});
if (count1 == 0 || count2 == 0) {
User.update({ _id: userId }, {
$set: {
hasUnreadMentions: count1 != 0 || count2 != 0,
hasUnreadSpecifiedNotes: count2 != 0
}
});
}
if (count1 == 0) {
// 全て既読になったイベントを発行
publishUserStream(userId, 'readAllUnreadMentions');
}
if (count2 == 0) {
// 全て既読になったイベントを発行
publishUserStream(userId, 'readAllUnreadSpecifiedNotes');
}
});

View File

@ -0,0 +1,47 @@
import NoteUnread from '../../models/note-unread';
import User, { IUser } from '../../models/user';
import { INote } from '../../models/note';
import Mute from '../../models/mute';
import { publishUserStream } from '../../stream';
export default async function(user: IUser, note: INote, isSpecified = false) {
const unread = await NoteUnread.insert({
noteId: note._id,
userId: user._id,
isSpecified,
_note: {
userId: note.userId
}
});
// 3秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
setTimeout(async () => {
const exist = await NoteUnread.findOne({ _id: unread._id });
if (exist == null) return;
//#region ただしミュートされているなら発行しない
const mute = await Mute.find({
muterId: user._id
});
const mutedUserIds = mute.map(m => m.muteeId.toString());
if (mutedUserIds.includes(note.userId.toString())) return;
//#endregion
User.update({
_id: user._id
}, {
$set: isSpecified ? {
hasUnreadSpecifiedNotes: true,
hasUnreadMentions: true
} : {
hasUnreadMentions: true
}
});
publishUserStream(user._id, 'unreadMention', note._id);
if (isSpecified) {
publishUserStream(user._id, 'unreadSpecifiedNote', note._id);
}
}, 3000);
}

View File

@ -87,6 +87,20 @@ describe('Text', () => {
], tokens2);
});
it('quote', () => {
const tokens1 = analyze('> foo\nbar\baz');
assert.deepEqual([
{ type: 'quote', content: '> foo\nbar\baz', quote: 'foo\nbar\baz' }
], tokens1);
const tokens2 = analyze('before\n> foo\nbar\baz\n\nafter');
assert.deepEqual([
{ type: 'text', content: 'before' },
{ type: 'quote', content: '\n> foo\nbar\baz\n\n', quote: 'foo\nbar\baz' },
{ type: 'text', content: 'after' }
], tokens2);
});
it('url', () => {
const tokens = analyze('https://himasaku.net');
assert.deepEqual([{