diff --git a/config/index.js b/config/index.js index 6652048c..c48d91b8 100644 --- a/config/index.js +++ b/config/index.js @@ -23,9 +23,15 @@ module.exports = { assetsPublicPath: '/', proxyTable: { '/api': { - target: 'https://social.heldscal.la/', + target: 'htts://localhost:4000/', changeOrigin: true, cookieDomainRewrite: 'localhost' + }, + '/socket': { + target: 'htts://localhost:4000/', + changeOrigin: true, + cookieDomainRewrite: 'localhost', + ws: true } }, // CSS Sourcemaps off by default because relative paths are "buggy" diff --git a/package.json b/package.json index e8d84274..4e98647b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "localforage": "^1.5.0", "node-sass": "^3.10.1", "object-path": "^0.11.3", + "phoenix": "^1.3.0", "sanitize-html": "^1.13.0", "sass-loader": "^4.0.2", "vue": "^2.3.4", diff --git a/src/App.scss b/src/App.scss index 9aa3ee98..95a653ce 100644 --- a/src/App.scss +++ b/src/App.scss @@ -16,7 +16,13 @@ h4 { } #content { - padding-top: 60px; + box-sizing: border-box; + padding-top: 60px; + margin: auto; + min-height: 100vh; + max-width: 980px; + background-color: rgba(0,0,0,0.15); + align-content: flex-start; } .text-center { @@ -157,15 +163,6 @@ main-router { margin: 0; } - -#content { - margin: auto; - min-height: 100vh; - max-width: 980px; - padding-bottom: 1em; - background-color: rgba(0,0,0,0.1); -} - .container > * { min-width: 0px; } @@ -210,10 +207,11 @@ nav { .panel-switcher { display: none; width: 100%; - + height: 46px; button { display: block; flex: 1; + max-height: 32px; margin: 0.5em; padding: 0.5em; } diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index 20d10cce..d6a51ffd 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -34,6 +34,13 @@ display: flex; flex-wrap: wrap; margin-right: -0.7em; + + .attachment.media-upload-container { + flex: 0 0 auto; + max-height: 300px; + max-width: 100%; + } + .attachment { flex: 1 0 30%; margin: 0.5em 0.7em 0.6em 0.0em; @@ -82,9 +89,7 @@ img.media-upload { margin-bottom: -2px; max-height: 300px; - width: 100%; - height: 100%; - flex: 1; + max-width: 100%; } .oembed { @@ -126,6 +131,7 @@ width: 100%; height: 100%; /* If this isn't here, chrome will stretch the images */ max-height: 500px; + image-orientation: from-image; } } } diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js new file mode 100644 index 00000000..ef326d4a --- /dev/null +++ b/src/components/chat/chat.js @@ -0,0 +1,21 @@ +const chat = { + data () { + return { + currentMessage: '', + channel: null + } + }, + computed: { + messages () { + return this.$store.state.chat.messages + } + }, + methods: { + submit (message) { + this.$store.state.chat.channel.push('new_msg', {text: message}, 10000) + this.currentMessage = '' + } + } +} + +export default chat diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue new file mode 100644 index 00000000..6c1e2c38 --- /dev/null +++ b/src/components/chat/chat.vue @@ -0,0 +1,59 @@ +<template> + <div class="chat-panel panel panel-default"> + <div class="panel-heading timeline-heading base02-background base04"> + <div class="title"> + {{$t('chat.title')}} + </div> + </div> + <div class="panel-body base01-background"> + <div class="chat-window"> + <div class="chat-message" v-for="message in messages" :key="message.id"> + <span class="chat-avatar"> + <img :src="message.author.avatar" /> + {{message.author.username}}: + </span> + <span class="chat-text"> + {{message.text}} + </span> + </div> + </div> + <div class="chat-input"> + <form @submit.prevent="submit(currentMessage)"> + <input v-model="currentMessage" type="text" > + </form> + </div> + </div> + </div> +</template> + +<script src="./chat.js"></script> + + +<style lang="scss"> + .chat-window { + max-height: 80vh; + overflow-y: auto; + overflow-x: hidden; + } + .chat-message { + padding: 0.2em 0.5em + } + .chat-avatar { + img { + height: 32px; + width: 32px; + border-radius: 5px; + margin-right: 0.5em; + } + } + .chat-input { + display: flex; + form { + flex: auto; + input { + margin: 0.5em; + width: fill-available; + } + } + } +</style> diff --git a/src/components/delete_button/delete_button.vue b/src/components/delete_button/delete_button.vue index 304f8a63..845ac777 100644 --- a/src/components/delete_button/delete_button.vue +++ b/src/components/delete_button/delete_button.vue @@ -1,7 +1,7 @@ <template> <div v-if="canDelete"> <a href="#" v-on:click.prevent="deleteStatus()"> - <i class='fa icon-cancel delete-status'></i> + <i class='base09 icon-cancel delete-status'></i> </a> </div> </template> diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue index 0abece31..dcf28e35 100644 --- a/src/components/favorite_button/favorite_button.vue +++ b/src/components/favorite_button/favorite_button.vue @@ -1,6 +1,6 @@ <template> <div> - <i :class='classes' class='favorite-button fa' @click.prevent='favorite()'/> + <i :class='classes' class='favorite-button base09' @click.prevent='favorite()'/> <span v-if='status.fave_num > 0'>{{status.fave_num}}</span> </div> </template> @@ -15,7 +15,7 @@ color: orange; } } - .icon-star { + .favorite-button.icon-star { color: orange; } diff --git a/src/components/media_upload/media_upload.vue b/src/components/media_upload/media_upload.vue index b839067b..9e6ad608 100644 --- a/src/components/media_upload/media_upload.vue +++ b/src/components/media_upload/media_upload.vue @@ -1,8 +1,8 @@ <template> <div class="media-upload" @drop.prevent @dragover.prevent="fileDrag" @drop="fileDrop"> <label class="btn btn-default"> - <i class="fa icon-spin4 animate-spin" v-if="uploading"></i> - <i class="fa icon-upload" v-if="!uploading"></i> + <i class="base09 icon-spin4 animate-spin" v-if="uploading"></i> + <i class="base09 icon-upload" v-if="!uploading"></i> <input type=file style="position: fixed; top: -100em"></input> </label> </div> diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index baeaaede..ea5d7ea4 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -2,6 +2,9 @@ const NavPanel = { computed: { currentUser () { return this.$store.state.users.currentUser + }, + chat () { + return this.$store.state.chat.channel } } } diff --git a/src/components/nav_panel/nav_panel.vue b/src/components/nav_panel/nav_panel.vue index aea841e9..ccc772a8 100644 --- a/src/components/nav_panel/nav_panel.vue +++ b/src/components/nav_panel/nav_panel.vue @@ -7,6 +7,11 @@ {{ $t("nav.timeline") }} </router-link> </li> + <li v-if='chat && currentUser'> + <router-link class="base00-background" to='/chat'> + {{ $t("nav.chat") }} + </router-link> + </li> <li v-if='currentUser'> <router-link class="base00-background" :to="{ name: 'mentions', params: { username: currentUser.screen_name } }"> {{ $t("nav.mentions") }} diff --git a/src/components/notifications/notifications.scss b/src/components/notifications/notifications.scss index db7b0843..241f10b4 100644 --- a/src/components/notifications/notifications.scss +++ b/src/components/notifications/notifications.scss @@ -59,6 +59,10 @@ color: $blue; } + .icon-star.lit { + color: orange; + } + .status-content { margin: 0; max-height: 300px; diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue index 64624873..b341fcef 100644 --- a/src/components/notifications/notifications.vue +++ b/src/components/notifications/notifications.vue @@ -4,7 +4,7 @@ <div class="panel-heading base02-background base04"> <span class="unseen-count" v-if="unseenCount">{{unseenCount}}</span> {{$t('notifications.notifications')}} - <button @click.prevent="markAsSeen" class="base04 base02-background read-button">{{$t('notifications.read')}}</button> + <button v-if="unseenCount" @click.prevent="markAsSeen" class="base04 base02-background read-button">{{$t('notifications.read')}}</button> </div> <div class="panel-body base03-border"> <div v-for="notification in visibleNotifications" :key="notification" class="notification" :class='{"unseen": !notification.seen}'> @@ -17,7 +17,7 @@ <div v-if="notification.type === 'favorite'"> <h1> <span :title="'@'+notification.action.user.screen_name">{{ notification.action.user.name }}</span> - <i class="fa icon-star"></i> + <i class="fa icon-star 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 class="notification-gradient" :style="hiderStyle"></div> diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index 2eb091f4..8c0cd5ee 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -157,6 +157,14 @@ const PostStatusForm = { type (fileInfo) { return fileTypeService.fileType(fileInfo.mimetype) }, + paste (e) { + if (e.clipboardData.files.length > 0) { + // Strangely, files property gets emptied after event propagation + // Trying to wrap it in array doesn't work. Plus I doubt it's possible + // to hold more than one file in clipboard. + this.dropFiles = [e.clipboardData.files[0]] + } + }, fileDrop (e) { if (e.dataTransfer.files.length > 0) { e.preventDefault() // allow dropping text like before diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index 8a2ec24d..8e436428 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -2,7 +2,7 @@ <div class="post-status-form"> <form @submit.prevent="postStatus(newStatus)"> <div class="form-group base03-border" > - <textarea @click="setCaret" @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" @keydown.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize"></textarea> + <textarea @click="setCaret" @keyup="setCaret" v-model="newStatus.status" :placeholder="$t('post_status.default')" rows="1" class="form-control" @keydown.meta.enter="postStatus(newStatus)" @keyup.ctrl.enter="postStatus(newStatus)" @drop="fileDrop" @dragover.prevent="fileDrag" @input="resize" @paste="paste"></textarea> </div> <div style="position:relative;" v-if="candidates"> <div class="autocomplete-panel base05-background"> @@ -26,7 +26,7 @@ <i class="icon-cancel" @click="clearError"></i> </div> <div class="attachments"> - <div class="attachment base03-border" v-for="file in newStatus.files"> + <div class="media-upload-container attachment base03-border" v-for="file in newStatus.files"> <i class="fa icon-cancel" @click="removeMediaFile(file)"></i> <img class="thumbnail media-upload" :src="file.image" v-if="type(file) === 'image'"></img> <video v-if="type(file) === 'video'" :src="file.image" controls></video> @@ -41,6 +41,7 @@ <script src="./post_status_form.js"></script> <style lang="scss"> + .tribute-container { ul { padding: 0px; diff --git a/src/components/retweet_button/retweet_button.vue b/src/components/retweet_button/retweet_button.vue index d923c5c4..edbfef32 100644 --- a/src/components/retweet_button/retweet_button.vue +++ b/src/components/retweet_button/retweet_button.vue @@ -1,6 +1,6 @@ <template> <div> - <i :class='classes' class='icon-retweet fa' v-on:click.prevent='retweet()'></i> + <i :class='classes' class='icon-retweet base09' v-on:click.prevent='retweet()'></i> <span v-if='status.repeat_num > 0'>{{status.repeat_num}}</span> </div> </template> @@ -16,7 +16,7 @@ color: $green; } } - .retweeted { + .icon-retweet.retweeted { color: $green; } </style> diff --git a/src/components/status/status.vue b/src/components/status/status.vue index 5e3df8ba..d6c8cdb3 100644 --- a/src/components/status/status.vue +++ b/src/components/status/status.vue @@ -5,7 +5,7 @@ <div class='status-actions'> <div> <a href="#" v-on:click.prevent="toggleReplying"> - <i class="fa icon-reply" :class="{'icon-reply-active': replying}"></i> + <i class="base09 icon-reply" :class="{'icon-reply-active': replying}"></i> </a> </div> <retweet-button :status=status></retweet-button> @@ -19,7 +19,7 @@ <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="fa icon-eye-off"></i></a> + <a href="#" class="unmute" @click.prevent="toggleMute"><i class="base09 icon-eye-off"></i></a> </div> </template> <template v-if="!muted"> @@ -75,10 +75,10 @@ </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> + <a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="base09 icon-eye-off"></i></a> + <a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url"><i class="base09 icon-binoculars"></i></a> <template v-if="expandable"> - <a href="#" @click.prevent="toggleExpanded" class="expand"><i class="fa icon-plus-squared"></i></a> + <a href="#" @click.prevent="toggleExpanded" class="expand"><i class="base09 icon-plus-squared"></i></a> </template> </div> </div> @@ -94,7 +94,7 @@ </div> </div> <div class="status-preview status-preview-loading base00-background base03-border" v-else-if="showPreview"> - <i class="fa icon-spin4 animate-spin"></i> + <i class="base09 icon-spin4 animate-spin"></i> </div> <div @click.prevent="linkClicked" class="status-content" v-html="status.statusnet_html"></div> @@ -109,7 +109,7 @@ <div class='status-actions'> <div> <a href="#" v-on:click.prevent="toggleReplying"> - <i class="fa icon-reply" :class="{'icon-reply-active': replying}"></i> + <i class="base09 icon-reply" :class="{'icon-reply-active': replying}"></i> </a> </div> <retweet-button :status=status></retweet-button> @@ -324,7 +324,7 @@ color: $blue; } - .icon-reply-active { + .icon-reply.icon-reply-active { color: $blue; } diff --git a/src/components/style_switcher/style_switcher.js b/src/components/style_switcher/style_switcher.js index b1359d13..a762f914 100644 --- a/src/components/style_switcher/style_switcher.js +++ b/src/components/style_switcher/style_switcher.js @@ -1,3 +1,5 @@ +import { rgbstr2hex } from '../../services/color_convert/color_convert.js' + export default { data () { return { @@ -19,13 +21,6 @@ export default { }) }, mounted () { - const rgbstr2hex = (rgb) => { - if (rgb[0] === '#') { - return rgb - } - rgb = rgb.match(/\d+/g) - return `#${((Number(rgb[0]) << 16) + (Number(rgb[1]) << 8) + Number(rgb[2])).toString(16)}` - } this.bgColorLocal = rgbstr2hex(this.$store.state.config.colors['base00']) this.fgColorLocal = rgbstr2hex(this.$store.state.config.colors['base02']) this.textColorLocal = rgbstr2hex(this.$store.state.config.colors['base05']) diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js index be0aefc1..660a8752 100644 --- a/src/components/timeline/timeline.js +++ b/src/components/timeline/timeline.js @@ -29,6 +29,13 @@ const Timeline = { }, newStatusCount () { return this.timeline.newStatusCount + }, + newStatusCountStr () { + if (this.timeline.flushMarker !== 0) { + return '' + } else { + return ` (${this.newStatusCount})` + } } }, components: { @@ -64,8 +71,14 @@ const Timeline = { }, methods: { showNewStatuses () { - this.$store.commit('showNewStatuses', { timeline: this.timelineName }) - this.paused = false + if (this.timeline.flushMarker !== 0) { + this.$store.commit('clearTimeline', { timeline: this.timelineName }) + this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) + this.fetchOlderStatuses() + } else { + this.$store.commit('showNewStatuses', { timeline: this.timelineName }) + this.paused = false + } }, fetchOlderStatuses () { const store = this.$store diff --git a/src/components/timeline/timeline.vue b/src/components/timeline/timeline.vue index 0e2ed92c..9d2e1ea1 100644 --- a/src/components/timeline/timeline.vue +++ b/src/components/timeline/timeline.vue @@ -5,7 +5,7 @@ {{title}} </div> <button @click.prevent="showNewStatuses" class="base05 base02-background loadmore-button" v-if="timeline.newStatusCount > 0 && !timelineError"> - {{$t('timeline.show_new')}} ({{timeline.newStatusCount}}) + {{$t('timeline.show_new')}}{{newStatusCountStr}} </button> <div @click.prevent class="base06 error loadmore-text" v-if="timelineError"> {{$t('timeline.error_fetching')}} diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index ba315faa..dd14d1b4 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -59,13 +59,16 @@ } .usercard { - width: -webkit-fill-available; - width: -moz-webkit-fill-available; - stretch: fill; + width: fill-available; margin: 0.2em 0 0.7em 0; - border-radius: 5px; + border-radius: 10px; border-style: solid; border-color: inherit; border-width: 1px; + overflow: hidden; + + p { + margin-bottom: 0; + } } </style> diff --git a/src/components/user_card_content/user_card_content.js b/src/components/user_card_content/user_card_content.js new file mode 100644 index 00000000..6e67a321 --- /dev/null +++ b/src/components/user_card_content/user_card_content.js @@ -0,0 +1,64 @@ +import { hex2rgb } from '../../services/color_convert/color_convert.js' + +export default { + props: [ 'user', 'switcher' ], + computed: { + headingStyle () { + const color = this.$store.state.config.colors['base00'] + if (color) { + const rgb = hex2rgb(color) + console.log(rgb) + 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 () { + return { + background: `linear-gradient(to bottom, rgba(0, 0, 0, 0), ${this.$store.state.config.colors['base00']} 80%)` + } + }, + isOtherUser () { + return this.user.id !== this.$store.state.users.currentUser.id + }, + loggedIn () { + return this.$store.state.users.currentUser + }, + dailyAvg () { + const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000)) + return Math.round(this.user.statuses_count / days) + } + }, + methods: { + followUser () { + const store = this.$store + store.state.api.backendInteractor.followUser(this.user.id) + .then((followedUser) => store.commit('addNewUsers', [followedUser])) + }, + unfollowUser () { + const store = this.$store + store.state.api.backendInteractor.unfollowUser(this.user.id) + .then((unfollowedUser) => store.commit('addNewUsers', [unfollowedUser])) + }, + blockUser () { + const store = this.$store + store.state.api.backendInteractor.blockUser(this.user.id) + .then((blockedUser) => store.commit('addNewUsers', [blockedUser])) + }, + unblockUser () { + const store = this.$store + store.state.api.backendInteractor.unblockUser(this.user.id) + .then((unblockedUser) => store.commit('addNewUsers', [unblockedUser])) + }, + toggleMute () { + const store = this.$store + store.commit('setMuted', {user: this.user, muted: !this.user.muted}) + store.state.api.backendInteractor.setUserMute(this.user) + }, + setProfileView (v) { + const store = this.$store + store.commit('setProfileView', { v }) + } + } +} diff --git a/src/components/user_card_content/user_card_content.vue b/src/components/user_card_content/user_card_content.vue index 5635a177..4c40c55f 100644 --- a/src/components/user_card_content/user_card_content.vue +++ b/src/components/user_card_content/user_card_content.vue @@ -84,69 +84,7 @@ </div> </template> -<script> - export default { - props: [ 'user', 'switcher' ], - computed: { - headingStyle () { - 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 () { - return { - background: `linear-gradient(to bottom, rgba(0, 0, 0, 0), ${this.$store.state.config.colors['base00']} 80%)` - } - }, - isOtherUser () { - return this.user.id !== this.$store.state.users.currentUser.id - }, - loggedIn () { - return this.$store.state.users.currentUser - }, - dailyAvg () { - const days = Math.ceil((new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000)) - return Math.round(this.user.statuses_count / days) - } - }, - methods: { - followUser () { - const store = this.$store - store.state.api.backendInteractor.followUser(this.user.id) - .then((followedUser) => store.commit('addNewUsers', [followedUser])) - }, - unfollowUser () { - const store = this.$store - store.state.api.backendInteractor.unfollowUser(this.user.id) - .then((unfollowedUser) => store.commit('addNewUsers', [unfollowedUser])) - }, - blockUser () { - const store = this.$store - store.state.api.backendInteractor.blockUser(this.user.id) - .then((blockedUser) => store.commit('addNewUsers', [blockedUser])) - }, - unblockUser () { - const store = this.$store - store.state.api.backendInteractor.unblockUser(this.user.id) - .then((unblockedUser) => store.commit('addNewUsers', [unblockedUser])) - }, - toggleMute () { - const store = this.$store - store.commit('setMuted', {user: this.user, muted: !this.user.muted}) - store.state.api.backendInteractor.setUserMute(this.user) - }, - setProfileView (v) { - const store = this.$store - store.commit('setProfileView', { v }) - } - } - } -</script> +<script src="./user_card_content.js"></script> <style lang="scss"> @import '../../_variables.scss'; @@ -164,7 +102,6 @@ .profile-panel-body { top: -0em; padding-top: 4em; - word-wrap: break-word; } diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index 74b0ff2a..515fd253 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -22,7 +22,7 @@ <div> <input type="file" @change="uploadFile(0, $event)" ></input> </div> - <i class="fa icon-spin4 animate-spin" v-if="uploading[0]"></i> + <i class="base09 icon-spin4 animate-spin" v-if="uploading[0]"></i> <button class="btn btn-default base05 base02-background" v-else-if="previews[0]" @click="submitAvatar">{{$t('general.submit')}}</button> </div> <div class="setting-item"> @@ -35,7 +35,7 @@ <div> <input type="file" @change="uploadFile(1, $event)" ></input> </div> - <i class="fa icon-spin4 animate-spin uploading" v-if="uploading[1]"></i> + <i class="base09 icon-spin4 animate-spin uploading" v-if="uploading[1]"></i> <button class="btn btn-default base05 base02-background" v-else-if="previews[1]" @click="submitBanner">{{$t('general.submit')}}</button> </div> <div class="setting-item"> @@ -46,7 +46,7 @@ <div> <input type="file" @change="uploadFile(2, $event)" ></input> </div> - <i class="fa icon-spin4 animate-spin uploading" v-if="uploading[2]"></i> + <i class="base09 icon-spin4 animate-spin uploading" v-if="uploading[2]"></i> <button class="btn btn-default base05 base02-background" v-else-if="previews[2]" @click="submitBg">{{$t('general.submit')}}</button> </div> </div> diff --git a/src/i18n/messages.js b/src/i18n/messages.js index 9aeffdfa..4c5be151 100644 --- a/src/i18n/messages.js +++ b/src/i18n/messages.js @@ -1,5 +1,9 @@ const de = { + chat: { + title: 'Chat' + }, nav: { + chat: 'Lokaler Chat', timeline: 'Zeitleiste', mentions: 'Erwähnungen', public_tl: 'Lokale Zeitleiste', @@ -179,7 +183,11 @@ const fi = { } const en = { + chat: { + title: 'Chat' + }, nav: { + chat: 'Local Chat', timeline: 'Timeline', mentions: 'Mentions', public_tl: 'Public Timeline', diff --git a/src/main.js b/src/main.js index 14cb27eb..51e0f7eb 100644 --- a/src/main.js +++ b/src/main.js @@ -12,11 +12,13 @@ import UserProfile from './components/user_profile/user_profile.vue' import Settings from './components/settings/settings.vue' import Registration from './components/registration/registration.vue' import UserSettings from './components/user_settings/user_settings.vue' +import Chat from './components/chat/chat.vue' import statusesModule from './modules/statuses.js' import usersModule from './modules/users.js' import apiModule from './modules/api.js' import configModule from './modules/config.js' +import chatModule from './modules/chat.js' import VueTimeago from 'vue-timeago' import VueI18n from 'vue-i18n' @@ -57,35 +59,12 @@ const store = new Vuex.Store({ statuses: statusesModule, users: usersModule, api: apiModule, - config: configModule + config: configModule, + chat: chatModule }, plugins: [createPersistedState(persistedStateOptions)], - strict: process.env.NODE_ENV !== 'production' -}) - -const routes = [ - { name: 'root', path: '/', redirect: '/main/all' }, - { path: '/main/all', component: PublicAndExternalTimeline }, - { path: '/main/public', component: PublicTimeline }, - { path: '/main/friends', component: FriendsTimeline }, - { path: '/tag/:tag', component: TagTimeline }, - { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, - { name: 'user-profile', path: '/users/:id', component: UserProfile }, - { name: 'mentions', path: '/:username/mentions', component: Mentions }, - { name: 'settings', path: '/settings', component: Settings }, - { name: 'registration', path: '/registration', component: Registration }, - { name: 'user-settings', path: '/user-settings', component: UserSettings } -] - -const router = new VueRouter({ - mode: 'history', - routes, - scrollBehavior: (to, from, savedPosition) => { - if (to.matched.some(m => m.meta.dontScroll)) { - return false - } - return savedPosition || { x: 0, y: 0 } - } + strict: false // Socket modifies itself, let's ignore this for now. + // strict: process.env.NODE_ENV !== 'production' }) const i18n = new VueI18n({ @@ -94,23 +73,53 @@ const i18n = new VueI18n({ messages }) -/* eslint-disable no-new */ -new Vue({ - router, - store, - i18n, - el: '#app', - render: h => h(App) -}) - window.fetch('/static/config.json') .then((res) => res.json()) - .then(({name, theme, background, logo, registrationOpen}) => { + .then((data) => { + const {name, theme, background, logo, registrationOpen} = data store.dispatch('setOption', { name: 'name', value: name }) store.dispatch('setOption', { name: 'theme', value: theme }) store.dispatch('setOption', { name: 'background', value: background }) store.dispatch('setOption', { name: 'logo', value: logo }) store.dispatch('setOption', { name: 'registrationOpen', value: registrationOpen }) + if (data['chatDisabled']) { + store.dispatch('disableChat') + } + + const routes = [ + { name: 'root', path: '/', redirect: data['defaultPath'] || '/main/all' }, + { path: '/main/all', component: PublicAndExternalTimeline }, + { path: '/main/public', component: PublicTimeline }, + { path: '/main/friends', component: FriendsTimeline }, + { path: '/tag/:tag', component: TagTimeline }, + { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, + { name: 'user-profile', path: '/users/:id', component: UserProfile }, + { name: 'mentions', path: '/:username/mentions', component: Mentions }, + { name: 'settings', path: '/settings', component: Settings }, + { name: 'registration', path: '/registration', component: Registration }, + { name: 'user-settings', path: '/user-settings', component: UserSettings }, + { name: 'chat', path: '/chat', component: Chat } + ] + + const router = new VueRouter({ + mode: 'history', + routes, + scrollBehavior: (to, from, savedPosition) => { + if (to.matched.some(m => m.meta.dontScroll)) { + return false + } + return savedPosition || { x: 0, y: 0 } + } + }) + + /* eslint-disable no-new */ + new Vue({ + router, + store, + i18n, + el: '#app', + render: h => h(App) + }) }) window.fetch('/static/terms-of-service.html') @@ -120,13 +129,19 @@ window.fetch('/static/terms-of-service.html') }) window.fetch('/api/pleroma/emoji.json') - .then((res) => res.json()) - .then((values) => { - const emoji = Object.keys(values).map((key) => { - return { shortcode: key, image_url: values[key] } - }) - store.dispatch('setOption', { name: 'customEmoji', value: emoji }) - }) + .then( + (res) => res.json() + .then( + (values) => { + const emoji = Object.keys(values).map((key) => { + return { shortcode: key, image_url: values[key] } + }) + store.dispatch('setOption', { name: 'emoji', value: emoji }) + }, + (failure) => {} + ), + (error) => console.log(error) + ) window.fetch('/static/emoji.json') .then((res) => res.json()) diff --git a/src/modules/api.js b/src/modules/api.js index e61382eb..c91fb97b 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -1,10 +1,13 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import {isArray} from 'lodash' +import { Socket } from 'phoenix' const api = { state: { backendInteractor: backendInteractorService(), - fetchers: {} + fetchers: {}, + socket: null, + chatDisabled: false }, mutations: { setBackendInteractor (state, backendInteractor) { @@ -15,6 +18,12 @@ const api = { }, removeFetcher (state, {timeline}) { delete state.fetchers[timeline] + }, + setSocket (state, socket) { + state.socket = socket + }, + setChatDisabled (state, value) { + state.chatDisabled = value } }, actions: { @@ -37,6 +46,17 @@ const api = { const fetcher = store.state.fetchers[timeline] window.clearInterval(fetcher) store.commit('removeFetcher', {timeline}) + }, + initializeSocket (store, token) { + // Set up websocket connection + if (!store.state.chatDisabled) { + let socket = new Socket('/socket', {params: {token: token}}) + socket.connect() + store.dispatch('initializeChat', socket) + } + }, + disableChat (store) { + store.commit('setChatDisabled', true) } } } diff --git a/src/modules/chat.js b/src/modules/chat.js new file mode 100644 index 00000000..b1244ebe --- /dev/null +++ b/src/modules/chat.js @@ -0,0 +1,33 @@ +const chat = { + state: { + messages: [], + channel: null + }, + mutations: { + setChannel (state, channel) { + state.channel = channel + }, + addMessage (state, message) { + state.messages.push(message) + state.messages = state.messages.slice(-19, 20) + }, + setMessages (state, messages) { + state.messages = messages.slice(-19, 20) + } + }, + actions: { + initializeChat (store, socket) { + const channel = socket.channel('chat:public') + channel.on('new_msg', (msg) => { + store.commit('addMessage', msg) + }) + channel.on('messages', ({messages}) => { + store.commit('setMessages', messages) + }) + channel.join() + store.commit('setChannel', channel) + } + } +} + +export default chat diff --git a/src/modules/statuses.js b/src/modules/statuses.js index d954b023..18191424 100644 --- a/src/modules/statuses.js +++ b/src/modules/statuses.js @@ -22,7 +22,8 @@ export const defaultState = { loading: false, followers: [], friends: [], - viewing: 'statuses' + viewing: 'statuses', + flushMarker: 0 }, public: { statuses: [], @@ -36,7 +37,8 @@ export const defaultState = { loading: false, followers: [], friends: [], - viewing: 'statuses' + viewing: 'statuses', + flushMarker: 0 }, user: { statuses: [], @@ -50,7 +52,8 @@ export const defaultState = { loading: false, followers: [], friends: [], - viewing: 'statuses' + viewing: 'statuses', + flushMarker: 0 }, publicAndExternal: { statuses: [], @@ -64,7 +67,8 @@ export const defaultState = { loading: false, followers: [], friends: [], - viewing: 'statuses' + viewing: 'statuses', + flushMarker: 0 }, friends: { statuses: [], @@ -78,7 +82,8 @@ export const defaultState = { loading: false, followers: [], friends: [], - viewing: 'statuses' + viewing: 'statuses', + flushMarker: 0 }, tag: { statuses: [], @@ -92,7 +97,8 @@ export const defaultState = { loading: false, followers: [], friends: [], - viewing: 'statuses' + viewing: 'statuses', + flushMarker: 0 } } } @@ -381,7 +387,8 @@ export const mutations = { loading: false, followers: [], friends: [], - viewing: 'statuses' + viewing: 'statuses', + flushMarker: 0 } state.timelines[timeline] = emptyTimeline @@ -422,6 +429,9 @@ export const mutations = { each(notifications, (notification) => { notification.seen = true }) + }, + queueFlush (state, { timeline, id }) { + state.timelines[timeline].flushMarker = id } } @@ -458,6 +468,9 @@ const statuses = { // Optimistic retweeting... commit('setRetweeted', { status, value: true }) apiService.retweet({ id: status.id, credentials: rootState.users.currentUser.credentials }) + }, + queueFlush ({ rootState, commit }, { timeline, id }) { + commit('queueFlush', { timeline, id }) } }, mutations diff --git a/src/modules/users.js b/src/modules/users.js index 30f8dc27..8303ecc1 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -97,6 +97,10 @@ const users = { // Set our new backend interactor commit('setBackendInteractor', backendInteractorService(userCredentials)) + if (user.token) { + store.dispatch('initializeSocket', user.token) + } + // Start getting fresh tweets. store.dispatch('startFetching', 'friends') diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 5de0a457..5b078bc8 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -43,6 +43,16 @@ let fetch = (url, options) => { return oldfetch(fullUrl, options) } +// from https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/Base64_encoding_and_decoding +let utoa = (str) => { + // first we use encodeURIComponent to get percent-encoded UTF-8, + // then we convert the percent encodings into raw bytes which + // can be fed into btoa. + return btoa(encodeURIComponent(str) + .replace(/%([0-9A-F]{2})/g, + (match, p1) => { return String.fromCharCode('0x' + p1) })) +} + // Params // cropH // cropW @@ -156,7 +166,7 @@ const register = (params) => { const authHeaders = (user) => { if (user && user.username && user.password) { - return { 'Authorization': `Basic ${btoa(`${user.username}:${user.password}`)}` } + return { 'Authorization': `Basic ${utoa(`${user.username}:${user.password}`)}` } } else { return { } } @@ -281,6 +291,8 @@ const fetchTimeline = ({timeline, credentials, since = false, until = false, use url += `/${tag}.json` } + params.push(['count', 20]) + const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') url += `?${queryString}` diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js new file mode 100644 index 00000000..13dd8979 --- /dev/null +++ b/src/services/color_convert/color_convert.js @@ -0,0 +1,34 @@ +import { map } from 'lodash' + +const rgb2hex = (r, g, b) => { + [r, g, b] = map([r, g, b], (val) => { + val = Math.ceil(val) + val = val < 0 ? 0 : val + val = val > 255 ? 255 : val + return val + }) + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}` +} + +const hex2rgb = (hex) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null +} + +const rgbstr2hex = (rgb) => { + if (rgb[0] === '#') { + return rgb + } + rgb = rgb.match(/\d+/g) + return `#${((Number(rgb[0]) << 16) + (Number(rgb[1]) << 8) + Number(rgb[2])).toString(16)}` +} + +export { + rgb2hex, + hex2rgb, + rgbstr2hex +} diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index 62296e79..6863bd0a 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -1,4 +1,5 @@ -import { times, map } from 'lodash' +import { times } from 'lodash' +import { rgb2hex, hex2rgb } from '../color_convert/color_convert.js' // While this is not used anymore right now, I left it in if we want to do custom // styles that aren't just colors, so user can pick from a few different distinct @@ -56,16 +57,6 @@ const setStyle = (href, commit) => { cssEl.addEventListener('load', setDynamic) } -const rgb2hex = (r, g, b) => { - [r, g, b] = map([r, g, b], (val) => { - val = Math.ceil(val) - val = val < 0 ? 0 : val - val = val > 255 ? 255 : val - return val - }) - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}` -} - const setColors = (col, commit) => { const head = document.head const body = document.body @@ -82,6 +73,7 @@ const setColors = (col, commit) => { if (isDark) { mod = mod * -1 } + colors['base00'] = rgb2hex(col.bg.r, col.bg.g, col.bg.b) // background colors['base01'] = rgb2hex((col.bg.r + col.fg.r) / 2, (col.bg.g + col.fg.g) / 2, (col.bg.b + col.fg.b) / 2) // hilighted bg colors['base02'] = rgb2hex(col.fg.r, col.fg.g, col.fg.b) // panels & buttons @@ -91,11 +83,13 @@ const setColors = (col, commit) => { colors['base06'] = rgb2hex(col.text.r - mod, col.text.g - mod, col.text.b - mod) // strong text colors['base07'] = rgb2hex(col.text.r - mod * 2, col.text.g - mod * 2, col.text.b - mod * 2) colors['base08'] = rgb2hex(col.link.r, col.link.g, col.link.b) // links + colors['base09'] = rgb2hex((col.bg.r + col.text.r) / 2, (col.bg.g + col.text.g) / 2, (col.bg.b + col.text.b) / 2) // icons - times(9, (n) => { - const color = colors[`base0${8 - n}`] - styleSheet.insertRule(`.base0${8 - n} { color: ${color}`, 'index-max') - styleSheet.insertRule(`.base0${8 - n}-background { background-color: ${color}`, 'index-max') + const num = 10 + times(num, (n) => { + const color = colors[`base0${num - 1 - n}`] + styleSheet.insertRule(`.base0${num - 1 - n} { color: ${color}`, 'index-max') + styleSheet.insertRule(`.base0${num - 1 - n}-background { background-color: ${color}`, 'index-max') }) styleSheet.insertRule(`a { color: ${colors['base08']}`, 'index-max') @@ -108,15 +102,6 @@ const setColors = (col, commit) => { commit('setOption', { name: 'customTheme', value: col }) } -const hex2rgb = (hex) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) - return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16) - } : null -} - const setPreset = (val, commit) => { window.fetch('/static/styles.json') .then((data) => data.json()) diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js index 6b76eb54..a4a80df0 100644 --- a/src/services/timeline_fetcher/timeline_fetcher.service.js +++ b/src/services/timeline_fetcher/timeline_fetcher.service.js @@ -29,12 +29,19 @@ const fetchAndUpdate = ({store, credentials, timeline = 'friends', older = false args['tag'] = tag return apiService.fetchTimeline(args) - .then((statuses) => update({store, statuses, timeline, showImmediately}), - () => store.dispatch('setError', { value: true })) + .then((statuses) => { + if (!older && statuses.length >= 20) { + store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId }) + } + update({store, statuses, timeline, showImmediately}) + }, () => store.dispatch('setError', { value: true })) } const startFetching = ({timeline = 'friends', credentials, store, userId = false, tag = false}) => { - fetchAndUpdate({timeline, credentials, store, showImmediately: true, userId, tag}) + const rootState = store.rootState || store.state + const timelineData = rootState.statuses.timelines[camelCase(timeline)] + const showImmediately = timelineData.visibleStatuses.length === 0 + fetchAndUpdate({timeline, credentials, store, showImmediately, userId, tag}) const boundFetchAndUpdate = () => fetchAndUpdate({ timeline, credentials, store, userId, tag }) return setInterval(boundFetchAndUpdate, 10000) } diff --git a/static/config.json b/static/config.json index 8b596992..880efca8 100644 --- a/static/config.json +++ b/static/config.json @@ -3,5 +3,7 @@ "theme": "pleroma-dark", "background": "/static/bg.jpg", "logo": "/static/logo.png", - "registrationOpen": false + "registrationOpen": false, + "defaultPath": "/main/all", + "chatDisabled": false } diff --git a/yarn.lock b/yarn.lock index d0d2dde9..3fcd29ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4226,6 +4226,10 @@ phantomjs-prebuilt@^2.1.3, phantomjs-prebuilt@^2.1.7: request-progress "~2.0.1" which "~1.2.10" +phoenix@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/phoenix/-/phoenix-1.3.0.tgz#1df2c27f986ee295e37c9983ec28ebac1d7f4a3e" + pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"