Compare commits
	
		
			52 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 | ||
| 
						 | 
					0ed704d173 | ||
| 
						 | 
					87b6ef0ec5 | ||
| 
						 | 
					5184a07cf2 | ||
| 
						 | 
					dba04cc59c | ||
| 
						 | 
					f4045fb5b3 | ||
| 
						 | 
					16c36163b4 | ||
| 
						 | 
					1ac033ff18 | ||
| 
						 | 
					ccfd48232a | ||
| 
						 | 
					429bf179dc | ||
| 
						 | 
					8ba3fb13eb | ||
| 
						 | 
					11496d887e | ||
| 
						 | 
					bec48319ec | ||
| 
						 | 
					71a93b2b43 | ||
| 
						 | 
					6ed3f9e414 | ||
| 
						 | 
					dc8f592c1f | ||
| 
						 | 
					f66c31c771 | ||
| 
						 | 
					55e2ae1408 | ||
| 
						 | 
					19c72627fc | 
							
								
								
									
										16
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								package.json
									
									
									
									
									
								
							@@ -1,8 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
	"name": "misskey",
 | 
			
		||||
	"author": "syuilo <i@syuilo.com>",
 | 
			
		||||
	"version": "8.48.0",
 | 
			
		||||
	"clientVersion": "1.0.9876",
 | 
			
		||||
	"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>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,28 +1,46 @@
 | 
			
		||||
import keyCode from './keycode';
 | 
			
		||||
import { concat } from '../../../prelude/array';
 | 
			
		||||
 | 
			
		||||
const getKeyMap = keymap => Object.keys(keymap).map(input => {
 | 
			
		||||
	const result = {} as any;
 | 
			
		||||
type pattern = {
 | 
			
		||||
	which: string[];
 | 
			
		||||
	ctrl?: boolean;
 | 
			
		||||
	shift?: boolean;
 | 
			
		||||
	alt?: boolean;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
	const { keyup, keydown } = keymap[input];
 | 
			
		||||
type action = {
 | 
			
		||||
	patterns: pattern[];
 | 
			
		||||
 | 
			
		||||
	input.split('+').forEach(keyName => {
 | 
			
		||||
		switch (keyName.toLowerCase()) {
 | 
			
		||||
			case 'ctrl':
 | 
			
		||||
			case 'alt':
 | 
			
		||||
			case 'shift':
 | 
			
		||||
			case 'meta':
 | 
			
		||||
				result[keyName] = true;
 | 
			
		||||
				break;
 | 
			
		||||
			default:
 | 
			
		||||
				result.keyCode = keyCode(keyName);
 | 
			
		||||
		}
 | 
			
		||||
	callback: Function;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getKeyMap = keymap => Object.entries(keymap).map(([patterns, callback]): action => {
 | 
			
		||||
	const result = {
 | 
			
		||||
		patterns: [],
 | 
			
		||||
		callback: callback
 | 
			
		||||
	} as action;
 | 
			
		||||
 | 
			
		||||
	result.patterns = patterns.split('|').map(part => {
 | 
			
		||||
		const pattern = {
 | 
			
		||||
			which: [],
 | 
			
		||||
			ctrl: false,
 | 
			
		||||
			alt: false,
 | 
			
		||||
			shift: false
 | 
			
		||||
		} as pattern;
 | 
			
		||||
 | 
			
		||||
		part.trim().split('+').forEach(key => {
 | 
			
		||||
			key = key.trim().toLowerCase();
 | 
			
		||||
			switch (key) {
 | 
			
		||||
				case 'ctrl': pattern.ctrl = true; break;
 | 
			
		||||
				case 'alt': pattern.alt = true; break;
 | 
			
		||||
				case 'shift': pattern.shift = true; break;
 | 
			
		||||
				default: pattern.which = keyCode(key).map(k => k.toLowerCase());
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		return pattern;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	result.callback = {
 | 
			
		||||
		keydown: keydown || keymap[input],
 | 
			
		||||
		keyup
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return result;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -34,28 +52,40 @@ export default {
 | 
			
		||||
			bind(el, binding) {
 | 
			
		||||
				el._hotkey_global = binding.modifiers.global === true;
 | 
			
		||||
 | 
			
		||||
				el._keymap = getKeyMap(binding.value);
 | 
			
		||||
				const actions = getKeyMap(binding.value);
 | 
			
		||||
 | 
			
		||||
				el.dataset.reservedKeyCodes = el._keymap.map(key => `'${key.keyCode}'`).join(' ');
 | 
			
		||||
				// flatten
 | 
			
		||||
				const reservedKeys = concat(concat(actions.map(a => a.patterns.map(p => p.which))));
 | 
			
		||||
 | 
			
		||||
				el.dataset.reservedKeys = reservedKeys.map(key => `'${key}'`).join(' ');
 | 
			
		||||
 | 
			
		||||
				el._keyHandler = e => {
 | 
			
		||||
					const reservedKeyCodes = document.activeElement ? ((document.activeElement as any).dataset || {}).reservedKeyCodes || '' : '';
 | 
			
		||||
					const key = e.code.toLowerCase();
 | 
			
		||||
 | 
			
		||||
					const targetReservedKeys = document.activeElement ? ((document.activeElement as any).dataset || {}).reservedKeys || '' : '';
 | 
			
		||||
					if (document.activeElement && ignoreElemens.some(el => document.activeElement.matches(el))) return;
 | 
			
		||||
 | 
			
		||||
					for (const hotkey of el._keymap) {
 | 
			
		||||
						if (el._hotkey_global && reservedKeyCodes.includes(`'${e.keyCode}'`)) break;
 | 
			
		||||
					for (const action of actions) {
 | 
			
		||||
						if (el._hotkey_global && targetReservedKeys.includes(`'${key}'`)) break;
 | 
			
		||||
 | 
			
		||||
						const callback = hotkey.keyCode === e.keyCode &&
 | 
			
		||||
							!!hotkey.ctrl === e.ctrlKey &&
 | 
			
		||||
							!!hotkey.alt === e.altKey &&
 | 
			
		||||
							!!hotkey.shift === e.shiftKey &&
 | 
			
		||||
							!!hotkey.meta === e.metaKey &&
 | 
			
		||||
							hotkey.callback[e.type];
 | 
			
		||||
						const matched = action.patterns.some(pattern => {
 | 
			
		||||
							const matched = pattern.which.includes(key) &&
 | 
			
		||||
								pattern.ctrl == e.ctrlKey &&
 | 
			
		||||
								pattern.shift == e.shiftKey &&
 | 
			
		||||
								pattern.alt == e.altKey;
 | 
			
		||||
 | 
			
		||||
						if (callback) {
 | 
			
		||||
							e.preventDefault();
 | 
			
		||||
							e.stopPropagation();
 | 
			
		||||
							callback(e);
 | 
			
		||||
							if (matched) {
 | 
			
		||||
								e.preventDefault();
 | 
			
		||||
								e.stopPropagation();
 | 
			
		||||
								action.callback(e);
 | 
			
		||||
								return true;
 | 
			
		||||
							} else {
 | 
			
		||||
								return false;
 | 
			
		||||
							}
 | 
			
		||||
						});
 | 
			
		||||
 | 
			
		||||
						if (matched) {
 | 
			
		||||
							break;
 | 
			
		||||
						}
 | 
			
		||||
					}
 | 
			
		||||
				};
 | 
			
		||||
 
 | 
			
		||||
@@ -1,117 +1,20 @@
 | 
			
		||||
export default searchInput => {
 | 
			
		||||
	// Keyboard Events
 | 
			
		||||
	if (searchInput && typeof searchInput === 'object') {
 | 
			
		||||
		const hasKeyCode = searchInput.which || searchInput.keyCode || searchInput.charCode;
 | 
			
		||||
		if (hasKeyCode) {
 | 
			
		||||
			searchInput = hasKeyCode;
 | 
			
		||||
		}
 | 
			
		||||
export default (input: string): string[] => {
 | 
			
		||||
	if (Object.keys(aliases).some(a => a.toLowerCase() == input.toLowerCase())) {
 | 
			
		||||
		const codes = aliases[input];
 | 
			
		||||
		return Array.isArray(codes) ? codes : [codes];
 | 
			
		||||
	} else {
 | 
			
		||||
		return [input];
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Numbers
 | 
			
		||||
	// if (typeof searchInput === 'number') {
 | 
			
		||||
	//   return names[searchInput]
 | 
			
		||||
	// }
 | 
			
		||||
 | 
			
		||||
	// Everything else (cast to string)
 | 
			
		||||
	const search = String(searchInput);
 | 
			
		||||
 | 
			
		||||
	// check codes
 | 
			
		||||
	const foundNamedKeyCodes = codes[search.toLowerCase()];
 | 
			
		||||
	if (foundNamedKeyCodes) {
 | 
			
		||||
		return foundNamedKeyCodes;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// check aliases
 | 
			
		||||
	const foundNamedKeyAliases = aliases[search.toLowerCase()];
 | 
			
		||||
	if (foundNamedKeyAliases) {
 | 
			
		||||
		return foundNamedKeyAliases;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// weird character?
 | 
			
		||||
	if (search.length === 1) {
 | 
			
		||||
		return search.charCodeAt(0);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return undefined;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Get by name
 | 
			
		||||
 *
 | 
			
		||||
 *   exports.code['enter'] // => 13
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export const codes = {
 | 
			
		||||
	'backspace': 8,
 | 
			
		||||
	'tab': 9,
 | 
			
		||||
	'enter': 13,
 | 
			
		||||
	'shift': 16,
 | 
			
		||||
	'ctrl': 17,
 | 
			
		||||
	'alt': 18,
 | 
			
		||||
	'pause/break': 19,
 | 
			
		||||
	'caps lock': 20,
 | 
			
		||||
	'esc': 27,
 | 
			
		||||
	'space': 32,
 | 
			
		||||
	'page up': 33,
 | 
			
		||||
	'page down': 34,
 | 
			
		||||
	'end': 35,
 | 
			
		||||
	'home': 36,
 | 
			
		||||
	'left': 37,
 | 
			
		||||
	'up': 38,
 | 
			
		||||
	'right': 39,
 | 
			
		||||
	'down': 40,
 | 
			
		||||
	// 'add': 43,
 | 
			
		||||
	'insert': 45,
 | 
			
		||||
	'delete': 46,
 | 
			
		||||
	'command': 91,
 | 
			
		||||
	'left command': 91,
 | 
			
		||||
	'right command': 93,
 | 
			
		||||
	'numpad *': 106,
 | 
			
		||||
	// 'numpad +': 107,
 | 
			
		||||
	'numpad +': 43,
 | 
			
		||||
	'numpad add': 43, // as a trick
 | 
			
		||||
	'numpad -': 109,
 | 
			
		||||
	'numpad .': 110,
 | 
			
		||||
	'numpad /': 111,
 | 
			
		||||
	'num lock': 144,
 | 
			
		||||
	'scroll lock': 145,
 | 
			
		||||
	'my computer': 182,
 | 
			
		||||
	'my calculator': 183,
 | 
			
		||||
	';': 186,
 | 
			
		||||
	'=': 187,
 | 
			
		||||
	',': 188,
 | 
			
		||||
	'-': 189,
 | 
			
		||||
	'.': 190,
 | 
			
		||||
	'/': 191,
 | 
			
		||||
	'`': 192,
 | 
			
		||||
	'[': 219,
 | 
			
		||||
	'\\': 220,
 | 
			
		||||
	']': 221,
 | 
			
		||||
	"'": 222
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Helper aliases
 | 
			
		||||
 | 
			
		||||
export const aliases = {
 | 
			
		||||
	'windows': 91,
 | 
			
		||||
	'⇧': 16,
 | 
			
		||||
	'⌥': 18,
 | 
			
		||||
	'⌃': 17,
 | 
			
		||||
	'⌘': 91,
 | 
			
		||||
	'ctl': 17,
 | 
			
		||||
	'control': 17,
 | 
			
		||||
	'option': 18,
 | 
			
		||||
	'pause': 19,
 | 
			
		||||
	'break': 19,
 | 
			
		||||
	'caps': 20,
 | 
			
		||||
	'return': 13,
 | 
			
		||||
	'escape': 27,
 | 
			
		||||
	'spc': 32,
 | 
			
		||||
	'pgup': 33,
 | 
			
		||||
	'pgdn': 34,
 | 
			
		||||
	'ins': 45,
 | 
			
		||||
	'del': 46,
 | 
			
		||||
	'cmd': 91
 | 
			
		||||
	'esc': 'Escape',
 | 
			
		||||
	'enter': ['Enter', 'NumpadEnter'],
 | 
			
		||||
	'up': 'ArrowUp',
 | 
			
		||||
	'down': 'ArrowDown',
 | 
			
		||||
	'left': 'ArrowLeft',
 | 
			
		||||
	'right': 'ArrowRight',
 | 
			
		||||
	'plus': ['NumpadAdd', 'Semicolon'],
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/*!
 | 
			
		||||
@@ -120,20 +23,11 @@ export const aliases = {
 | 
			
		||||
 | 
			
		||||
// lower case chars
 | 
			
		||||
for (let i = 97; i < 123; i++) {
 | 
			
		||||
	codes[String.fromCharCode(i)] = i - 32;
 | 
			
		||||
	const char = String.fromCharCode(i);
 | 
			
		||||
	aliases[char] = `Key${char.toUpperCase()}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// numbers
 | 
			
		||||
for (let i = 48; i < 58; i++) {
 | 
			
		||||
	codes[i - 48] = i;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// function keys
 | 
			
		||||
for (let i = 1; i < 13; i++) {
 | 
			
		||||
	codes['f' + i] = i + 111;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// numpad keys
 | 
			
		||||
for (let i = 0; i < 10; i++) {
 | 
			
		||||
	codes['numpad ' + i] = i + 96;
 | 
			
		||||
	aliases[i] = [`Numpad${i}`, `Digit${i}`];
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
	<div class="backdrop" ref="backdrop" @click="close"></div>
 | 
			
		||||
	<div class="popover" :class="{ compact, big }" ref="popover">
 | 
			
		||||
		<p v-if="!compact">{{ title }}</p>
 | 
			
		||||
		<div>
 | 
			
		||||
		<div ref="buttons" :class="{ showFocus }">
 | 
			
		||||
			<button @click="react('like')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="1" title="%i18n:common.reactions.like%"><mk-reaction-icon reaction='like'/></button>
 | 
			
		||||
			<button @click="react('love')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="2" title="%i18n:common.reactions.love%"><mk-reaction-icon reaction='love'/></button>
 | 
			
		||||
			<button @click="react('laugh')" @mouseover="onMouseover" @mouseout="onMouseout" tabindex="3" title="%i18n:common.reactions.laugh%"><mk-reaction-icon reaction='laugh'/></button>
 | 
			
		||||
@@ -50,18 +50,37 @@ export default Vue.extend({
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showFocus: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		animation: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: true
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			title: placeholder
 | 
			
		||||
			title: placeholder,
 | 
			
		||||
			focus: null
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	computed: {
 | 
			
		||||
		keymap(): any {
 | 
			
		||||
			return {
 | 
			
		||||
				'esc': this.close,
 | 
			
		||||
				'enter|space|plus': this.choose,
 | 
			
		||||
				'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'),
 | 
			
		||||
@@ -76,8 +95,20 @@ export default Vue.extend({
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		focus(i) {
 | 
			
		||||
			this.$refs.buttons.children[i].focus();
 | 
			
		||||
 | 
			
		||||
			if (this.showFocus) {
 | 
			
		||||
				this.title = this.$refs.buttons.children[i].title;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.$nextTick(() => {
 | 
			
		||||
			this.focus = 0;
 | 
			
		||||
 | 
			
		||||
			const popover = this.$refs.popover as any;
 | 
			
		||||
 | 
			
		||||
			const rect = this.source.getBoundingClientRect();
 | 
			
		||||
@@ -99,7 +130,7 @@ export default Vue.extend({
 | 
			
		||||
			anime({
 | 
			
		||||
				targets: this.$refs.backdrop,
 | 
			
		||||
				opacity: 1,
 | 
			
		||||
				duration: 100,
 | 
			
		||||
				duration: this.animation ? 100 : 0,
 | 
			
		||||
				easing: 'linear'
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
@@ -107,7 +138,7 @@ export default Vue.extend({
 | 
			
		||||
				targets: this.$refs.popover,
 | 
			
		||||
				opacity: 1,
 | 
			
		||||
				scale: [0.5, 1],
 | 
			
		||||
				duration: 500
 | 
			
		||||
				duration: this.animation ? 500 : 0
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
	},
 | 
			
		||||
@@ -137,7 +168,7 @@ export default Vue.extend({
 | 
			
		||||
			anime({
 | 
			
		||||
				targets: this.$refs.backdrop,
 | 
			
		||||
				opacity: 0,
 | 
			
		||||
				duration: 200,
 | 
			
		||||
				duration: this.animation ? 200 : 0,
 | 
			
		||||
				easing: 'linear'
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
@@ -146,13 +177,33 @@ export default Vue.extend({
 | 
			
		||||
				targets: this.$refs.popover,
 | 
			
		||||
				opacity: 0,
 | 
			
		||||
				scale: 0.5,
 | 
			
		||||
				duration: 200,
 | 
			
		||||
				duration: this.animation ? 200 : 0,
 | 
			
		||||
				easing: 'easeInBack',
 | 
			
		||||
				complete: () => {
 | 
			
		||||
					this.$emit('closed');
 | 
			
		||||
					this.destroyDom();
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focusUp() {
 | 
			
		||||
			this.focus = this.focus == 0 ? 9 : this.focus < 5 ? (this.focus + 4) : (this.focus - 5);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focusDown() {
 | 
			
		||||
			this.focus = this.focus == 9 ? 0 : this.focus >= 5 ? (this.focus - 4) : (this.focus + 5);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focusRight() {
 | 
			
		||||
			this.focus = this.focus == 9 ? 0 : (this.focus + 1);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focusLeft() {
 | 
			
		||||
			this.focus = this.focus == 0 ? 9 : (this.focus - 1);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		choose() {
 | 
			
		||||
			this.$refs.buttons.childNodes[this.focus].click();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
@@ -238,6 +289,21 @@ root(isDark)
 | 
			
		||||
			width 240px
 | 
			
		||||
			text-align center
 | 
			
		||||
 | 
			
		||||
			&.showFocus
 | 
			
		||||
				> button:focus
 | 
			
		||||
					z-index 1
 | 
			
		||||
 | 
			
		||||
					&:after
 | 
			
		||||
						content ""
 | 
			
		||||
						pointer-events none
 | 
			
		||||
						position absolute
 | 
			
		||||
						top 0
 | 
			
		||||
						right 0
 | 
			
		||||
						bottom 0
 | 
			
		||||
						left 0
 | 
			
		||||
						border 2px solid rgba($theme-color, 0.3)
 | 
			
		||||
						border-radius 4px
 | 
			
		||||
 | 
			
		||||
			> button
 | 
			
		||||
				padding 0
 | 
			
		||||
				width 40px
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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%">
 | 
			
		||||
				<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,13 +113,25 @@ export default Vue.extend({
 | 
			
		||||
	computed: {
 | 
			
		||||
		keymap(): any {
 | 
			
		||||
			return {
 | 
			
		||||
				'r': this.reply,
 | 
			
		||||
				'a': this.react,
 | 
			
		||||
				'n': this.renote,
 | 
			
		||||
				'up': this.focusBefore,
 | 
			
		||||
				'shift+tab': this.focusBefore,
 | 
			
		||||
				'down': this.focusAfter,
 | 
			
		||||
				'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'),
 | 
			
		||||
			};
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
@@ -201,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);
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
@@ -232,36 +248,63 @@ 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);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		react() {
 | 
			
		||||
		renoteDirectly() {
 | 
			
		||||
			(this as any).api('notes/create', {
 | 
			
		||||
				renoteId: this.p.id
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		react(viaKeyboard = false) {
 | 
			
		||||
			this.blur();
 | 
			
		||||
			(this as any).os.new(MkReactionPicker, {
 | 
			
		||||
				source: this.$refs.reactButton,
 | 
			
		||||
				note: this.p
 | 
			
		||||
				note: this.p,
 | 
			
		||||
				showFocus: viaKeyboard,
 | 
			
		||||
				animation: !viaKeyboard
 | 
			
		||||
			}).$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();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		blur() {
 | 
			
		||||
			this.$el.blur();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		focusBefore() {
 | 
			
		||||
			focus(this.$el, e => e.previousElementSibling);
 | 
			
		||||
		},
 | 
			
		||||
 
 | 
			
		||||
@@ -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) {
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
		},
 | 
			
		||||
 
 | 
			
		||||
@@ -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();
 | 
			
		||||
 
 | 
			
		||||
@@ -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() {
 | 
			
		||||
 
 | 
			
		||||
@@ -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,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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								src/remote/activitypub/renderer/add.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/remote/activitypub/renderer/add.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
});
 | 
			
		||||
@@ -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;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										9
									
								
								src/remote/activitypub/renderer/remove.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/remote/activitypub/renderer/remove.ts
									
									
									
									
									
										Normal 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
 | 
			
		||||
});
 | 
			
		||||
@@ -53,6 +53,7 @@ export interface IPerson extends IObject {
 | 
			
		||||
	publicKey: any;
 | 
			
		||||
	followers: any;
 | 
			
		||||
	following: any;
 | 
			
		||||
	featured?: any;
 | 
			
		||||
	outbox: any;
 | 
			
		||||
	endpoints: string[];
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										39
									
								
								src/server/activitypub/featured.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/server/activitypub/featured.ts
									
									
									
									
									
										Normal 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);
 | 
			
		||||
};
 | 
			
		||||
@@ -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
 | 
			
		||||
	}));
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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);
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -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')
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										61
									
								
								src/services/i/pin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								src/services/i/pin.ts
									
									
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
@@ -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