<template> <div class="mk-messaging" :data-compact="compact"> <div class="search" v-if="!compact" :style="{ top: headerTop + 'px' }"> <div class="form"> <label for="search-input">%fa:search%</label> <input v-model="q" type="search" @input="search" @keydown="onSearchKeydown" placeholder="%i18n:@search-user%"/> </div> <div class="result"> <ol class="users" v-if="result.length > 0" ref="searchResult"> <li v-for="(user, i) in result" @keydown.enter="navigate(user)" @keydown="onSearchResultKeydown(i)" @click="navigate(user)" tabindex="-1" > <mk-avatar class="avatar" :user="user"/> <span class="name">{{ user | userName }}</span> <span class="username">@{{ user | acct }}</span> </li> </ol> </div> </div> <div class="history" v-if="messages.length > 0"> <template> <a v-for="message in messages" class="user" :href="`/i/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" :data-is-me="isMe(message)" :data-is-read="message.isRead" @click.prevent="navigate(isMe(message) ? message.recipient : message.user)" :key="message.id" > <div> <mk-avatar class="avatar" :user="isMe(message) ? message.recipient : message.user"/> <header> <span class="name">{{ isMe(message) ? message.recipient : message.user | userName }}</span> <span class="username">@{{ isMe(message) ? message.recipient : message.user | acct }}</span> <mk-time :time="message.createdAt"/> </header> <div class="body"> <p class="text"><span class="me" v-if="isMe(message)">%i18n:@you%:</span>{{ message.text }}</p> </div> </div> </a> </template> </div> <p class="no-history" v-if="!fetching && messages.length == 0">%i18n:@no-history%</p> <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> </div> </template> <script lang="ts"> import Vue from 'vue'; import getAcct from '../../../../../acct/render'; export default Vue.extend({ props: { compact: { type: Boolean, default: false }, headerTop: { type: Number, default: 0 } }, data() { return { fetching: true, moreFetching: false, messages: [], q: null, result: [], connection: null, connectionId: null }; }, mounted() { this.connection = (this as any).os.streams.messagingIndexStream.getConnection(); this.connectionId = (this as any).os.streams.messagingIndexStream.use(); this.connection.on('message', this.onMessage); this.connection.on('read', this.onRead); (this as any).api('messaging/history').then(messages => { this.messages = messages; this.fetching = false; }); }, beforeDestroy() { this.connection.off('message', this.onMessage); this.connection.off('read', this.onRead); (this as any).os.streams.messagingIndexStream.dispose(this.connectionId); }, methods: { getAcct, isMe(message) { return message.userId == (this as any).os.i.id; }, onMessage(message) { this.messages = this.messages.filter(m => !( (m.recipientId == message.recipientId && m.userId == message.userId) || (m.recipientId == message.userId && m.userId == message.recipientId))); this.messages.unshift(message); }, onRead(ids) { ids.forEach(id => { const found = this.messages.find(m => m.id == id); if (found) found.isRead = true; }); }, search() { if (this.q == '') { this.result = []; return; } (this as any).api('users/search', { query: this.q, max: 5 }).then(users => { this.result = users; }); }, navigate(user) { this.$emit('navigate', user); }, onSearchKeydown(e) { switch (e.which) { case 9: // [TAB] case 40: // [↓] e.preventDefault(); e.stopPropagation(); (this.$refs.searchResult as any).childNodes[0].focus(); break; } }, onSearchResultKeydown(i, e) { const list = this.$refs.searchResult as any; const cancel = () => { e.preventDefault(); e.stopPropagation(); }; switch (true) { case e.which == 27: // [ESC] cancel(); (this.$refs.search as any).focus(); break; case e.which == 9 && e.shiftKey: // [TAB] + [Shift] case e.which == 38: // [↑] cancel(); (list.childNodes[i].previousElementSibling || list.childNodes[this.result.length - 1]).focus(); break; case e.which == 9: // [TAB] case e.which == 40: // [↓] cancel(); (list.childNodes[i].nextElementSibling || list.childNodes[0]).focus(); break; } } } }); </script> <style lang="stylus" scoped> @import '~const.styl' root(isDark) &[data-compact] font-size 0.8em > .history > a &:last-child border-bottom none &:not([data-is-me]):not([data-is-read]) > div background-image none border-left solid 4px #3aa2dc > div padding 16px > header > .mk-time font-size 1em > .avatar width 42px height 42px margin 0 12px 0 0 > .search display block position -webkit-sticky position sticky top 0 left 0 z-index 1 width 100% background #fff box-shadow 0 0px 2px rgba(#000, 0.2) > .form padding 8px background isDark ? #282c37 : #f7f7f7 > label display block position absolute top 0 left 8px z-index 1 height 100% width 38px pointer-events none > [data-fa] display block position absolute top 0 right 0 bottom 0 left 0 width 1em line-height 56px margin auto color #555 > input margin 0 padding 0 0 0 32px width 100% font-size 1em line-height 38px color #000 outline none background isDark ? #191b22 : #fff border solid 1px isDark ? #495156 : #eee border-radius 5px box-shadow none transition color 0.5s ease, border 0.5s ease &:hover border solid 1px isDark ? #b0b0b0 : #ddd transition border 0.2s ease &:focus color darken($theme-color, 20%) border solid 1px $theme-color transition color 0, border 0 > .result display block top 0 left 0 z-index 2 width 100% margin 0 padding 0 background #fff > .users margin 0 padding 0 list-style none > li display inline-block z-index 1 width 100% padding 8px 32px vertical-align top white-space nowrap overflow hidden color rgba(#000, 0.8) text-decoration none transition none cursor pointer &:hover &:focus color #fff background $theme-color .name color #fff .username color #fff &:active color #fff background darken($theme-color, 10%) .name color #fff .username color #fff .avatar vertical-align middle min-width 32px min-height 32px max-width 32px max-height 32px margin 0 8px 0 0 border-radius 6px .name margin 0 8px 0 0 /*font-weight bold*/ font-weight normal color rgba(#000, 0.8) .username font-weight normal color rgba(#000, 0.3) > .history > a display block text-decoration none background isDark ? #282c37 : #fff border-bottom solid 1px isDark ? #1c2023 : #eee * pointer-events none user-select none &:hover background isDark ? #1e2129 : #fafafa > .avatar filter saturate(200%) &:active background isDark ? #14161b : #eee &[data-is-read] &[data-is-me] opacity 0.8 &:not([data-is-me]):not([data-is-read]) > div background-image url("/assets/unread.svg") background-repeat no-repeat background-position 0 center &:after content "" display block clear both > div max-width 500px margin 0 auto padding 20px 30px &:after content "" display block clear both > header display flex align-items center margin-bottom 2px white-space nowrap overflow hidden > .name margin 0 padding 0 overflow hidden text-overflow ellipsis font-size 1em color isDark ? #fff : rgba(#000, 0.9) font-weight bold transition all 0.1s ease > .username margin 0 8px color isDark ? #606984 : rgba(#000, 0.5) > .mk-time margin 0 0 0 auto color isDark ? #606984 : rgba(#000, 0.5) font-size 80% > .avatar float left width 54px height 54px margin 0 16px 0 0 border-radius 8px transition all 0.1s ease > .body > .text display block margin 0 0 0 0 padding 0 overflow hidden overflow-wrap break-word font-size 1.1em color isDark ? #fff : rgba(#000, 0.8) .me color isDark ? rgba(#fff, 0.7) : rgba(#000, 0.4) > .image display block max-width 100% max-height 512px > .no-history margin 0 padding 2em 1em text-align center color #999 font-weight 500 > .fetching margin 0 padding 16px text-align center color #aaa > [data-fa] margin-right 4px // TODO: element base media query @media (max-width 400px) > .search > .result > .users > li padding 8px 16px > .history > a &:not([data-is-me]):not([data-is-read]) > div background-image none border-left solid 4px #3aa2dc > div padding 16px font-size 14px > .avatar margin 0 12px 0 0 .mk-messaging[data-darkmode] root(true) .mk-messaging:not([data-darkmode]) root(false) </style>