ハッシュタグタイムラインを実装
This commit is contained in:
		
							parent
							
								
									433dbe179d
								
							
						
					
					
						commit
						109738ccb9
					
				
					 19 changed files with 555 additions and 92 deletions
				
			
		| 
						 | 
				
			
			@ -166,6 +166,7 @@ common:
 | 
			
		|||
    home: "ホーム"
 | 
			
		||||
    local: "ローカル"
 | 
			
		||||
    hybrid: "ソーシャル"
 | 
			
		||||
    hashtag: "ハッシュタグ"
 | 
			
		||||
    global: "グローバル"
 | 
			
		||||
    mentions: "あなた宛て"
 | 
			
		||||
    notifications: "通知"
 | 
			
		||||
| 
						 | 
				
			
			@ -916,6 +917,10 @@ desktop/views/components/timeline.vue:
 | 
			
		|||
  global: "グローバル"
 | 
			
		||||
  mentions: "あなた宛て"
 | 
			
		||||
  list: "リスト"
 | 
			
		||||
  hashtag: "ハッシュタグ"
 | 
			
		||||
  add-tag-timeline: "ハッシュタグを追加"
 | 
			
		||||
  add-list: "リストを追加"
 | 
			
		||||
  list-name: "リスト名"
 | 
			
		||||
 | 
			
		||||
desktop/views/components/ui.header.vue:
 | 
			
		||||
  welcome-back: "おかえりなさい、"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										13
									
								
								src/client/app/common/scripts/streaming/hashtag.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/client/app/common/scripts/streaming/hashtag.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
import Stream from './stream';
 | 
			
		||||
import MiOS from '../../../mios';
 | 
			
		||||
 | 
			
		||||
export class HashtagStream extends Stream {
 | 
			
