diff options
Diffstat (limited to 'src')
29 files changed, 701 insertions, 234 deletions
@@ -1,13 +1,15 @@ import UserPanel from './components/user_panel/user_panel.vue' import NavPanel from './components/nav_panel/nav_panel.vue' import Notifications from './components/notifications/notifications.vue' +import UserFinder from './components/user_finder/user_finder.vue' export default { name: 'app', components: { UserPanel, NavPanel, - Notifications + Notifications, + UserFinder }, data: () => ({ mobileActivePanel: 'timeline' diff --git a/src/App.scss b/src/App.scss index 0a7e1ce5..a5f190cb 100644 --- a/src/App.scss +++ b/src/App.scss @@ -52,6 +52,8 @@ button{ .item { flex: 1; + line-height: 21px; + height: 21px; } .gaps > .item { @@ -134,11 +136,6 @@ main-router { background-color: rgba(0,0,0,0.1); } -.media-body { - flex: 1; - padding-left: 0.5em; -} - .container > * { min-width: 0px; } @@ -147,60 +144,6 @@ main-router { color: grey; } -.status-actions { - width: 50%; - display: flex; - - div, favorite-button { - flex: 1; - } -} - -status-text-container { - display: block; -} - -.status-el { - line-height: 18px; - - .notify { - .avatar { - border-width: 3px; - border-style: solid; - } - } - - .media-left { - img { - margin-top: 0.2em; - float: right; - margin-right: 0.3em; - border-radius: 5px; - } - } - - .retweet-info { - padding: 0.7em 0 0 0.6em; - - .media-left { - display: flex; - - i { - align-self: center; - text-align: right; - flex: 1; - padding-right: 0.3em; - } - } - } - - .media-heading { - small { - font-weight: lighter; - } - margin-bottom: 0.3em; - } -} nav { z-index: 1000; } diff --git a/src/App.vue b/src/App.vue index fcfdae97..b2d8df8b 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,6 +6,7 @@ <router-link :to="{ name: 'root'}">{{sitename}}</router-link> </div> <div class='item right'> + <user-finder></user-finder> <router-link :to="{ name: 'settings'}"><i class="icon-cog"></i></router-link> </div> </div> diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index d50664b6..d45a6825 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -11,7 +11,7 @@ <img class="base03-border" referrerpolicy="no-referrer" :src="attachment.large_thumb_url || attachment.url"/> </a> - <video v-if="type === 'video' && !hidden" :src="attachment.url" controls></video> + <video v-if="type === 'video' && !hidden" :src="attachment.url" controls loop></video> <audio v-if="type === 'audio'" :src="attachment.url" controls></audio> diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index e89fa84f..059028f9 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,4 +1,4 @@ -import { filter, sortBy } from 'lodash' +import { find, filter, sortBy } from 'lodash' import { statusType } from '../../modules/statuses.js' import Status from '../status/status.vue' @@ -10,7 +10,12 @@ const sortAndFilterConversation = (conversation) => { const conversation = { data () { return { - highlight: null + highlight: null, + preview: { + x: 0, + y: 0, + status: null + } } }, props: [ @@ -27,7 +32,6 @@ const conversation = { const conversationId = this.status.statusnet_conversation_id const statuses = this.$store.state.statuses.allStatuses const conversation = filter(statuses, { statusnet_conversation_id: conversationId }) - return sortAndFilterConversation(conversation) } }, @@ -46,6 +50,7 @@ const conversation = { const conversationId = this.status.statusnet_conversation_id this.$store.state.api.backendInteractor.fetchConversation({id: conversationId}) .then((statuses) => this.$store.dispatch('addNewStatuses', { statuses })) + .then(() => this.setHighlight(this.statusoid.id)) } else { const id = this.$route.params.id this.$store.state.api.backendInteractor.fetchStatus({id}) @@ -53,7 +58,21 @@ const conversation = { .then(() => this.fetchConversation()) } }, - focused: function (id) { + getReplies (id) { + let res = [] + id = Number(id) + let i + for (i = 0; i < this.conversation.length; i++) { + if (Number(this.conversation[i].in_reply_to_status_id) === id) { + res.push({ + name: `#${i}`, + id: this.conversation[i].id + }) + } + } + return res + }, + focused (id) { if (this.statusoid.retweeted_status) { return (id === this.statusoid.retweeted_status.id) } else { @@ -62,6 +81,15 @@ const conversation = { }, setHighlight (id) { this.highlight = Number(id) + }, + setPreview (id, x, y) { + if (id) { + this.preview.x = x + this.preview.y = y + this.preview.status = find(this.conversation, { id: id }) + } else { + this.preview.status = null + } } } } diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 8b1e00ac..e8d97f99 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -8,7 +8,17 @@ </div> <div class="panel-body"> <div class="timeline"> - <status v-for="status in conversation" @goto="setHighlight" :key="status.id" :statusoid="status" :expandable='false' :focused="focused(status.id)" :inConversation='true' :highlight="highlight"></status> + <status v-for="status in conversation" @goto="setHighlight" :key="status.id" @preview="setPreview" :statusoid="status" :expandable='false' :focused="focused(status.id)" :inConversation='true' :highlight="highlight" :replies="getReplies(status.id)"></status> + </div> + </div> + <div class="status-preview base00-background base03-border" :style="{ left: preview.x + 'px', top: preview.y + 'px'}" v-if="preview.status"> + <img class="avatar" :src="preview.status.user.profile_image_url_original"> + <div class="text"> + <h4> + {{ preview.status.user.name }} + <small><a>{{ preview.status.user.screen_name}}</a></small> + </h4> + <div @click.prevent="linkClicked" class="status-content" v-html="preview.status.statusnet_html"></div> </div> </div> </div> @@ -21,4 +31,30 @@ border-bottom-style: solid; border-bottom-width: 1px; } + + .status-preview { + position: absolute; + max-width: 35em; + padding: 0.5em; + display: flex; + border-color: inherit; + border-style: solid; + border-width: 1px; + border-radius: 4px; + box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5); + .avatar { + width: 32px; + height: 32px; + border-radius: 50%; + } + .text { + h4 { + margin-bottom: 0.4em; + small { + font-weight: lighter; + } + } + padding: 0 0.5em 0.5em 0.5em; + } + } </style> diff --git a/src/components/notifications/notifications.js b/src/components/notifications/notifications.js index c8d5e212..c0c86c68 100644 --- a/src/components/notifications/notifications.js +++ b/src/components/notifications/notifications.js @@ -1,3 +1,5 @@ +import Status from '../status/status.vue' + import { sortBy, take, filter } from 'lodash' const Notifications = { @@ -23,6 +25,9 @@ const Notifications = { return this.unseenNotifications.length } }, + components: { + Status + }, watch: { unseenCount (count) { if (count > 0) { diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index c7c650a9..f02ced8d 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -45,19 +45,23 @@ word-wrap: break-word; line-height:18px; - .icon-retweet { + .icon-retweet.lit { color: $green; } - .icon-reply { + .icon-reply.lit { color: $blue; } h1 { + word-break: break-all; margin: 0 0 0.3em; padding: 0; font-size: 1em; line-height:20px; + small { + font-weight: lighter; + } } padding: 0.3em 0.8em 0.5em; diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index a326e9cd..f5950ac9 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -7,23 +7,34 @@ <button @click.prevent="markAsSeen" class="base06 base02-background read-button">Read!</button> </div> <div class="panel-body base03-border"> - <div v-for="notification in visibleNotifications" class="notification" :class='{"unseen": !notification.seen}'> + <div v-for="notification in visibleNotifications" :key="notification" class="notification" :class='{"unseen": !notification.seen}'> <a :href="notification.action.user.statusnet_profile_url"> <img class='avatar' :src="notification.action.user.profile_image_url_original"> </a> - <div class='text'> - <timeago :since="notification.action.created_at" :auto-update="240"></timeago> + <div class='text' style="width: 100%;"> <div v-if="notification.type === 'favorite'"> - <h1>{{ notification.action.user.name }}<br><i class="fa icon-star"></i> favorited your <router-link :to="{ name: 'conversation', params: { id: notification.status.id } }">status</router-link></h1> - <p>{{ notification.status.text }}</p> + <h1> + <span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span> + <i class="fa icon-star"></i> + <small><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> + </h1> + <div v-html="notification.status.statusnet_html"></div> </div> <div v-if="notification.type === 'repeat'"> - <h1>{{ notification.action.user.name }}<br><i class="fa icon-retweet"></i> repeated your <router-link :to="{ name: 'conversation', params: { id: notification.status.id } }">status</router-link></h1> - <p>{{ notification.status.text }}</p> + <h1> + <span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span> + <i class="fa icon-retweet lit"></i> + <small><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> + </h1> + <div v-html="notification.status.statusnet_html"></div> </div> <div v-if="notification.type === 'mention'"> - <h1>{{ notification.action.user.name }}<br><i class="fa icon-reply"></i> <router-link :to="{ name: 'conversation', params: { id: notification.status.id } }">mentioned</router-link> you</h1> - <p>{{ notification.status.text }}</p> + <h1> + <span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span> + <i class="fa icon-reply lit"></i> + <small><router-link :to="{ name: 'conversation', params: { id: notification.status.id } }"><timeago :since="notification.action.created_at" :auto-update="240"></timeago></router-link></small> + </h1> + <status :compact="true" :statusoid="notification.status"></status> </div> </div> </div> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 01aeeb68..a8b4d39c 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -1,10 +1,8 @@ import statusPoster from '../../services/status_poster/status_poster.service.js' import MediaUpload from '../media_upload/media_upload.vue' import fileTypeService from '../../services/file_type/file_type.service.js' -import Tribute from '../../../node_modules/tributejs/src/Tribute.js' -require('../../../node_modules/tributejs/scss/tribute.scss') - -import { merge, reject, map, uniqBy } from 'lodash' +import Completion from '../../services/completion/completion.js' +import { take, filter, reject, map, uniqBy } from 'lodash' const buildMentionsString = ({user, attentions}, currentUser) => { let allAttentions = [...attentions] @@ -21,51 +19,6 @@ const buildMentionsString = ({user, attentions}, currentUser) => { return mentions.join(' ') + ' ' } -const defaultCollection = { - // symbol that starts the lookup - trigger: '@', - - // element to target for @mentions - iframe: null, - - // class added in the flyout menu for active item - selectClass: 'highlight', - - // function called on select that returns the content to insert - selectTemplate: function (item) { - return '@' + item.original.screen_name - }, - - // template for displaying item in menu - menuItemTemplate: function (item) { - return `<img src="${item.original.profile_image_url}"></img> <div class='name'>${item.string}</div>` - }, - - // template for when no match is found (optional), - // If no template is provided, menu is hidden. - noMatchTemplate: null, - - // specify an alternative parent container for the menu - menuContainer: document.body, - - // column to search against in the object (accepts function or string) - lookup: ({name, screen_name}) => `${name} (@${screen_name})`, // eslint-disable-line camelcase - - // column that contains the content to insert by default - fillAttr: 'screen_name', - - // REQUIRED: array of objects to match - values: [], - - // specify whether a space is required before the trigger character - requireLeadingSpace: true, - - // specify whether a space is allowed in the middle of mentions - allowSpaces: false -} - -const tribute = new Tribute({ collection: [] }) - const PostStatusForm = { props: [ 'replyTo', @@ -89,30 +42,48 @@ const PostStatusForm = { newStatus: { status: statusText, files: [] - } + }, + caret: 0 } }, computed: { + candidates () { + if (this.textAtCaret.charAt(0) === '@') { + const matchedUsers = filter(this.users, (user) => (user.name + user.screen_name).match(this.textAtCaret.slice(1))) + if (matchedUsers.length <= 0) { + return false + } + // eslint-disable-next-line camelcase + return map(take(matchedUsers, 5), ({screen_name, name, profile_image_url_original}) => ({ + screen_name: screen_name, + name: name, + img: profile_image_url_original + })) + } else { + return false + } + }, + textAtCaret () { + return (this.wordAtCaret || {}).word || '' + }, + wordAtCaret () { + const word = Completion.wordAtPosition(this.newStatus.status, this.caret - 1) || {} + return word + }, users () { return this.$store.state.users.users - }, - completions () { - let users = this.users - users = merge({values: users}, defaultCollection) - return [users] } }, - watch: { - completions () { - tribute.collection = this.completions - } - }, - mounted () { - const textarea = this.$el.querySelector('textarea') - tribute.collection = this.completions - tribute.attach(textarea) - }, methods: { + replace (replacement) { + this.newStatus.status = Completion.replaceWord(this.newStatus.status, this.wordAtCaret, replacement) + const el = this.$el.querySelector('textarea') + el.focus() + this.caret = 0 + }, + setCaret ({target: {selectionStart}}) { + this.caret = selectionStart + }, postStatus (newStatus) { statusPoster.postStatus({ status: newStatus.status, @@ -125,6 +96,8 @@ const PostStatusForm = { files: [] } this.$emit('posted') + let el = this.$el.querySelector('textarea') + el.style.height = '16px' }, addMediaFile (fileInfo) { this.newStatus.files.push(fileInfo) @@ -151,6 +124,13 @@ const PostStatusForm = { }, fileDrag (e) { e.dataTransfer.dropEffect = 'copy' + }, + resize (e) { + e.target.style.height = 'auto' + e.target.style.height = `${e.target.scrollHeight - 10}px` + if (e.target.value === '') { + e.target.style.height = '16px' + } } } } diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index e7143b62..a17d6479 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -1,8 +1,23 @@ <template> <div class="post-status-form"> <form @submit.prevent="postStatus(newStatus)"> - <div class="form-group" > - <textarea v-model="newStatus.status" placeholder="Just landed in L.A." rows="3" class="form-control" @keyup.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag"></textarea> + <div class="form-group base03-border" > + <textarea @click="setCaret" @keyup="setCaret" v-model="newStatus.status" placeholder="Just landed in L.A." rows="1" class="form-control" @keydown.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize"></textarea> + </div> + <div style="position:relative;" v-if="candidates"> + <div class="autocomplete-panel base05-background"> + <div v-for="candidate in candidates" @click="replace('@' + candidate.screen_name + ' ')" class="autocomplete base01"> + <img :src="candidate.img"></img> + <span> + @{{candidate.screen_name}} + <small class="base02">{{candidate.name}}</small> + </span> + </div> + </div> + </div> + <div class='form-bottom'> + <media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload> + <button :disabled="submitDisabled" type="submit" class="btn btn-default base05 base01-background">Submit</button> </div> <div class="attachments"> <div class="attachment" v-for="file in newStatus.files"> @@ -13,10 +28,6 @@ <a v-if="type(file) === 'unknown'" :href="file.image">{{file.url}}</a> </div> </div> - <div class='form-bottom'> - <media-upload @uploading="disableSubmit" @uploaded="addMediaFile" @upload-failed="enableSubmit" :drop-files="dropFiles"></media-upload> - <button :disabled="submitDisabled" type="submit" class="btn btn-default base05 base01-background">Submit</button> - </div> </form> </div> </template> @@ -44,14 +55,20 @@ .form-bottom { display: flex; padding: 0.5em; + height: 32px; button { - flex: 2; + width: 10em; } } .attachments { - padding: 0.5em; + padding: 0 0.5em; + + .attachment { + position: relative; + margin: 0.5em 0.8em 0.2em 0; + } i { position: absolute; @@ -91,11 +108,56 @@ form textarea { border: solid; border-width: 1px; - border-color: silver; + border-color: inherit; border-radius: 5px; line-height:16px; padding: 5px; - resize: vertical; + resize: none; + overflow: hidden; + } + + form textarea:focus { + min-height: 48px; + } + + .btn { + cursor: pointer; + } + + .btn[disabled] { + cursor: not-allowed; + } + + .icon-cancel { + cursor: pointer; + } + + .autocomplete-panel { + margin: 0 0.5em 0 0.5em; + border-radius: 5px; + position: absolute; + z-index: 1; + box-shadow: 1px 2px 4px rgba(0, 0, 0, 0.5); + min-width: 75%; + } + + .autocomplete { + cursor: pointer; + padding: 0.2em 0.4em 0.2em 0.4em; + border-bottom: 1px solid rgba(0, 0, 0, 0.4); + display: flex; + img { + width: 24px; + height: 24px; + border-radius: 2px; + } + span { + line-height: 24px; + margin: 0 0.1em 0 0.2em; + } + small { + font-style: italic; + } } } diff --git a/src/components/settings/settings.js b/src/components/settings/settings.js index e4ea56f6..b8aa876b 100644 --- a/src/components/settings/settings.js +++ b/src/components/settings/settings.js @@ -8,7 +8,10 @@ const settings = { hideAttachmentsInConvLocal: this.$store.state.config.hideAttachmentsInConv, hideNsfwLocal: this.$store.state.config.hideNsfw, muteWordsString: this.$store.state.config.muteWords.join('\n'), - previewfile: null + previewfile: null, + autoLoadLocal: this.$store.state.config.autoLoad, + hoverPreviewLocal: this.$store.state.config.hoverPreview, + muteWordsString: this.$store.state.config.muteWords.join('\n') } }, components: { @@ -58,6 +61,12 @@ const settings = { hideNsfwLocal (value) { this.$store.dispatch('setOption', { name: 'hideNsfw', value }) }, + autoLoadLocal (value) { + this.$store.dispatch('setOption', { name: 'autoLoad', value }) + }, + hoverPreviewLocal (value) { + this.$store.dispatch('setOption', { name: 'hoverPreview', value }) + }, muteWordsString (value) { value = filter(value.split('\n'), (word) => trim(word).length > 0) this.$store.dispatch('setOption', { name: 'muteWords', value }) diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index bdc0f351..f2442194 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -40,6 +40,14 @@ <input type="checkbox" id="hideNsfw" v-model="hideNsfwLocal"> <label for="hideNsfw">Enable clickthrough NSFW attachment hiding</label> </li> + <li> + <input type="checkbox" id="autoLoad" v-model="autoLoadLocal"> + <label for="autoLoad">Enable automatic loading when scrolled to the bottom</label> + </li> + <li> + <input type="checkbox" id="hoverPreview" v-model="hoverPreviewLocal"> + <label for="hoverPreview">Enable reply-link preview on mouse hover</label> + </li> </ul> </div> </div> diff --git a/src/components/status/status.js b/src/components/status/status.js index 5e7bde53..4f5093e1 100644 --- a/src/components/status/status.js +++ b/src/components/status/status.js @@ -12,7 +12,9 @@ const Status = { 'expandable', 'inConversation', 'focused', - 'highlight' + 'highlight', + 'compact', + 'replies' ], data: () => ({ replying: false, @@ -86,9 +88,9 @@ const Status = { toggleReplying () { this.replying = !this.replying }, - gotoOriginal () { + gotoOriginal (id) { // only handled by conversation, not status_or_conversation - this.$emit('goto', this.status.in_reply_to_status_id) + this.$emit('goto', id) }, toggleExpanded () { this.$emit('toggleExpanded') @@ -98,6 +100,15 @@ const Status = { }, toggleUserExpanded () { this.userExpanded = !this.userExpanded + }, + replyEnter (id, event) { + if (this.$store.state.config.hoverPreview) { + let rect = event.target.getBoundingClientRect() + this.$emit('preview', Number(id), rect.left + 20, rect.top + 20 + window.pageYOffset) + } + }, + replyLeave () { + this.$emit('preview', 0, 0, 0) } }, watch: { @@ -107,9 +118,8 @@ const Status = { let rect = this.$el.getBoundingClientRect() if (rect.top < 100) { window.scrollBy(0, rect.top - 200) - } else if (rect.bottom > window.innerHeight - 100) { - // will be useful when scrolling down to replies or root posts is in - window.scrollBy(0, rect.bottom + 200) + } else if (rect.bottom > window.innerHeight - 50) { + window.scrollBy(0, rect.bottom - window.innerHeight + 50) } } } diff --git a/src/components/status/status.vue b/src/components/status/status.vue index db33a200..e582a80d 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -1,10 +1,25 @@ <template> - <div class="status-el base00-background base03-border" v-if="!status.deleted" v-bind:class="[{ 'base01-background': isFocused }, { 'status-conversation': inConversation }]" > + <div class="status-el base00-background" v-if="compact"> + <div @click.prevent="linkClicked" class="status-content" v-html="status.statusnet_html"></div> + <div v-if="loggedIn"> + <div class='status-actions'> + <div> + <a href="#" v-on:click.prevent="toggleReplying"> + <i class="fa icon-reply" :class="{'icon-reply-active': replying}"></i> + </a> + </div> + <retweet-button :status=status></retweet-button> + <favorite-button :status=status></favorite-button> + </div> + </div> + <post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" v-on:posted="toggleReplying" v-if="replying"/> + </div> + <div class="status-el base00-background base03-border" v-else-if="!status.deleted" v-bind:class="[{ 'base01-background': isFocused }, { 'status-conversation': inConversation }]" > <template v-if="muted"> <div class="media status container muted"> <small><router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small> <small class="muteWords">{{muteWordHits.join(', ')}}</small> - <a href="#" class="unmute" @click.prevent="toggleMute"><i class="icon-eye-off"></i></a> + <a href="#" class="unmute" @click.prevent="toggleMute"><i class="fa icon-eye-off"></i></a> </div> </template> <template v-if="!muted"> @@ -13,13 +28,14 @@ <i class='fa icon-retweet retweeted'></i> </div> <div class="media-body"> - Retweeted by {{retweeter}} + Repeated by <a :href="statusoid.user.statusnet_profile_url" style="font-weight: bold;" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a> </div> </div> <div class="media status container"> <div class="media-left"> <a :href="status.user.statusnet_profile_url"> - <img @click.prevent="toggleUserExpanded" class='avatar' :src="status.user.profile_image_url_original"> + <img @click.prevent="toggleUserExpanded" :class="{retweeted: retweet}" class='avatar' :src="status.user.profile_image_url_original"> + <img v-if="retweet" class='avatar-retweeter' :src="statusoid.user.profile_image_url_original"></img> </a> </div> <div class="media-body"> @@ -27,38 +43,45 @@ <user-card-content :user="status.user"></user-card-content> </div> <div class="user-content"> - <h4 class="media-heading"> - {{status.user.name}} - <small><router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small> - <small v-if="status.in_reply_to_screen_name"> > - <router-link :to="{ name: 'user-profile', params: { id: status.in_reply_to_user_id } }"> - {{status.in_reply_to_screen_name}} - </router-link> - </small> - <template v-if="isReply && !expandable"> - <small> - <a href="#" @click.prevent="gotoOriginal" ><i class="icon-reply"></i></a> - </small> - </template> - - - <small> - <router-link :to="{ name: 'conversation', params: { id: status.id } }"> - <timeago :since="status.created_at" :auto-update="60"></timeago> - </router-link> - </small> - <template v-if="expandable"> - - - <small> - <a href="#" @click.prevent="toggleExpanded" ><i class="icon-plus-squared"></i></a> - </small> - </template> - <small v-if="unmuted"> - <a href="#" @click.prevent="toggleMute" ><i class="icon-eye-off"></i></a> - </small> - <small v-if="!status.is_local" class="source_url"> - <a :href="status.external_url" target="_blank" ><i class="icon-binoculars"></i></a> - </small> - </h4> + <div class="media-heading"> + <div class="name-and-links"> + <h4 class="user-name">{{status.user.name}}</h4> + <div class="links"> + <h4> + <small><router-link :to="{ name: 'user-profile', params: { id: status.user.id } }">{{status.user.screen_name}}</router-link></small> + <small v-if="status.in_reply_to_screen_name"> > + <router-link :to="{ name: 'user-profile', params: { id: status.in_reply_to_user_id } }"> + {{status.in_reply_to_screen_name}} + </router-link> + </small> + <template v-if="isReply && !expandable"> + <small> + <a href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"><i class="icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i></a> + </small> + </template> + - + <small> + <router-link :to="{ name: 'conversation', params: { id: status.id } }"> + <timeago :since="status.created_at" :auto-update="60"></timeago> + </router-link> + </small> + </h4> + </div> + <h4 class="replies" v-if="inConversation"> + <small v-if="replies.length">Replies:</small> + <small v-for="reply in replies"> + <a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}} </a> + </small> + </h4> + </div> + <div class="heading-icons"> + <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="fa icon-eye-off"></i></a> + <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url"><i class="fa icon-binoculars"></i></a> + <template v-if="expandable"> + <a href="#" @click.prevent="toggleExpanded" class="expand"><i class="fa icon-plus-squared"></i></a> + </template> + </div> + </div> <div @click.prevent="linkClicked" class="status-content" v-html="status.statusnet_html"></div> @@ -94,24 +117,65 @@ <style lang="scss"> @import '../../_variables.scss'; + + status-text-container { + display: block; +} + .status-el { hyphens: auto; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; border-left-width: 0px; + line-height: 18px; + + .notify { + .avatar { + border-width: 3px; + border-style: solid; + } + } + + .media-body { + flex: 1; + padding-left: 0.5em; + } + .user-content { + min-height: 52px; padding-top: 1px; } + .media-heading { + display: flex; + min-height: 1.4em; + margin-bottom: 0.3em; + + small { + font-weight: lighter; + } + h4 { + margin-right: 0.4em; + } + .name-and-links { + flex: 1 0; + display: flex; + flex-wrap: wrap; + } + .replies { + flex-basis: 100%; + } + } + .source_url { - float: right; + } - .greentext { - color: green; + .expand { + margin-right: -0.3em; } a { @@ -128,6 +192,34 @@ margin-top: 0.2em; margin-bottom: 0.5em; } + + .media-left { + img { + margin-top: 0.2em; + float: right; + margin-right: 0.3em; + border-radius: 5px; + } + } + + .retweet-info { + padding: 0.7em 0 0 0.6em; + + .media-left { + display: flex; + + i { + align-self: center; + text-align: right; + flex: 1; + padding-right: 0.3em; + } + } + } + } + + .greentext { + color: green; } .status-conversation { @@ -135,7 +227,14 @@ } .status-actions { - padding-top: 5px; + padding-top: 0.15em; + width: 100%; + display: flex; + + div, favorite-button { + max-width: 6em; + flex: 1; + } } .icon-reply:hover { @@ -147,7 +246,23 @@ } .status .avatar { - width: 48px; + width: 48px; + height: 48px; + + &.retweeted { + width: 40px; + height: 40px; + margin-right: 8px; + margin-bottom: 8px; + } + } + + .status img.avatar-retweeter { + width: 24px; + height: 24px; + position: absolute; + margin-left: 24px; + margin-top: 24px; } .status.compact .avatar { @@ -155,14 +270,15 @@ } .status { - padding: 0.65em 0.7em 0.8em 0.8em; + padding: 0.4em 0.7em 0.45em 0.7em; border-bottom: 1px solid; border-bottom-color: inherit; border-left: 4px rgba(255, 48, 16, 0.65); border-left-style: inherit; } + .muted { - padding: 0.1em 0.7em 0.1em 0.8em; + padding: 0.1em 0.4em 0.1em 0.8em; button { margin-left: auto; } @@ -194,4 +310,35 @@ flex: 1; } + @media all and (max-width: 960px) { + .status-el { + .name-and-links { + margin-left: -0.25em; + } + } + .status { + max-width: 100%; + } + + .status .avatar { + width: 40px; + height: 40px; + + &.retweeted { + width: 34px; + height: 34px; + margin-right: 8px; + margin-bottom: 8px; + } + } + + .status img.avatar-retweeter { + width: 22px; + height: 22px; + position: absolute; + margin-left: 18px; + margin-top: 18px; + } + } + </style> diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index d5a9adcc..3dc07f9e 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -6,7 +6,8 @@ const Timeline = { props: [ 'timeline', 'timelineName', - 'title' + 'title', + 'userId' ], computed: { timelineError () { return this.$store.state.statuses.error } @@ -20,11 +21,14 @@ const Timeline = { const credentials = store.state.users.currentUser.credentials const showImmediately = this.timeline.visibleStatuses.length === 0 + window.onscroll = this.scrollLoad + timelineFetcher.fetchAndUpdate({ store, credentials, timeline: this.timelineName, - showImmediately + showImmediately, + userId: this.userId }) }, methods: { @@ -40,8 +44,15 @@ const Timeline = { credentials, timeline: this.timelineName, older: true, - showImmediately: true + showImmediately: true, + userId: this.userId }).then(() => store.commit('setLoading', { timeline: this.timelineName, value: false })) + }, + scrollLoad (e) { + let height = Math.max(document.body.offsetHeight, document.body.scrollHeight) + if (this.timeline.loading === false && this.$store.state.config.autoLoad && (window.innerHeight + window.pageYOffset) >= (height - 750)) { + this.fetchOlderStatuses() + } } } } diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index bba16584..ff1b108c 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -61,10 +61,13 @@ props: [ 'user' ], computed: { headingStyle () { - let rgb = this.$store.state.config.colors['base00'].match(/\d+/g) - return { - backgroundColor: `rgb(${Math.floor(rgb[0] * 0.53)}, ${Math.floor(rgb[1] * 0.56)}, ${Math.floor(rgb[2] * 0.59)})`, - backgroundImage: `url(${this.user.cover_photo})` + let color = this.$store.state.config.colors['base00'] + if (color) { + let rgb = this.$store.state.config.colors['base00'].match(/\d+/g) + return { + backgroundColor: `rgb(${Math.floor(rgb[0] * 0.53)}, ${Math.floor(rgb[1] * 0.56)}, ${Math.floor(rgb[2] * 0.59)})`, + backgroundImage: `url(${this.user.cover_photo})` + } } }, bodyStyle () { diff --git a/src/components/user_finder/user_finder.js b/src/components/user_finder/user_finder.js new file mode 100644 index 00000000..03205382 --- /dev/null +++ b/src/components/user_finder/user_finder.js @@ -0,0 +1,22 @@ +const UserFinder = { + data: () => ({ + username: undefined, + hidden: true + }), + methods: { + findUser (username) { + this.$store.state.api.backendInteractor.externalProfile(username) + .then((user) => { + if (!user.error) { + this.$store.commit('addNewUsers', [user]) + this.$router.push({name: 'user-profile', params: {id: user.id}}) + } + }) + }, + toggleHidden () { + this.hidden = !this.hidden + } + } +} + +export default UserFinder diff --git a/src/components/user_finder/user_finder.vue b/src/components/user_finder/user_finder.vue new file mode 100644 index 00000000..c23d8ee0 --- /dev/null +++ b/src/components/user_finder/user_finder.vue @@ -0,0 +1,23 @@ +<template> + <a href="#" v-if="hidden"><i class="icon-user-plus user-finder-icon" @click.prevent="toggleHidden"/></a> + <span v-else> + <input class="user-finder-input base03-border" @keyup.enter="findUser(username)" v-model="username" placeholder="Find user" id="user-finder-input" type="text"/> + <i class="icon-cancel user-finder-icon" @click="toggleHidden"/> + </span> +</template> + +<script src="./user_finder.js"></script> + +<style lang="scss"> + .user-finder-icon { + margin-right: 0.25em; + } + + .user-finder-input { + border-width: 1px; + border-style: solid; + border-color: inherit; + border-radius: 5px; + padding: 0.1em 0.2em 0.2em 0.2em; + } +</style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 4d52bc95..5f9d4d08 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -1,16 +1,30 @@ import UserCardContent from '../user_card_content/user_card_content.vue' -import { find } from 'lodash' +import Timeline from '../timeline/timeline.vue' const UserProfile = { + created () { + this.$store.commit('clearTimeline', { timeline: 'user' }) + this.$store.dispatch('startFetching', ['user', this.userId]) + }, + destroyed () { + this.$store.dispatch('stopFetching', 'user') + }, computed: { + timeline () { return this.$store.state.statuses.timelines.user }, + userId () { + return this.$route.params.id + }, user () { - const id = this.$route.params.id - const user = find(this.$store.state.users.users, {id}) - return user + if (this.timeline.statuses[0]) { + return this.timeline.statuses[0].user + } else { + return false + } } }, components: { - UserCardContent + UserCardContent, + Timeline } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 11a61bfc..9241c469 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -1,6 +1,9 @@ <template> - <div class="user-profile panel panel-default base00-background"> - <user-card-content :user="user"></user-card-content> + <div> + <div v-if="user" class="user-profile panel panel-default base00-background"> + <user-card-content :user="user"></user-card-content> + </div> + <Timeline :title="'User Timeline'" v-bind:timeline="timeline" v-bind:timeline-name="'user'" :user-id="userId"/> </div> </template> diff --git a/src/main.js b/src/main.js index cb59746d..dd59dea7 100644 --- a/src/main.js +++ b/src/main.js @@ -25,7 +25,7 @@ Vue.use(VueRouter) Vue.use(VueTimeago, { locale: 'en-US', locales: { - 'en-US': require('vue-timeago/locales/en-US.json') + 'en-US': require('../static/timeago.json') } }) @@ -34,6 +34,8 @@ const persistedStateOptions = { 'config.hideAttachments', 'config.hideAttachmentsInConv', 'config.hideNsfw', + 'config.autoLoad', + 'config.hoverPreview', 'config.muteWords', 'statuses.notifications', 'users.users' diff --git a/src/modules/api.js b/src/modules/api.js index a32adfde..e61382eb 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -1,4 +1,5 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' +import {isArray} from 'lodash' const api = { state: { @@ -18,9 +19,17 @@ const api = { }, actions: { startFetching (store, timeline) { + let userId = false + + // This is for user timelines + if (isArray(timeline)) { + userId = timeline[1] + timeline = timeline[0] + } + // Don't start fetching if we already are. if (!store.state.fetchers[timeline]) { - const fetcher = store.state.backendInteractor.startFetching({timeline, store}) + const fetcher = store.state.backendInteractor.startFetching({timeline, store, userId}) store.commit('addFetcher', {timeline, fetcher}) } }, diff --git a/src/modules/config.js b/src/modules/config.js index f59dc6f0..f7d6e9c8 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -7,6 +7,8 @@ const defaultState = { hideAttachments: false, hideAttachmentsInConv: false, hideNsfw: true, + autoLoad: true, + hoverPreview: true, muteWords: [] } diff --git a/src/modules/statuses.js b/src/modules/statuses.js index 051ec71b..084800fa 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -1,4 +1,4 @@ -import { remove, slice, sortBy, toInteger, each, find, flatten, maxBy, last, merge, max, isArray } from 'lodash' +import { includes, remove, slice, sortBy, toInteger, each, find, flatten, maxBy, last, merge, max, isArray } from 'lodash' import apiService from '../services/api/api.service.js' // import parse from '../services/status_parser/status_parser.js' @@ -32,6 +32,17 @@ export const defaultState = { minVisibleId: 0, loading: false }, + user: { + statuses: [], + statusesObject: {}, + faves: [], + visibleStatuses: [], + visibleStatusesObject: {}, + newStatusCount: 0, + maxId: 0, + minVisibleId: 0, + loading: false + }, publicAndExternal: { statuses: [], statusesObject: {}, @@ -57,11 +68,15 @@ export const defaultState = { } } +const isNsfw = (status) => { + const nsfwRegex = /#nsfw/i + return includes(status.tags, 'nsfw') || !!status.text.match(nsfwRegex) +} + export const prepareStatus = (status) => { // Parse nsfw tags if (status.nsfw === undefined) { - const nsfwRegex = /#nsfw/i - status.nsfw = !!status.text.match(nsfwRegex) + status.nsfw = isNsfw(status) } // Set deleted flag @@ -242,6 +257,14 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us const uri = deletion.uri updateMaxId(deletion) + // Remove possible notification + const status = find(allStatuses, {uri}) + if (!status) { + return + } + + remove(state.notifications, ({action: {id}}) => id === status.id) + remove(allStatuses, { uri }) if (timeline) { remove(timelineObject.statuses, { uri }) @@ -276,6 +299,21 @@ export const mutations = { oldTimeline.visibleStatusesObject = {} each(oldTimeline.visibleStatuses, (status) => { oldTimeline.visibleStatusesObject[status.id] = status }) }, + clearTimeline (state, { timeline }) { + const emptyTimeline = { + statuses: [], + statusesObject: {}, + faves: [], + visibleStatuses: [], + visibleStatusesObject: {}, + newStatusCount: 0, + maxId: 0, + minVisibleId: 0, + loading: false + } + + state.timelines[timeline] = emptyTimeline + }, setFavorited (state, { status, value }) { const newStatus = state.allStatusesObject[status.id] newStatus.favorited = value diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index b0e8dd87..e848d076 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -19,9 +19,11 @@ const UNFOLLOWING_URL = '/api/friendships/destroy.json' const QVITTER_USER_PREF_URL = '/api/qvitter/set_profile_pref.json' const REGISTRATION_URL = '/api/account/register.json' const AVATAR_UPDATE_URL = '/api/qvitter/update_avatar.json' +const EXTERNAL_PROFILE_URL = '/api/externalprofile/show.json' +const QVITTER_USER_TIMELINE_URL = '/api/qvitter/statuses/user_timeline.json' // const USER_URL = '/api/users/show.json' -import { each } from 'lodash' +import { each, map } from 'lodash' const oldfetch = window.fetch @@ -88,6 +90,13 @@ const authHeaders = (user) => { } } +const externalProfile = (profileUrl) => { + let url = `${EXTERNAL_PROFILE_URL}?profileurl=${profileUrl}` + return fetch(url, { + method: 'GET' + }).then((data) => data.json()) +} + const followUser = ({id, credentials}) => { let url = `${FOLLOWING_URL}?user_id=${id}` return fetch(url, { @@ -143,24 +152,34 @@ const setUserMute = ({id, credentials, muted = true}) => { }) } -const fetchTimeline = ({timeline, credentials, since = false, until = false}) => { +const fetchTimeline = ({timeline, credentials, since = false, until = false, userId = false}) => { const timelineUrls = { public: PUBLIC_TIMELINE_URL, friends: FRIENDS_TIMELINE_URL, mentions: MENTIONS_URL, - 'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL + 'publicAndExternal': PUBLIC_AND_EXTERNAL_TIMELINE_URL, + user: QVITTER_USER_TIMELINE_URL } let url = timelineUrls[timeline] + let params = [] + if (since) { - url += `?since_id=${since}` + params.push(['since_id', since]) } if (until) { - url += `?max_id=${until}` + params.push(['max_id', until]) + } + + if (userId) { + params.push(['user_id', userId]) } + const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') + url += `?${queryString}` + return fetch(url, { headers: authHeaders(credentials) }).then((data) => data.json()) } @@ -253,7 +272,8 @@ const apiService = { setUserMute, fetchMutes, register, - updateAvatar + updateAvatar, + externalProfile } export default apiService diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index ceb559b6..5dbbf4b3 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -26,8 +26,8 @@ const backendInteractorService = (credentials) => { return apiService.unfollowUser({credentials, id}) } - const startFetching = ({timeline, store}) => { - return timelineFetcherService.startFetching({timeline, store, credentials}) + const startFetching = ({timeline, store, userId = false}) => { + return timelineFetcherService.startFetching({timeline, store, credentials, userId}) } const setUserMute = ({id, muted = true}) => { @@ -38,6 +38,7 @@ const backendInteractorService = (credentials) => { const register = (params) => apiService.register(params) const updateAvatar = ({params}) => apiService.updateAvatar({credentials, params}) + const externalProfile = (profileUrl) => apiService.externalProfile(profileUrl) const backendInteractorServiceInstance = { fetchStatus, @@ -51,7 +52,8 @@ const backendInteractorService = (credentials) => { setUserMute, fetchMutes, register, - updateAvatar + updateAvatar, + externalProfile } return backendInteractorServiceInstance diff --git a/src/services/completion/completion.js b/src/services/completion/completion.js new file mode 100644 index 00000000..8788d837 --- /dev/null +++ b/src/services/completion/completion.js @@ -0,0 +1,70 @@ +import { reduce, find } from 'lodash' + +export const replaceWord = (str, toReplace, replacement) => { + return str.slice(0, toReplace.start) + replacement + str.slice(toReplace.end) +} + +export const wordAtPosition = (str, pos) => { + const words = splitIntoWords(str) + const wordsWithPosition = addPositionToWords(words) + + return find(wordsWithPosition, ({start, end}) => start <= pos && end > pos) +} + +export const addPositionToWords = (words) => { + return reduce(words, (result, word) => { + const data = { + word, + start: 0, + end: word.length + } + + if (result.length > 0) { + const previous = result.pop() + + data.start += previous.end + data.end += previous.end + + result.push(previous) + } + + result.push(data) + + return result + }, []) +} + +export const splitIntoWords = (str) => { + // Split at word boundaries + const regex = /\b/ + const triggers = /[@#]+$/ + + let split = str.split(regex) + + // Add trailing @ and # to the following word. + const words = reduce(split, (result, word) => { + if (result.length > 0) { + let previous = result.pop() + const matches = previous.match(triggers) + if (matches) { + previous = previous.replace(triggers, '') + word = matches[0] + word + } + result.push(previous) + } + result.push(word) + + return result + }, []) + + return words +} + +const completion = { + wordAtPosition, + addPositionToWords, + splitIntoWords, + replaceWord +} + +export default completion diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index 24aef069..b28de9e7 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -14,7 +14,7 @@ const update = ({store, statuses, timeline, showImmediately}) => { }) } -const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false}) => { +const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false, showImmediately = false, userId = false}) => { const args = { timeline, credentials } const rootState = store.rootState || store.state const timelineData = rootState.statuses.timelines[camelCase(timeline)] @@ -25,14 +25,16 @@ const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false args['since'] = timelineData.maxId } + args['userId'] = userId + return apiService.fetchTimeline(args) .then((statuses) => update({store, statuses, timeline, showImmediately}), () => store.dispatch('setError', { value: true })) } -const startFetching = ({ timeline = 'friends', credentials, store }) => { - fetchAndUpdate({timeline, credentials, store, showImmediately: true}) - const boundFetchAndUpdate = () => fetchAndUpdate({ timeline, credentials, store }) +const startFetching = ({timeline = 'friends', credentials, store, userId = false}) => { + fetchAndUpdate({timeline, credentials, store, showImmediately: true, userId}) + const boundFetchAndUpdate = () => fetchAndUpdate({ timeline, credentials, store, userId }) return setInterval(boundFetchAndUpdate, 10000) } const timelineFetcher = { |
