Compare commits

..

15 Commits

Author SHA1 Message Date
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
19 changed files with 244 additions and 45 deletions

View File

@ -1,8 +1,8 @@
{ {
"name": "misskey", "name": "misskey",
"author": "syuilo <i@syuilo.com>", "author": "syuilo <i@syuilo.com>",
"version": "8.50.0", "version": "8.53.0",
"clientVersion": "1.0.9883", "clientVersion": "1.0.9898",
"codename": "nighthike", "codename": "nighthike",
"main": "./built/index.js", "main": "./built/index.js",
"private": true, "private": true,

View File

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

View File

@ -2,9 +2,9 @@
<div class="onchrpzrvnoruiaenfcqvccjfuupzzwv"> <div class="onchrpzrvnoruiaenfcqvccjfuupzzwv">
<div class="backdrop" ref="backdrop" @click="close"></div> <div class="backdrop" ref="backdrop" @click="close"></div>
<div class="popover" :class="{ hukidasi }" ref="popover"> <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> <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> </template>
</div> </div>
</div> </div>

View File

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

View File

@ -40,18 +40,18 @@
</div> </div>
<footer> <footer>
<mk-reactions-viewer :note="p" ref="reactionsViewer"/> <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-if="p.reply">%fa:reply-all%</template>
<template v-else>%fa:reply%</template> <template v-else>%fa:reply%</template>
<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> <p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p>
</button> </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> %fa:retweet%<p class="count" v-if="p.renoteCount > 0">{{ p.renoteCount }}</p>
</button> </button>
<button class="reactionButton" :class="{ reacted: p.myReaction != null }" @click="react()" ref="reactButton" title="%i18n:@add-reaction%"> <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> %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p>
</button> </button>
<button @click="menu" ref="menuButton"> <button @click="menu()" ref="menuButton">
%fa:ellipsis-h% %fa:ellipsis-h%
</button> </button>
<!-- <button title="%i18n:@detail"> <!-- <button title="%i18n:@detail">
@ -113,11 +113,23 @@ export default Vue.extend({
computed: { computed: {
keymap(): any { keymap(): any {
return { return {
'r|left': this.reply, 'r|left': () => this.reply(true),
'a|plus': () => this.react(true), 'e|a|plus': () => this.react(true),
'n|right': this.renote, 'q|right': () => this.renote(true),
'up|shift+tab': this.focusBefore, 'ctrl+q|ctrl+right': this.renoteDirectly,
'down|tab': this.focusAfter, 'up|k|shift+tab': this.focusBefore,
'down|j|tab': this.focusAfter,
'm|o': () => this.menu(true),
'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'),
}; };
}, },
@ -230,18 +242,26 @@ export default Vue.extend({
} }
}, },
reply() { reply(viaKeyboard = false) {
(this as any).os.new(MkPostFormWindow, { (this as any).os.new(MkPostFormWindow, {
reply: this.p reply: this.p,
animation: !viaKeyboard
}).$once('closed', this.focus); }).$once('closed', this.focus);
}, },
renote() { renote(viaKeyboard = false) {
(this as any).os.new(MkRenoteFormWindow, { (this as any).os.new(MkRenoteFormWindow, {
note: this.p note: this.p,
animation: !viaKeyboard
}).$once('closed', this.focus); }).$once('closed', this.focus);
}, },
renoteDirectly() {
(this as any).api('notes/create', {
renoteId: this.p.id
});
},
react(viaKeyboard = false) { react(viaKeyboard = false) {
this.blur(); this.blur();
(this as any).os.new(MkReactionPicker, { (this as any).os.new(MkReactionPicker, {
@ -252,10 +272,18 @@ export default Vue.extend({
}).$once('closed', this.focus); }).$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, { (this as any).os.new(MkNoteMenu, {
source: this.$refs.menuButton, source: this.$refs.menuButton,
note: this.p note: this.p,
animation: !viaKeyboard
}).$once('closed', this.focus); }).$once('closed', this.focus);
}, },

View File

@ -10,7 +10,7 @@
</div> </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"> <template v-for="(note, i) in _notes">
<x-note :note="note" :key="note.id" @update:note="onNoteUpdated(i, $event)" ref="note"/> <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"> <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() { 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) { onNoteUpdated(i, note) {

View File

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

View File

@ -1,5 +1,5 @@
<template> <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> <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-renote-form ref="form" :note="note" @posted="onPosted" @canceled="onCanceled" v-hotkey.global="keymap"/>
</mk-window> </mk-window>
@ -9,13 +9,25 @@
import Vue from 'vue'; import Vue from 'vue';
export default Vue.extend({ export default Vue.extend({
props: ['note'], props: {
note: {
type: Object,
required: true
},
animation: {
type: Boolean,
required: false,
default: true
}
},
computed: { computed: {
keymap(): any { keymap(): any {
return { return {
'esc': this.close, 'esc': this.close,
'ctrl+enter': this.post 'enter': this.post,
'q': this.quote,
}; };
} }
}, },
@ -24,6 +36,9 @@ export default Vue.extend({
post() { post() {
(this.$refs.form as any).ok(); (this.$refs.form as any).ok();
}, },
quote() {
(this.$refs.form as any).onQuote();
},
close() { close() {
(this.$refs.window as any).close(); (this.$refs.window as any).close();
}, },

View File

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

View File

@ -35,7 +35,7 @@
<span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%</span> <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 == 'messages'" @click="src = 'messages'">%fa:envelope R% %i18n:@messages%</span>
<template v-if="lists"> <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> <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> </template>
<div class="hr" v-if="$store.state.settings.tagTimelines && $store.state.settings.tagTimelines.length > 0"></div> <div class="hr" v-if="$store.state.settings.tagTimelines && $store.state.settings.tagTimelines.length > 0"></div>

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 totalItems Total number of items
* @param first URL of first page (optional) * @param first URL of first page (optional)
* @param last URL of last 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 = { const page: any = {
id, id,
type: 'OrderedCollection', type: 'OrderedCollection',
@ -14,6 +15,7 @@ export default function(id: string, totalItems: any, first: string, last: string
if (first) page.first = first; if (first) page.first = first;
if (last) page.last = last; if (last) page.last = last;
if (orderedItems) page.orderedItems = orderedItems;
return page; return page;
} }

View File

@ -21,6 +21,7 @@ export default async (user: ILocalUser) => {
outbox: `${id}/outbox`, outbox: `${id}/outbox`,
followers: `${id}/followers`, followers: `${id}/followers`,
following: `${id}/following`, following: `${id}/following`,
featured: `${id}/collections/featured`,
sharedInbox: `${config.url}/inbox`, sharedInbox: `${config.url}/inbox`,
url: `${config.url}/@${user.username}`, url: `${config.url}/@${user.username}`,
preferredUsername: 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; publicKey: any;
followers: any; followers: any;
following: any; following: any;
featured?: any;
outbox: any; outbox: any;
endpoints: string[]; endpoints: string[];
} }

View File

@ -13,6 +13,7 @@ import renderPerson from '../remote/activitypub/renderer/person';
import Outbox, { packActivity } from './activitypub/outbox'; import Outbox, { packActivity } from './activitypub/outbox';
import Followers from './activitypub/followers'; import Followers from './activitypub/followers';
import Following from './activitypub/following'; import Following from './activitypub/following';
import Featured from './activitypub/featured';
// Init router // Init router
const router = new Router(); const router = new Router();
@ -102,6 +103,9 @@ router.get('/users/:user/followers', Followers);
// following // following
router.get('/users/:user/following', Following); router.get('/users/:user/following', Following);
// featured
router.get('/users/:user/collections/featured', Featured);
// publickey // publickey
router.get('/users/:user/publickey', async ctx => { router.get('/users/:user/publickey', async ctx => {
const userId = new mongo.ObjectID(ctx.params.user); const userId = new mongo.ObjectID(ctx.params.user);

View File

@ -0,0 +1,38 @@
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);
setResponseType(ctx);
};

View File

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

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;
}