		||||
	constructor(os: MiOS, me, q) {
 | 
			
		||||
		super(os, 'hashtag', me ? {
 | 
			
		||||
			i: me.token,
 | 
			
		||||
			q: JSON.stringify(q)
 | 
			
		||||
		} : {
 | 
			
		||||
			q: JSON.stringify(q)
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,13 +1,19 @@
 | 
			
		|||
<template>
 | 
			
		||||
<mk-window ref="window" is-modal width="700px" height="550px" @closed="$destroy">
 | 
			
		||||
	<span slot="header" :class="$style.header">%fa:cog%%i18n:@settings%</span>
 | 
			
		||||
	<mk-settings @done="close"/>
 | 
			
		||||
	<mk-settings :initial-page="initialPage" @done="close"/>
 | 
			
		||||
</mk-window>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
export default Vue.extend({
 | 
			
		||||
	props: {
 | 
			
		||||
		initialPage: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	methods: {
 | 
			
		||||
		close() {
 | 
			
		||||
			(this as any).$refs.window.close();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										65
									
								
								src/client/app/desktop/views/components/settings.tags.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/client/app/desktop/views/components/settings.tags.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,65 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div class="vfcitkilproprqtbnpoertpsziierwzi">
 | 
			
		||||
	<div v-for="timeline in timelines" class="timeline">
 | 
			
		||||
		<ui-input v-model="timeline.title" @change="save">
 | 
			
		||||
			<span>%i18n:@title%</span>
 | 
			
		||||
		</ui-input>
 | 
			
		||||
		<ui-textarea :value="timeline.query ? timeline.query.map(tags => tags.join(' ')).join('\n') : ''" @input="onQueryChange(timeline, $event)">
 | 
			
		||||
			<span>%i18n:@query%</span>
 | 
			
		||||
		</ui-textarea>
 | 
			
		||||
		<ui-button class="save" @click="save">%i18n:@save%</ui-button>
 | 
			
		||||
	</div>
 | 
			
		||||
	<ui-button class="add" @click="add">%i18n:@add%</ui-button>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
import * as uuid from 'uuid';
 | 
			
		||||
 | 
			
		||||
export default Vue.extend({
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			timelines: this.$store.state.settings.tagTimelines
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		add() {
 | 
			
		||||
			this.timelines.push({
 | 
			
		||||
				id: uuid(),
 | 
			
		||||
				title: '',
 | 
			
		||||
				query: ''
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			this.save();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		save() {
 | 
			
		||||
			this.$store.dispatch('settings/set', { key: 'tagTimelines', value: this.timelines });
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		onQueryChange(timeline, value) {
 | 
			
		||||
			timeline.query = value.split('\n').map(tags => tags.split(' '));
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="stylus" scoped>
 | 
			
		||||
 | 
			
		||||
root(isDark)
 | 
			
		||||
	> .timeline
 | 
			
		||||
		padding-bottom 16px
 | 
			
		||||
		border-bottom solid 1px rgba(#000, 0.1)
 | 
			
		||||
 | 
			
		||||
	> .add
 | 
			
		||||
		margin-top 16px
 | 
			
		||||
 | 
			
		||||
.vfcitkilproprqtbnpoertpsziierwzi[data-darkmode]
 | 
			
		||||
	root(true)
 | 
			
		||||
 | 
			
		||||
.vfcitkilproprqtbnpoertpsziierwzi:not([data-darkmode])
 | 
			
		||||
	root(false)
 | 
			
		||||
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			@ -5,6 +5,7 @@
 | 
			
		|||
		<p :class="{ active: page == 'web' }" @mousedown="page = 'web'">%fa:desktop .fw%Web</p>
 | 
			
		||||
		<p :class="{ active: page == 'notification' }" @mousedown="page = 'notification'">%fa:R bell .fw%%i18n:@notification%</p>
 | 
			
		||||
		<p :class="{ active: page == 'drive' }" @mousedown="page = 'drive'">%fa:cloud .fw%%i18n:@drive%</p>
 | 
			
		||||
		<p :class="{ active: page == 'hashtags' }" @mousedown="page = 'hashtags'">%fa:hashtag .fw%%i18n:@tags%</p>
 | 
			
		||||
		<p :class="{ active: page == 'mute' }" @mousedown="page = 'mute'">%fa:ban .fw%%i18n:@mute%</p>
 | 
			
		||||
		<p :class="{ active: page == 'apps' }" @mousedown="page = 'apps'">%fa:puzzle-piece .fw%%i18n:@apps%</p>
 | 
			
		||||
		<p :class="{ active: page == 'twitter' }" @mousedown="page = 'twitter'">%fa:B twitter .fw%Twitter</p>
 | 
			
		||||
| 
						 | 
				
			
			@ -138,6 +139,11 @@
 | 
			
		|||
			<x-drive/>
 | 
			
		||||
		</section>
 | 
			
		||||
 | 
			
		||||
		<section class="hashtags" v-show="page == 'hashtags'">
 | 
			
		||||
			<h1>%i18n:@tags%</h1>
 | 
			
		||||
			<x-tags/>
 | 
			
		||||
		</section>
 | 
			
		||||
 | 
			
		||||
		<section class="mute" v-show="page == 'mute'">
 | 
			
		||||
			<h1>%i18n:@mute%</h1>
 | 
			
		||||
			<x-mute/>
 | 
			
		||||
| 
						 | 
				
			
			@ -222,6 +228,7 @@ import XApi from './settings.api.vue';
 | 
			
		|||
import XApps from './settings.apps.vue';
 | 
			
		||||
import XSignins from './settings.signins.vue';
 | 
			
		||||
import XDrive from './settings.drive.vue';
 | 
			
		||||
import XTags from './settings.tags.vue';
 | 
			
		||||
import { url, langs, version } from '../../../config';
 | 
			
		||||
import checkForUpdate from '../../../common/scripts/check-for-update';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -234,11 +241,18 @@ export default Vue.extend({
 | 
			
		|||
		XApi,
 | 
			
		||||
		XApps,
 | 
			
		||||
		XSignins,
 | 
			
		||||
		XDrive
 | 
			
		||||
		XDrive,
 | 
			
		||||
		XTags
 | 
			
		||||
	},
 | 
			
		||||
	props: {
 | 
			
		||||
		initialPage: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: false
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			page: 'profile',
 | 
			
		||||
			page: this.initialPage || 'profile',
 | 
			
		||||
			meta: null,
 | 
			
		||||
			version,
 | 
			
		||||
			langs,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,7 @@
 | 
			
		|||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
import { HashtagStream } from '../../../common/scripts/streaming/hashtag';
 | 
			
		||||
 | 
			
		||||
const fetchLimit = 10;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -23,6 +24,9 @@ export default Vue.extend({
 | 
			
		|||
		src: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		tagTl: {
 | 
			
		||||
			required: false
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +35,7 @@ export default Vue.extend({
 | 
			
		|||
			fetching: true,
 | 
			
		||||
			moreFetching: false,
 | 
			
		||||
			existMore: false,
 | 
			
		||||
			streamManager: null,
 | 
			
		||||
			connection: null,
 | 
			
		||||
			connectionId: null,
 | 
			
		||||
			date: null
 | 
			
		||||
| 
						 | 
				
			
			@ -42,16 +47,6 @@ export default Vue.extend({
 | 
			
		|||
			return this.$store.state.i.followingCount == 0;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		stream(): any {
 | 
			
		||||
			switch (this.src) {
 | 
			
		||||
				case 'home': return (this as any).os.stream;
 | 
			
		||||
				case 'local': return (this as any).os.streams.localTimelineStream;
 | 
			
		||||
				case 'hybrid': return (this as any).os.streams.hybridTimelineStream;
 | 
			
		||||
				case 'global': return (this as any).os.streams.globalTimelineStream;
 | 
			
		||||
				case 'mentions': return (this as any).os.stream;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		endpoint(): string {
 | 
			
		||||
			switch (this.src) {
 | 
			
		||||
				case 'home': return 'notes/timeline';
 | 
			
		||||
| 
						 | 
				
			
			@ -59,6 +54,7 @@ export default Vue.extend({
 | 
			
		|||
				case 'hybrid': return 'notes/hybrid-timeline';
 | 
			
		||||
				case 'global': return 'notes/global-timeline';
 | 
			
		||||
				case 'mentions': return 'notes/mentions';
 | 
			
		||||
				case 'tag': return 'notes/search_by_tag';
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -68,13 +64,36 @@ export default Vue.extend({
 | 
			
		|||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.connection = this.stream.getConnection();
 | 
			
		||||
		this.connectionId = this.stream.use();
 | 
			
		||||
 | 
			
		||||
		this.connection.on(this.src == 'mentions' ? 'mention' : 'note', this.onNote);
 | 
			
		||||
		if (this.src == 'home') {
 | 
			
		||||
		if (this.src == 'tag') {
 | 
			
		||||
			this.connection = new HashtagStream((this as any).os, this.$store.state.i, this.tagTl.query);
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
		} else if (this.src == 'home') {
 | 
			
		||||
			this.streamManager = (this as any).os.stream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
			this.connection.on('follow', this.onChangeFollowing);
 | 
			
		||||
			this.connection.on('unfollow', this.onChangeFollowing);
 | 
			
		||||
		} else if (this.src == 'local') {
 | 
			
		||||
			this.streamManager = (this as any).os.streams.localTimelineStream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
		} else if (this.src == 'hybrid') {
 | 
			
		||||
			this.streamManager = (this as any).os.streams.hybridTimelineStream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
		} else if (this.src == 'global') {
 | 
			
		||||
			this.streamManager = (this as any).os.streams.globalTimelineStream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
		} else if (this.src == 'mentions') {
 | 
			
		||||
			this.streamManager = (this as any).os.stream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('mention', this.onNote);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		document.addEventListener('keydown', this.onKeydown);
 | 
			
		||||
| 
						 | 
				
			
			@ -83,12 +102,27 @@ export default Vue.extend({
 | 
			
		|||
	},
 | 
			
		||||
 | 
			
		||||
	beforeDestroy() {
 | 
			
		||||
		this.connection.off(this.src == 'mentions' ? 'mention' : 'note', this.onNote);
 | 
			
		||||
		if (this.src == 'home') {
 | 
			
		||||
		if (this.src == 'tag') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.connection.close();
 | 
			
		||||
		} else if (this.src == 'home') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.connection.off('follow', this.onChangeFollowing);
 | 
			
		||||
			this.connection.off('unfollow', this.onChangeFollowing);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		} else if (this.src == 'local') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		} else if (this.src == 'hybrid') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		} else if (this.src == 'global') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		} else if (this.src == 'mentions') {
 | 
			
		||||
			this.connection.off('mention', this.onNote);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		}
 | 
			
		||||
		this.stream.dispose(this.connectionId);
 | 
			
		||||
 | 
			
		||||
		document.removeEventListener('keydown', this.onKeydown);
 | 
			
		||||
	},
 | 
			
		||||
| 
						 | 
				
			
			@ -103,7 +137,8 @@ export default Vue.extend({
 | 
			
		|||
					untilDate: this.date ? this.date.getTime() : undefined,
 | 
			
		||||
					includeMyRenotes: this.$store.state.settings.showMyRenotes,
 | 
			
		||||
					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
 | 
			
		||||
					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
 | 
			
		||||
					includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
 | 
			
		||||
					query: this.tagTl ? this.tagTl.query : undefined
 | 
			
		||||
				}).then(notes => {
 | 
			
		||||
					if (notes.length == fetchLimit + 1) {
 | 
			
		||||
						notes.pop();
 | 
			
		||||
| 
						 | 
				
			
			@ -126,7 +161,8 @@ export default Vue.extend({
 | 
			
		|||
				untilId: (this.$refs.timeline as any).tail().id,
 | 
			
		||||
				includeMyRenotes: this.$store.state.settings.showMyRenotes,
 | 
			
		||||
				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
 | 
			
		||||
				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
 | 
			
		||||
				includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
 | 
			
		||||
				query: this.tagTl ? this.tagTl.query : undefined
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			promise.then(notes => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,14 +6,19 @@
 | 
			
		|||
		<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>
 | 
			
		||||
		<span :data-active="src == 'mentions'" @click="src = 'mentions'">%fa:at% %i18n:@mentions%</span>
 | 
			
		||||
		<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>
 | 
			
		||||
		<button @click="chooseList" title="%i18n:@list%">%fa:list%</button>
 | 
			
		||||
		<div class="buttons">
 | 
			
		||||
			<button @click="chooseTag" title="%i18n:@hashtag%" ref="tagButton">%fa:hashtag%</button>
 | 
			
		||||
			<button @click="chooseList" title="%i18n:@list%" ref="listButton">%fa:list%</button>
 | 
			
		||||
		</div>
 | 
			
		||||
	</header>
 | 
			
		||||
	<x-core v-if="src == 'home'" ref="tl" key="home" src="home"/>
 | 
			
		||||
	<x-core v-if="src == 'local'" ref="tl" key="local" src="local"/>
 | 
			
		||||
	<x-core v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/>
 | 
			
		||||
	<x-core v-if="src == 'global'" ref="tl" key="global" src="global"/>
 | 
			
		||||
	<x-core v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/>
 | 
			
		||||
	<x-core v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/>
 | 
			
		||||
	<mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -21,7 +26,8 @@
 | 
			
		|||
<script lang="ts">
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
import XCore from './timeline.core.vue';
 | 
			
		||||
import MkUserListsWindow from './user-lists-window.vue';
 | 
			
		||||
import Menu from '../../../common/views/components/menu.vue';
 | 
			
		||||
import MkSettingsWindow from './settings-window.vue';
 | 
			
		||||
 | 
			
		||||
export default Vue.extend({
 | 
			
		||||
	components: {
 | 
			
		||||
| 
						 | 
				
			
			@ -32,6 +38,7 @@ export default Vue.extend({
 | 
			
		|||
		return {
 | 
			
		||||
			src: 'home',
 | 
			
		||||
			list: null,
 | 
			
		||||
			tagTl: null,
 | 
			
		||||
			enableLocalTimeline: false
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
| 
						 | 
				
			
			@ -41,8 +48,14 @@ export default Vue.extend({
 | 
			
		|||
			this.saveSrc();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		list() {
 | 
			
		||||
		list(x) {
 | 
			
		||||
			this.saveSrc();
 | 
			
		||||
			if (x != null) this.tagTl = null;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		tagTl(x) {
 | 
			
		||||
			this.saveSrc();
 | 
			
		||||
			if (x != null) this.list = null;
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -55,6 +68,8 @@ export default Vue.extend({
 | 
			
		|||
			this.src = this.$store.state.device.tl.src;
 | 
			
		||||
			if (this.src == 'list') {
 | 
			
		||||
				this.list = this.$store.state.device.tl.arg;
 | 
			
		||||
			} else if (this.src == 'tag') {
 | 
			
		||||
				this.tagTl = this.$store.state.device.tl.arg;
 | 
			
		||||
			}
 | 
			
		||||
		} else if (this.$store.state.i.followingCount == 0) {
 | 
			
		||||
			this.src = 'hybrid';
 | 
			
		||||
| 
						 | 
				
			
			@ -71,7 +86,7 @@ export default Vue.extend({
 | 
			
		|||
		saveSrc() {
 | 
			
		||||
			this.$store.commit('device/setTl', {
 | 
			
		||||
				src: this.src,
 | 
			
		||||
				arg: this.list
 | 
			
		||||
				arg: this.src == 'list' ? this.list : this.tagTl
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -79,12 +94,74 @@ export default Vue.extend({
 | 
			
		|||
			(this.$refs.tl as any).warp(date);
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		chooseList() {
 | 
			
		||||
			const w = (this as any).os.new(MkUserListsWindow);
 | 
			
		||||
			w.$once('choosen', list => {
 | 
			
		||||
				this.list = list;
 | 
			
		||||
				this.src = 'list';
 | 
			
		||||
				w.close();
 | 
			
		||||
		async chooseList() {
 | 
			
		||||
			const lists = await (this as any).api('users/lists/list');
 | 
			
		||||
 | 
			
		||||
			let menu = [{
 | 
			
		||||
				icon: '%fa:plus%',
 | 
			
		||||
				text: '%i18n:@add-list%',
 | 
			
		||||
				action: () => {
 | 
			
		||||
					(this as any).apis.input({
 | 
			
		||||
						title: '%i18n:@list-name%',
 | 
			
		||||
					}).then(async title => {
 | 
			
		||||
						const list = await (this as any).api('users/lists/create', {
 | 
			
		||||
							title
 | 
			
		||||
						});
 | 
			
		||||
 | 
			
		||||
						this.list = list;
 | 
			
		||||
						this.src = 'list';
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
			}];
 | 
			
		||||
 | 
			
		||||
			if (lists.length > 0) {
 | 
			
		||||
				menu.push(null);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			menu = menu.concat(lists.map(list => ({
 | 
			
		||||
				icon: '%fa:list%',
 | 
			
		||||
				text: list.title,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.list = list;
 | 
			
		||||
					this.src = 'list';
 | 
			
		||||
				}
 | 
			
		||||
			})));
 | 
			
		||||
 | 
			
		||||
			this.os.new(Menu, {
 | 
			
		||||
				source: this.$refs.listButton,
 | 
			
		||||
				compact: false,
 | 
			
		||||
				items: menu
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		chooseTag() {
 | 
			
		||||
			let menu = [{
 | 
			
		||||
				icon: '%fa:plus%',
 | 
			
		||||
				text: '%i18n:@add-tag-timeline%',
 | 
			
		||||
				action: () => {
 | 
			
		||||
					(this as any).os.new(MkSettingsWindow, {
 | 
			
		||||
						initialPage: 'hashtags'
 | 
			
		||||
					});
 | 
			
		||||
				}
 | 
			
		||||
			}];
 | 
			
		||||
 | 
			
		||||
			if (this.$store.state.settings.tagTimelines.length > 0) {
 | 
			
		||||
				menu.push(null);
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			menu = menu.concat(this.$store.state.settings.tagTimelines.map(t => ({
 | 
			
		||||
				icon: '%fa:hashtag%',
 | 
			
		||||
				text: t.title,
 | 
			
		||||
				action: () => {
 | 
			
		||||
					this.tagTl = t;
 | 
			
		||||
					this.src = 'tag';
 | 
			
		||||
				}
 | 
			
		||||
			})));
 | 
			
		||||
 | 
			
		||||
			this.os.new(Menu, {
 | 
			
		||||
				source: this.$refs.tagButton,
 | 
			
		||||
				compact: false,
 | 
			
		||||
				items: menu
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -106,22 +183,24 @@ root(isDark)
 | 
			
		|||
		border-radius 6px 6px 0 0
 | 
			
		||||
		box-shadow 0 1px isDark ? rgba(#000, 0.15) : rgba(#000, 0.08)
 | 
			
		||||
 | 
			
		||||
		> button
 | 
			
		||||
		> .buttons
 | 
			
		||||
			position absolute
 | 
			
		||||
			z-index 2
 | 
			
		||||
			top 0
 | 
			
		||||
			right 0
 | 
			
		||||
			padding 0
 | 
			
		||||
			width 42px
 | 
			
		||||
			font-size 0.9em
 | 
			
		||||
			line-height 42px
 | 
			
		||||
			color isDark ? #9baec8 : #ccc
 | 
			
		||||
 | 
			
		||||
			&:hover
 | 
			
		||||
				color isDark ? #b2c1d5 : #aaa
 | 
			
		||||
			> button
 | 
			
		||||
				padding 0
 | 
			
		||||
				width 42px
 | 
			
		||||
				font-size 0.9em
 | 
			
		||||
				line-height 42px
 | 
			
		||||
				color isDark ? #9baec8 : #ccc
 | 
			
		||||
 | 
			
		||||
			&:active
 | 
			
		||||
				color isDark ? #b2c1d5 : #999
 | 
			
		||||
				&:hover
 | 
			
		||||
					color isDark ? #b2c1d5 : #aaa
 | 
			
		||||
 | 
			
		||||
				&:active
 | 
			
		||||
					color isDark ? #b2c1d5 : #999
 | 
			
		||||
 | 
			
		||||
		> span
 | 
			
		||||
			display inline-block
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,7 @@
 | 
			
		|||
<x-tl-column v-else-if="column.type == 'hybrid'" :column="column" :is-stacked="isStacked"/>
 | 
			
		||||
<x-tl-column v-else-if="column.type == 'global'" :column="column" :is-stacked="isStacked"/>
 | 
			
		||||
<x-tl-column v-else-if="column.type == 'list'" :column="column" :is-stacked="isStacked"/>
 | 
			
		||||
<x-tl-column v-else-if="column.type == 'hashtag'" :column="column" :is-stacked="isStacked"/>
 | 
			
		||||
<x-mentions-column v-else-if="column.type == 'mentions'" :column="column" :is-stacked="isStacked"/>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										117
									
								
								src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								src/client/app/desktop/views/pages/deck/deck.hashtag-tl.vue
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,117 @@
 | 
			
		|||
<template>
 | 
			
		||||
	<x-notes ref="timeline" :more="existMore ? more : null" :media-view="mediaView"/>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
import XNotes from './deck.notes.vue';
 | 
			
		||||
import { HashtagStream } from '../../../../common/scripts/streaming/hashtag';
 | 
			
		||||
 | 
			
		||||
const fetchLimit = 10;
 | 
			
		||||
 | 
			
		||||
export default Vue.extend({
 | 
			
		||||
	components: {
 | 
			
		||||
		XNotes
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
		tagTl: {
 | 
			
		||||
			type: Object,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		mediaOnly: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		},
 | 
			
		||||
		mediaView: {
 | 
			
		||||
			type: Boolean,
 | 
			
		||||
			required: false,
 | 
			
		||||
			default: false
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	data() {
 | 
			
		||||
		return {
 | 
			
		||||
			fetching: true,
 | 
			
		||||
			moreFetching: false,
 | 
			
		||||
			existMore: false,
 | 
			
		||||
			connection: null
 | 
			
		||||
		};
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	watch: {
 | 
			
		||||
		mediaOnly() {
 | 
			
		||||
			this.fetch();
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		if (this.connection) this.connection.close();
 | 
			
		||||
		this.connection = new HashtagStream((this as any).os, this.$store.state.i, this.tagTl.query);
 | 
			
		||||
		this.connection.on('note', this.onNote);
 | 
			
		||||
 | 
			
		||||
		this.fetch();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeDestroy() {
 | 
			
		||||
		this.connection.close();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
		fetch() {
 | 
			
		||||
			this.fetching = true;
 | 
			
		||||
 | 
			
		||||
			(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
 | 
			
		||||
				(this as any).api('notes/search_by_tag', {
 | 
			
		||||
					limit: fetchLimit + 1,
 | 
			
		||||
					withFiles: this.mediaOnly,
 | 
			
		||||
					includeMyRenotes: this.$store.state.settings.showMyRenotes,
 | 
			
		||||
					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
 | 
			
		||||
					includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
 | 
			
		||||
					query: this.tagTl.query
 | 
			
		||||
				}).then(notes => {
 | 
			
		||||
					if (notes.length == fetchLimit + 1) {
 | 
			
		||||
						notes.pop();
 | 
			
		||||
						this.existMore = true;
 | 
			
		||||
					}
 | 
			
		||||
					res(notes);
 | 
			
		||||
					this.fetching = false;
 | 
			
		||||
					this.$emit('loaded');
 | 
			
		||||
				}, rej);
 | 
			
		||||
			}));
 | 
			
		||||
		},
 | 
			
		||||
		more() {
 | 
			
		||||
			this.moreFetching = true;
 | 
			
		||||
 | 
			
		||||
			const promise = (this as any).api('notes/search_by_tag', {
 | 
			
		||||
				limit: fetchLimit + 1,
 | 
			
		||||
				untilId: (this.$refs.timeline as any).tail().id,
 | 
			
		||||
				withFiles: this.mediaOnly,
 | 
			
		||||
				includeMyRenotes: this.$store.state.settings.showMyRenotes,
 | 
			
		||||
				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
 | 
			
		||||
				includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
 | 
			
		||||
				query: this.tagTl.query
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			promise.then(notes => {
 | 
			
		||||
				if (notes.length == fetchLimit + 1) {
 | 
			
		||||
					notes.pop();
 | 
			
		||||
				} else {
 | 
			
		||||
					this.existMore = false;
 | 
			
		||||
				}
 | 
			
		||||
				notes.forEach(n => (this.$refs.timeline as any).append(n));
 | 
			
		||||
				this.moreFetching = false;
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			return promise;
 | 
			
		||||
		},
 | 
			
		||||
		onNote(note) {
 | 
			
		||||
			if (this.mediaOnly && note.files.length == 0) return;
 | 
			
		||||
 | 
			
		||||
			// Prepend a note
 | 
			
		||||
			(this.$refs.timeline as any).prepend(note);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +6,7 @@
 | 
			
		|||
		<template v-if="column.type == 'hybrid'">%fa:share-alt%</template>
 | 
			
		||||
		<template v-if="column.type == 'global'">%fa:globe%</template>
 | 
			
		||||
		<template v-if="column.type == 'list'">%fa:list%</template>
 | 
			
		||||
		<template v-if="column.type == 'hashtag'">%fa:hashtag%</template>
 | 
			
		||||
		<span>{{ name }}</span>
 | 
			
		||||
	</span>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -14,6 +15,7 @@
 | 
			
		|||
		<mk-switch v-model="column.isMediaView" @change="onChangeSettings" text="%i18n:@is-media-view%"/>
 | 
			
		||||
	</div>
 | 
			
		||||
	<x-list-tl v-if="column.type == 'list'" :list="column.list" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/>
 | 
			
		||||
	<x-hashtag-tl v-if="column.type == 'hashtag'" :tag-tl="$store.state.settings.tagTimelines.find(x => x.id == column.tagTlId)" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/>
 | 
			
		||||
	<x-tl v-else :src="column.type" :media-only="column.isMediaOnly" :media-view="column.isMediaView"/>
 | 
			
		||||
</x-column>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -23,12 +25,14 @@ import Vue from 'vue';
 | 
			
		|||
import XColumn from './deck.column.vue';
 | 
			
		||||
import XTl from './deck.tl.vue';
 | 
			
		||||
import XListTl from './deck.list-tl.vue';
 | 
			
		||||
import XHashtagTl from './deck.hashtag-tl.vue';
 | 
			
		||||
 | 
			
		||||
export default Vue.extend({
 | 
			
		||||
	components: {
 | 
			
		||||
		XColumn,
 | 
			
		||||
		XTl,
 | 
			
		||||
		XListTl
 | 
			
		||||
		XListTl,
 | 
			
		||||
		XHashtagTl
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	props: {
 | 
			
		||||
| 
						 | 
				
			
			@ -65,6 +69,7 @@ export default Vue.extend({
 | 
			
		|||
				case 'hybrid': return '%i18n:common.deck.hybrid%';
 | 
			
		||||
				case 'global': return '%i18n:common.deck.global%';
 | 
			
		||||
				case 'list': return this.column.list.title;
 | 
			
		||||
				case 'hashtag': return this.$store.state.settings.tagTimelines.find(x => x.id == this.column.tagTlId).title;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -161,6 +161,20 @@ export default Vue.extend({
 | 
			
		|||
							w.close();
 | 
			
		||||
						});
 | 
			
		||||
					}
 | 
			
		||||
				}, {
 | 
			
		||||
					icon: '%fa:hashtag%',
 | 
			
		||||
					text: '%i18n:common.deck.hashtag%',
 | 
			
		||||
					action: () => {
 | 
			
		||||
						(this as any).apis.input({
 | 
			
		||||
							title: '%i18n:@enter-hashtag-tl-title%'
 | 
			
		||||
						}).then(title => {
 | 
			
		||||
							this.$store.dispatch('settings/addDeckColumn', {
 | 
			
		||||
								id: uuid(),
 | 
			
		||||
								type: 'hashtag',
 | 
			
		||||
								tagTlId: this.$store.state.settings.tagTimelines.find(x => x.title == title).id
 | 
			
		||||
							});
 | 
			
		||||
						});
 | 
			
		||||
					}
 | 
			
		||||
				}, {
 | 
			
		||||
					icon: '%fa:bell R%',
 | 
			
		||||
					text: '%i18n:common.deck.notifications%',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,6 +13,7 @@
 | 
			
		|||
 | 
			
		||||
<script lang="ts">
 | 
			
		||||
import Vue from 'vue';
 | 
			
		||||
import { HashtagStream } from '../../../common/scripts/streaming/hashtag';
 | 
			
		||||
 | 
			
		||||
const fetchLimit = 10;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -21,6 +22,9 @@ export default Vue.extend({
 | 
			
		|||
		src: {
 | 
			
		||||
			type: String,
 | 
			
		||||
			required: true
 | 
			
		||||
		},
 | 
			
		||||
		tagTl: {
 | 
			
		||||
			required: false
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -29,6 +33,7 @@ export default Vue.extend({
 | 
			
		|||
			fetching: true,
 | 
			
		||||
			moreFetching: false,
 | 
			
		||||
			existMore: false,
 | 
			
		||||
			streamManager: null,
 | 
			
		||||
			connection: null,
 | 
			
		||||
			connectionId: null,
 | 
			
		||||
			unreadCount: 0,
 | 
			
		||||
| 
						 | 
				
			
			@ -41,16 +46,6 @@ export default Vue.extend({
 | 
			
		|||
			return this.$store.state.i.followingCount == 0;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		stream(): any {
 | 
			
		||||
			switch (this.src) {
 | 
			
		||||
				case 'home': return (this as any).os.stream;
 | 
			
		||||
				case 'local': return (this as any).os.streams.localTimelineStream;
 | 
			
		||||
				case 'hybrid': return (this as any).os.streams.hybridTimelineStream;
 | 
			
		||||
				case 'global': return (this as any).os.streams.globalTimelineStream;
 | 
			
		||||
				case 'mentions': return (this as any).os.stream;
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		endpoint(): string {
 | 
			
		||||
			switch (this.src) {
 | 
			
		||||
				case 'home': return 'notes/timeline';
 | 
			
		||||
| 
						 | 
				
			
			@ -58,6 +53,7 @@ export default Vue.extend({
 | 
			
		|||
				case 'hybrid': return 'notes/hybrid-timeline';
 | 
			
		||||
				case 'global': return 'notes/global-timeline';
 | 
			
		||||
				case 'mentions': return 'notes/mentions';
 | 
			
		||||
				case 'tag': return 'notes/search_by_tag';
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -67,25 +63,63 @@ export default Vue.extend({
 | 
			
		|||
	},
 | 
			
		||||
 | 
			
		||||
	mounted() {
 | 
			
		||||
		this.connection = this.stream.getConnection();
 | 
			
		||||
		this.connectionId = this.stream.use();
 | 
			
		||||
 | 
			
		||||
		this.connection.on(this.src == 'mentions' ? 'mention' : 'note', this.onNote);
 | 
			
		||||
		if (this.src == 'home') {
 | 
			
		||||
		if (this.src == 'tag') {
 | 
			
		||||
			this.connection = new HashtagStream((this as any).os, this.$store.state.i, this.tagTl.query);
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
		} else if (this.src == 'home') {
 | 
			
		||||
			this.streamManager = (this as any).os.stream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
			this.connection.on('follow', this.onChangeFollowing);
 | 
			
		||||
			this.connection.on('unfollow', this.onChangeFollowing);
 | 
			
		||||
		} else if (this.src == 'local') {
 | 
			
		||||
			this.streamManager = (this as any).os.streams.localTimelineStream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
		} else if (this.src == 'hybrid') {
 | 
			
		||||
			this.streamManager = (this as any).os.streams.hybridTimelineStream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
		} else if (this.src == 'global') {
 | 
			
		||||
			this.streamManager = (this as any).os.streams.globalTimelineStream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('note', this.onNote);
 | 
			
		||||
		} else if (this.src == 'mentions') {
 | 
			
		||||
			this.streamManager = (this as any).os.stream;
 | 
			
		||||
			this.connection = this.streamManager.getConnection();
 | 
			
		||||
			this.connectionId = this.streamManager.use();
 | 
			
		||||
			this.connection.on('mention', this.onNote);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		this.fetch();
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	beforeDestroy() {
 | 
			
		||||
		this.connection.off(this.src == 'mentions' ? 'mention' : 'note', this.onNote);
 | 
			
		||||
		if (this.src == 'home') {
 | 
			
		||||
		if (this.src == 'tag') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.connection.close();
 | 
			
		||||
		} else if (this.src == 'home') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.connection.off('follow', this.onChangeFollowing);
 | 
			
		||||
			this.connection.off('unfollow', this.onChangeFollowing);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		} else if (this.src == 'local') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		} else if (this.src == 'hybrid') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		} else if (this.src == 'global') {
 | 
			
		||||
			this.connection.off('note', this.onNote);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		} else if (this.src == 'mentions') {
 | 
			
		||||
			this.connection.off('mention', this.onNote);
 | 
			
		||||
			this.streamManager.dispose(this.connectionId);
 | 
			
		||||
		}
 | 
			
		||||
		this.stream.dispose(this.connectionId);
 | 
			
		||||
	},
 | 
			
		||||
 | 
			
		||||
	methods: {
 | 
			
		||||
| 
						 | 
				
			
			@ -98,7 +132,8 @@ export default Vue.extend({
 | 
			
		|||
					untilDate: this.date ? this.date.getTime() : undefined,
 | 
			
		||||
					includeMyRenotes: this.$store.state.settings.showMyRenotes,
 | 
			
		||||
					includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
 | 
			
		||||
					includeLocalRenotes: this.$store.state.settings.showLocalRenotes
 | 
			
		||||
					includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
 | 
			
		||||
					query: this.tagTl ? this.tagTl.query : undefined
 | 
			
		||||
				}).then(notes => {
 | 
			
		||||
					if (notes.length == fetchLimit + 1) {
 | 
			
		||||
						notes.pop();
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +156,8 @@ export default Vue.extend({
 | 
			
		|||
				untilId: (this.$refs.timeline as any).tail().id,
 | 
			
		||||
				includeMyRenotes: this.$store.state.settings.showMyRenotes,
 | 
			
		||||
				includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
 | 
			
		||||
				includeLocalRenotes: this.$store.state.settings.showLocalRenotes
 | 
			
		||||
				includeLocalRenotes: this.$store.state.settings.showLocalRenotes,
 | 
			
		||||
				query: this.tagTl ? this.tagTl.query : undefined
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			promise.then(notes => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,6 +8,7 @@
 | 
			
		|||
			<span v-if="src == 'global'">%fa:globe%%i18n:@global%</span>
 | 
			
		||||
			<span v-if="src == 'mentions'">%fa:at%%i18n:@mentions%</span>
 | 
			
		||||
			<span v-if="src == 'list'">%fa:list%{{ list.title }}</span>
 | 
			
		||||
			<span v-if="src == 'tag'">%fa:hashtag%{{ tagTl.title }}</span>
 | 
			
		||||
		</span>
 | 
			
		||||
		<span style="margin-left:8px">
 | 
			
		||||
			<template v-if="!showNav">%fa:angle-down%</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -32,6 +33,7 @@
 | 
			
		|||
					<template v-if="lists">
 | 
			
		||||
						<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>
 | 
			
		||||
					<span v-for="tl in $store.state.settings.tagTimelines" :data-active="src == 'tag' && tagTl == tl" @click="src = 'tag'; tagTl = tl" :key="tl.id">%fa:hashtag% {{ tl.title }}</span>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +44,7 @@
 | 
			
		|||
			<x-tl v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/>
 | 
			
		||||
			<x-tl v-if="src == 'global'" ref="tl" key="global" src="global"/>
 | 
			
		||||
			<x-tl v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/>
 | 
			
		||||
			<x-tl v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/>
 | 
			
		||||
			<mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/>
 | 
			
		||||
		</div>
 | 
			
		||||
	</main>
 | 
			
		||||
| 
						 | 
				
			
			@ -63,6 +66,7 @@ export default Vue.extend({
 | 
			
		|||
			src: 'home',
 | 
			
		||||
			list: null,
 | 
			
		||||
			lists: null,
 | 
			
		||||
			tagTl: null,
 | 
			
		||||
			showNav: false,
 | 
			
		||||
			enableLocalTimeline: false
 | 
			
		||||
		};
 | 
			
		||||
| 
						 | 
				
			
			@ -74,9 +78,16 @@ export default Vue.extend({
 | 
			
		|||
			this.saveSrc();
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		list() {
 | 
			
		||||
		list(x) {
 | 
			
		||||
			this.showNav = false;
 | 
			
		||||
			this.saveSrc();
 | 
			
		||||
			if (x != null) this.tagTl = null;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		tagTl(x) {
 | 
			
		||||
			this.showNav = false;
 | 
			
		||||
			this.saveSrc();
 | 
			
		||||
			if (x != null) this.list = null;
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
		showNav(v) {
 | 
			
		||||
| 
						 | 
				
			
			@ -97,6 +108,8 @@ export default Vue.extend({
 | 
			
		|||
			this.src = this.$store.state.device.tl.src;
 | 
			
		||||
			if (this.src == 'list') {
 | 
			
		||||
				this.list = this.$store.state.device.tl.arg;
 | 
			
		||||
			} else if (this.src == 'tag') {
 | 
			
		||||
				this.tagTl = this.$store.state.device.tl.arg;
 | 
			
		||||
			}
 | 
			
		||||
		} else if (this.$store.state.i.followingCount == 0) {
 | 
			
		||||
			this.src = 'hybrid';
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +134,7 @@ export default Vue.extend({
 | 
			
		|||
		saveSrc() {
 | 
			
		||||
			this.$store.commit('device/setTl', {
 | 
			
		||||
				src: this.src,
 | 
			
		||||
				arg: this.list
 | 
			
		||||
				arg: this.src == 'list' ? this.list : this.tagTl
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,7 @@ const defaultSettings = {
 | 
			
		|||
	home: null,
 | 
			
		||||
	mobileHome: [],
 | 
			
		||||
	deck: null,
 | 
			
		||||
	tagTimelines: [],
 | 
			
		||||
	fetchOnScroll: true,
 | 
			
		||||
	showMaps: true,
 | 
			
		||||
	showPostFormOnTopOfTl: false,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,12 +13,18 @@ export const meta = {
 | 
			
		|||
	},
 | 
			
		||||
 | 
			
		||||
	params: {
 | 
			
		||||
		tag: $.str.note({
 | 
			
		||||
		tag: $.str.optional.note({
 | 
			
		||||
			desc: {
 | 
			
		||||
				'ja-JP': 'タグ'
 | 
			
		||||
			}
 | 
			
		||||
		}),
 | 
			
		||||
 | 
			
		||||
		query: $.arr($.arr($.str)).optional.note({
 | 
			
		||||
			desc: {
 | 
			
		||||
				'ja-JP': 'クエリ'
 | 
			
		||||
			}
 | 
			
		||||
		}),
 | 
			
		||||
 | 
			
		||||
		includeUserIds: $.arr($.type(ID)).optional.note({
 | 
			
		||||
			default: []
 | 
			
		||||
		}),
 | 
			
		||||
| 
						 | 
				
			
			@ -59,11 +65,9 @@ export const meta = {
 | 
			
		|||
			}
 | 
			
		||||
		}),
 | 
			
		||||
 | 
			
		||||
		withFiles: $.bool.optional.nullable.note({
 | 
			
		||||
			default: null,
 | 
			
		||||
 | 
			
		||||
		withFiles: $.bool.optional.note({
 | 
			
		||||
			desc: {
 | 
			
		||||
				'ja-JP': 'ファイルが添付された投稿に限定するか否か'
 | 
			
		||||
				'ja-JP': 'true にすると、ファイルが添付された投稿だけ取得します'
 | 
			
		||||
			}
 | 
			
		||||
		}),
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -126,8 +130,14 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	const q: any = {
 | 
			
		||||
		$and: [{
 | 
			
		||||
		$and: [ps.tag ? {
 | 
			
		||||
			tagsLower: ps.tag.toLowerCase()
 | 
			
		||||
		} : {
 | 
			
		||||
			$or: ps.query.map(tags => ({
 | 
			
		||||
				$and: tags.map(t => ({
 | 
			
		||||
					tagsLower: t.toLowerCase()
 | 
			
		||||
				}))
 | 
			
		||||
			}))
 | 
			
		||||
		}],
 | 
			
		||||
		deletedAt: { $exists: false }
 | 
			
		||||
	};
 | 
			
		||||
| 
						 | 
				
			
			@ -281,25 +291,10 @@ export default (params: any, me: ILocalUser) => new Promise(async (res, rej) =>
 | 
			
		|||
 | 
			
		||||
	const withFiles = ps.withFiles != null ? ps.withFiles : ps.media;
 | 
			
		||||
 | 
			
		||||
	if (withFiles != null) {
 | 
			
		||||
		if (withFiles) {
 | 
			
		||||
			push({
 | 
			
		||||
				fileIds: {
 | 
			
		||||
					$exists: true,
 | 
			
		||||
					$ne: null
 | 
			
		||||
				}
 | 
			
		||||
			});
 | 
			
		||||
		} else {
 | 
			
		||||
			push({
 | 
			
		||||
				$or: [{
 | 
			
		||||
					fileIds: {
 | 
			
		||||
						$exists: false
 | 
			
		||||
					}
 | 
			
		||||
				}, {
 | 
			
		||||
					fileIds: null
 | 
			
		||||
				}]
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	if (withFiles) {
 | 
			
		||||
		push({
 | 
			
		||||
			fileIds: { $exists: true, $ne: [] }
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (ps.poll != null) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										48
									
								
								src/server/api/stream/hashtag.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/server/api/stream/hashtag.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,48 @@
 | 
			
		|||
import * as websocket from 'websocket';
 | 
			
		||||
import Xev from 'xev';
 | 
			
		||||
 | 
			
		||||
import { IUser } from '../../../models/user';
 | 
			
		||||
import Mute from '../../../models/mute';
 | 
			
		||||
import { pack } from '../../../models/note';
 | 
			
		||||
 | 
			
		||||
export default async function(
 | 
			
		||||
	request: websocket.request,
 | 
			
		||||
	connection: websocket.connection,
 | 
			
		||||
	subscriber: Xev,
 | 
			
		||||
	user?: IUser
 | 
			
		||||
) {
 | 
			
		||||
	const mute = user ? await Mute.find({ muterId: user._id }) : null;
 | 
			
		||||
	const mutedUserIds = mute ? mute.map(m => m.muteeId.toString()) : [];
 | 
			
		||||
 | 
			
		||||
	const q: Array<string[]> = JSON.parse((request.resourceURL.query as any).q);
 | 
			
		||||
 | 
			
		||||
	// Subscribe stream
 | 
			
		||||
	subscriber.on('hashtag', async note => {
 | 
			
		||||
		const matched = q.some(tags => tags.every(tag => note.tags.map((t: string) => t.toLowerCase()).includes(tag.toLowerCase())));
 | 
			
		||||
		if (!matched) return;
 | 
			
		||||
 | 
			
		||||
		// Renoteなら再pack
 | 
			
		||||
		if (note.renoteId != null) {
 | 
			
		||||
			note.renote = await pack(note.renoteId, user, {
 | 
			
		||||
				detail: true
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		//#region 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
 | 
			
		||||
		if (mutedUserIds.indexOf(note.userId) != -1) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		if (note.reply != null && mutedUserIds.indexOf(note.reply.userId) != -1) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		if (note.renote != null && mutedUserIds.indexOf(note.renote.userId) != -1) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		//#endregion
 | 
			
		||||
 | 
			
		||||
		connection.send(JSON.stringify({
 | 
			
		||||
			type: 'note',
 | 
			
		||||
			body: note
 | 
			
		||||
		}));
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -14,6 +14,7 @@ import reversiGameStream from './stream/games/reversi-game';
 | 
			
		|||
import reversiStream from './stream/games/reversi';
 | 
			
		||||
import serverStatsStream from './stream/server-stats';
 | 
			
		||||
import notesStatsStream from './stream/notes-stats';
 | 
			
		||||
import hashtagStream from './stream/hashtag';
 | 
			
		||||
import { ParsedUrlQuery } from 'querystring';
 | 
			
		||||
import authenticate from './authenticate';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -57,6 +58,11 @@ module.exports = (server: http.Server) => {
 | 
			
		|||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (request.resourceURL.pathname === '/hashtag') {
 | 
			
		||||
			hashtagStream(request, connection, ev, user);
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (user == null) {
 | 
			
		||||
			connection.send('authentication-failed');
 | 
			
		||||
			connection.close();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import es from '../../db/elasticsearch';
 | 
			
		||||
import Note, { pack, INote } from '../../models/note';
 | 
			
		||||
import User, { isLocalUser, IUser, isRemoteUser, IRemoteUser, ILocalUser } from '../../models/user';
 | 
			
		||||
import { publishUserStream, publishLocalTimelineStream, publishHybridTimelineStream, publishGlobalTimelineStream, publishUserListStream } from '../../stream';
 | 
			
		||||
import { publishUserStream, publishLocalTimelineStream, publishHybridTimelineStream, publishGlobalTimelineStream, publishUserListStream, publishHashtagStream } from '../../stream';
 | 
			
		||||
import Following from '../../models/following';
 | 
			
		||||
import { deliver } from '../../queue';
 | 
			
		||||
import renderNote from '../../remote/activitypub/renderer/note';
 | 
			
		||||
| 
						 | 
				
			
			@ -181,6 +181,10 @@ export default async (user: IUser, data: Option, silent = false) => new Promise<
 | 
			
		|||
		noteObj.isFirstNote = true;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if (tags.length > 0) {
 | 
			
		||||
		publishHashtagStream(noteObj);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const nm = new NotificationManager(user, note);
 | 
			
		||||
	const nmRelatedPromises = [];
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -78,6 +78,10 @@ class Publisher {
 | 
			
		|||
	public publishGlobalTimelineStream = (note: any): void => {
 | 
			
		||||
		this.publish('global-timeline', null, note);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	public publishHashtagStream = (note: any): void => {
 | 
			
		||||
		this.publish('hashtag', null, note);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const publisher = new Publisher();
 | 
			
		||||
| 
						 | 
				
			
			@ -95,3 +99,4 @@ export const publishReversiGameStream = publisher.publishReversiGameStream;
 | 
			
		|||
export const publishLocalTimelineStream = publisher.publishLocalTimelineStream;
 | 
			
		||||
export const publishHybridTimelineStream = publisher.publishHybridTimelineStream;
 | 
			
		||||
export const publishGlobalTimelineStream = publisher.publishGlobalTimelineStream;
 | 
			
		||||
export const publishHashtagStream = publisher.publishHashtagStream;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue