Compare commits
34 Commits
Author | SHA1 | Date | |
---|---|---|---|
382b1d2250 | |||
629693355a | |||
00a3f8d392 | |||
80b6e8090e | |||
a5f817d896 | |||
51b0244cf2 | |||
01131e2606 | |||
6283b7668e | |||
d058ecc4ea | |||
77a0450b5d | |||
1dd1b9084f | |||
6341807d02 | |||
51a1f30225 | |||
5422482696 | |||
cd7f8b080e | |||
faf29b768f | |||
7576569dc9 | |||
ea3bcbbc37 | |||
d9f0e158a3 | |||
195f676500 | |||
a9a2f4820b | |||
8414db57f0 | |||
609d68933e | |||
a23b8cebbc | |||
89f6b03cd6 | |||
7bc9de03a6 | |||
3c865d6054 | |||
fd770b008e | |||
b0d60ef2c2 | |||
7b9cea06ef | |||
30608d3e22 | |||
8bf4e55338 | |||
6ead1de383 | |||
3b628ec3c4 |
16
package.json
16
package.json
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "misskey",
|
||||
"author": "syuilo <i@syuilo.com>",
|
||||
"version": "8.52.0",
|
||||
"clientVersion": "1.0.9894",
|
||||
"version": "8.57.1",
|
||||
"clientVersion": "1.0.9928",
|
||||
"codename": "nighthike",
|
||||
"main": "./built/index.js",
|
||||
"private": true,
|
||||
@ -27,7 +27,7 @@
|
||||
"@koa/cors": "2.2.2",
|
||||
"@prezzemolo/rap": "0.1.2",
|
||||
"@prezzemolo/zip": "0.0.3",
|
||||
"@types/bcryptjs": "2.4.1",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/dateformat": "1.0.1",
|
||||
"@types/debug": "0.0.30",
|
||||
"@types/deep-equal": "1.0.1",
|
||||
@ -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,11 +217,11 @@
|
||||
"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",
|
||||
"websocket": "1.0.28",
|
||||
"ws": "6.0.0",
|
||||
"xev": "2.0.1"
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
},
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -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)
|
||||
|
@ -51,7 +51,7 @@
|
||||
<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">
|
||||
@ -114,11 +114,14 @@ export default Vue.extend({
|
||||
keymap(): any {
|
||||
return {
|
||||
'r|left': () => this.reply(true),
|
||||
'a|plus': () => this.react(true),
|
||||
'ctrl+q|ctrl+right': this.renoteDirectly,
|
||||
'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'),
|
||||
@ -210,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);
|
||||
}
|
||||
},
|
||||
@ -278,13 +285,18 @@ export default Vue.extend({
|
||||
});
|
||||
},
|
||||
|
||||
menu() {
|
||||
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();
|
||||
},
|
||||
|
@ -28,7 +28,7 @@ export default Vue.extend({
|
||||
props: {
|
||||
reply: {
|
||||
type: Object,
|
||||
required: true
|
||||
required: false
|
||||
},
|
||||
|
||||
animation: {
|
||||
|
@ -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
|
||||
|
||||
|
@ -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();
|
||||
},
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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,8 +33,8 @@
|
||||
<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" 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>
|
||||
@ -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>
|
||||
|
@ -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"/>
|
||||
|
@ -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>
|
||||
|
@ -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"/>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
96
src/docs/keyboard-shortcut.ja-JP.md
Normal file
96
src/docs/keyboard-shortcut.ja-JP.md
Normal 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>
|
@ -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
|
||||
|
@ -8,13 +8,20 @@ export type TextElementQuote = {
|
||||
quote: string
|
||||
};
|
||||
|
||||
export default function(text: string) {
|
||||
const match = text.match(/^"([\s\S]+?)\n"/);
|
||||
export default function(text: string, index: number) {
|
||||
const match = text.match(/^"([\s\S]+?)\n"/) || text.match(/^\n>([\s\S]+?)(\n\n|$)/) ||
|
||||
(index == 0 ? text.match(/^>([\s\S]+?)(\n\n|$)/) : null);
|
||||
|
||||
if (!match) return null;
|
||||
const quote = match[0];
|
||||
|
||||
const quote = match[1]
|
||||
.split('\n')
|
||||
.map(line => line.replace(/^>+/g, '').trim())
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
type: 'quote',
|
||||
content: quote,
|
||||
quote: match[1].trim(),
|
||||
content: match[0],
|
||||
quote: quote,
|
||||
} as TextElementQuote;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -14,4 +14,5 @@ export type IMeta = {
|
||||
disableRegistration?: boolean;
|
||||
disableLocalTimeline?: boolean;
|
||||
hidedTags?: string[];
|
||||
bannerUrl?: string;
|
||||
};
|
||||
|
17
src/models/note-unread.ts
Normal file
17
src/models/note-unread.ts
Normal 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;
|
||||
};
|
||||
}
|
@ -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
|
||||
|
@ -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);
|
||||
|
||||
|
@ -75,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);
|
||||
});
|
||||
|
||||
@ -91,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);
|
||||
});
|
||||
|
||||
@ -122,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;
|
||||
@ -136,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);
|
||||
}
|
||||
|
||||
|
@ -34,5 +34,6 @@ export default async (ctx: Router.IRouterContext) => {
|
||||
);
|
||||
|
||||
ctx.body = pack(rendered);
|
||||
ctx.set('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
setResponseType(ctx);
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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 });
|
||||
|
@ -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
|
||||
}));
|
||||
|
||||
|
@ -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
|
||||
});
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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':
|
||||
|
@ -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')
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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
62
src/services/note/read.ts
Normal 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');
|
||||
}
|
||||
});
|
47
src/services/note/unread.ts
Normal file
47
src/services/note/unread.ts
Normal 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) {
|
||||
//#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
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
}
|
24
test/mfm.ts
24
test/mfm.ts
@ -87,6 +87,30 @@ describe('Text', () => {
|
||||
], tokens2);
|
||||
});
|
||||
|
||||
it('quote', () => {
|
||||
const tokens1 = analyze('> foo\nbar\nbaz');
|
||||
assert.deepEqual([
|
||||
{ type: 'quote', content: '> foo\nbar\nbaz', quote: 'foo\nbar\nbaz' }
|
||||
], tokens1);
|
||||
|
||||
const tokens2 = analyze('before\n> foo\nbar\nbaz\n\nafter');
|
||||
assert.deepEqual([
|
||||
{ type: 'text', content: 'before' },
|
||||
{ type: 'quote', content: '\n> foo\nbar\nbaz\n\n', quote: 'foo\nbar\nbaz' },
|
||||
{ type: 'text', content: 'after' }
|
||||
], tokens2);
|
||||
|
||||
const tokens3 = analyze('piyo> foo\nbar\nbaz');
|
||||
assert.deepEqual([
|
||||
{ type: 'text', content: 'piyo> foo\nbar\nbaz' }
|
||||
], tokens3);
|
||||
|
||||
const tokens4 = analyze('> foo\n> bar\n> baz');
|
||||
assert.deepEqual([
|
||||
{ type: 'quote', content: '> foo\n> bar\n> baz', quote: 'foo\nbar\nbaz' }
|
||||
], tokens4);
|
||||
});
|
||||
|
||||
it('url', () => {
|
||||
const tokens = analyze('https://himasaku.net');
|
||||
assert.deepEqual([{
|
||||
|
Reference in New Issue
Block a user