Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
48 KB
Referenced Files
None
Subscribers
None
diff --git a/src/App.js b/src/App.js
index 85df941691..83a61d3926 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,99 +1,101 @@
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'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ChatPanel from './components/chat_panel/chat_panel.vue'
+import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import { unseenNotificationsFromStore } from './services/notification_utils/notification_utils'
export default {
name: 'app',
components: {
UserPanel,
NavPanel,
Notifications,
UserFinder,
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
ChatPanel,
+ MediaModal,
SideDrawer
},
data: () => ({
mobileActivePanel: 'timeline',
finderHidden: true,
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')
)
}),
created () {
// Load the locale from the storage
this.$i18n.locale = this.$store.state.config.interfaceLanguage
},
computed: {
currentUser () { return this.$store.state.users.currentUser },
background () {
return this.currentUser.background_image || this.$store.state.instance.background
},
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
logoStyle () {
return {
'visibility': this.enableMask ? 'hidden' : 'visible'
}
},
logoMaskStyle () {
return this.enableMask ? {
'mask-image': `url(${this.$store.state.instance.logo})`
} : {
'background-color': this.enableMask ? '' : 'transparent'
}
},
logoBgStyle () {
return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.finderHidden ? 1 : 0
}, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent'
})
},
logo () { return this.$store.state.instance.logo },
style () {
return {
'--body-background-image': `url(${this.background})`,
'background-image': `url(${this.background})`
}
},
sitename () { return this.$store.state.instance.name },
chat () { return this.$store.state.chat.channel.state === 'joined' },
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () { return this.$store.state.instance.showInstanceSpecificPanel },
unseenNotifications () {
return unseenNotificationsFromStore(this.$store)
},
unseenNotificationsCount () {
return this.unseenNotifications.length
}
},
methods: {
scrollToTop () {
window.scrollTo(0, 0)
},
logout () {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
},
onFinderToggled (hidden) {
this.finderHidden = hidden
},
toggleMobileSidebar () {
this.$refs.sideDrawer.toggleDrawer()
}
}
}
diff --git a/src/App.vue b/src/App.vue
index feadb009bc..833608ea59 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,50 +1,51 @@
<template>
<div id="app" v-bind:style="style">
<nav class='nav-bar container' @click="scrollToTop()" id="nav">
<div class='logo' :style='logoBgStyle'>
<div class='mask' :style='logoMaskStyle'></div>
<img :src='logo' :style='logoStyle'>
</div>
<div class='inner-nav'>
<div class='item'>
<a href="#" class="menu-button" @click.stop.prevent="toggleMobileSidebar()">
<i class="button-icon icon-menu"></i>
<div class="alert-dot" v-if="unseenNotificationsCount"></div>
</a>
<router-link class="site-name" :to="{ name: 'root' }" active-class="home">{{sitename}}</router-link>
</div>
<div class='item right'>
<user-finder class="button-icon nav-icon mobile-hidden" @toggled="onFinderToggled"></user-finder>
<router-link class="mobile-hidden" :to="{ name: 'settings'}"><i class="button-icon icon-cog nav-icon" :title="$t('nav.preferences')"></i></router-link>
<a href="#" class="mobile-hidden" v-if="currentUser" @click.prevent="logout"><i class="button-icon icon-logout nav-icon" :title="$t('login.logout')"></i></a>
</div>
</div>
</nav>
<div v-if="" class="container" id="content">
<side-drawer ref="sideDrawer" :logout="logout"></side-drawer>
<div class="sidebar-flexer mobile-hidden">
<div class="sidebar-bounds">
<div class="sidebar-scroller">
<div class="sidebar">
<user-panel></user-panel>
<nav-panel></nav-panel>
<instance-specific-panel v-if="showInstanceSpecificPanel"></instance-specific-panel>
<features-panel v-if="!currentUser"></features-panel>
<who-to-follow-panel v-if="currentUser && suggestionsEnabled"></who-to-follow-panel>
<notifications v-if="currentUser"></notifications>
</div>
</div>
</div>
</div>
<div class="main">
<transition name="fade">
<router-view></router-view>
</transition>
</div>
+ <media-modal></media-modal>
</div>
<chat-panel :floating="true" v-if="currentUser && chat" class="floating-chat mobile-hidden"></chat-panel>
</div>
</template>
<script src="./App.js"></script>
<style lang="scss" src="./App.scss"></style>
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index 97c4f283f4..5e672ef284 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -1,85 +1,98 @@
import StillImage from '../still-image/still-image.vue'
import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js'
const Attachment = {
props: [
'attachment',
'nsfw',
'statusId',
- 'size'
+ 'size',
+ 'setMedia'
],
data () {
return {
nsfwImage: this.$store.state.config.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.state.config.hideNsfw,
preloadImage: this.$store.state.config.preloadImage,
loopVideo: this.$store.state.config.loopVideo,
showHidden: false,
loading: false,
- img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img')
+ img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
+ modalOpen: false
}
},
components: {
StillImage
},
computed: {
+ usePlaceHolder () {
+ return this.size === 'hide' || this.type === 'unknown'
+ },
type () {
return fileTypeService.fileType(this.attachment.mimetype)
},
hidden () {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty () {
return (this.type === 'html' && !this.attachment.oembed) || this.type === 'unknown'
},
isSmall () {
return this.size === 'small'
},
fullwidth () {
- return fileTypeService.fileType(this.attachment.mimetype) === 'html'
+ return this.type === 'html' || this.type === 'audio'
}
},
methods: {
linkClicked ({target}) {
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
toggleHidden () {
if (this.img && !this.preloadImage) {
if (this.img.onload) {
this.img.onload()
} else {
this.loading = true
this.img.src = this.attachment.url
this.img.onload = () => {
this.loading = false
this.showHidden = !this.showHidden
}
}
} else {
this.showHidden = !this.showHidden
}
},
+ toggleModal (event) {
+ if (this.type !== 'image' && this.type !== 'video') {
+ return
+ }
+ event.preventDefault()
+ this.setMedia()
+ this.$store.dispatch('setCurrent', this.attachment)
+ },
onVideoDataLoad (e) {
if (typeof e.srcElement.webkitAudioDecodedByteCount !== 'undefined') {
// non-zero if video has audio track
if (e.srcElement.webkitAudioDecodedByteCount > 0) {
this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
}
} else if (typeof e.srcElement.mozHasAudio !== 'undefined') {
// true if video has audio track
if (e.srcElement.mozHasAudio) {
this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
}
} else if (typeof e.srcElement.audioTracks !== 'undefined') {
if (e.srcElement.audioTracks.length > 0) {
this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
}
}
}
}
}
export default Attachment
diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue
index 5eaa0d1dfe..1c6b84df3a 100644
--- a/src/components/attachment/attachment.vue
+++ b/src/components/attachment/attachment.vue
@@ -1,187 +1,196 @@
<template>
- <div v-if="size==='hide'">
+ <div v-if="usePlaceHolder" @click="toggleModal">
<a class="placeholder" v-if="type !== 'html'" target="_blank" :href="attachment.url">[{{nsfw ? "NSFW/" : ""}}{{type.toUpperCase()}}]</a>
</div>
- <div v-else class="attachment" :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}" v-show="!isEmpty">
+ <div
+ v-else class="attachment"
+ :class="{[type]: true, loading, 'small-attachment': isSmall, 'fullwidth': fullwidth, 'nsfw-placeholder': hidden}"
+ v-show="!isEmpty"
+ @click="toggleModal"
+ >
<a class="image-attachment" v-if="hidden" @click.prevent="toggleHidden()">
<img :key="nsfwImage" :src="nsfwImage"/>
</a>
<div class="hider" v-if="nsfw && hideNsfwLocal && !hidden">
<a href="#" @click.prevent="toggleHidden()">Hide</a>
</div>
- <a v-if="type === 'image' && (!hidden || preloadImage)" class="image-attachment" :class="{'hidden': hidden && preloadImage}" :href="attachment.url" target="_blank" :title="attachment.description">
+ <a v-if="type === 'image' && (!hidden || preloadImage)"
+ class="image-attachment"
+ :class="{'hidden': hidden && preloadImage}"
+ :href="attachment.url" target="_blank"
+ :title="attachment.description"
+ >
<StillImage :class="{'small': isSmall}" referrerpolicy="no-referrer" :mimetype="attachment.mimetype" :src="attachment.large_thumb_url || attachment.url"/>
</a>
- <video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" @loadeddata="onVideoDataLoad" :src="attachment.url" controls :loop="loopVideo" playsinline></video>
+ <video :class="{'small': isSmall}" v-if="type === 'video' && !hidden" :src="attachment.url"></video>
<audio v-if="type === 'audio'" :src="attachment.url" controls></audio>
<div @click.prevent="linkClicked" v-if="type === 'html' && attachment.oembed" class="oembed">
<div v-if="attachment.thumb_url" class="image">
<img :src="attachment.thumb_url"/>
</div>
<div class="text">
<h1><a :href="attachment.url">{{attachment.oembed.title}}</a></h1>
<div v-html="attachment.oembed.oembedHTML"></div>
</div>
</div>
</div>
</template>
<script src="./attachment.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.attachments {
display: flex;
flex-wrap: wrap;
.attachment.media-upload-container {
flex: 0 0 auto;
- max-height: 300px;
+ max-height: 160px;
max-width: 100%;
}
.placeholder {
- margin-right: 0.5em;
+ margin-right: 8px;
+ margin-bottom: 4px;
}
.nsfw-placeholder {
cursor: pointer;
&.loading {
cursor: progress;
}
}
.small-attachment {
- &.image, &.video {
- max-width: 35%;
- }
max-height: 100px;
}
.attachment {
position: relative;
- flex: 1 0 30%;
- margin: 0.5em 0.7em 0.6em 0.0em;
+ margin: 0.5em 0.5em 0em 0em;
align-self: flex-start;
line-height: 0;
border-style: solid;
border-width: 1px;
border-radius: $fallback--attachmentRadius;
border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
overflow: hidden;
}
.fullwidth {
flex-basis: 100%;
}
// fixes small gap below video
&.video {
line-height: 0;
}
+ .video {
+ object-fit: cover;
+ }
+
&.html {
flex-basis: 90%;
width: 100%;
display: flex;
}
.hider {
position: absolute;
margin: 10px;
padding: 5px;
background: rgba(230,230,230,0.6);
font-weight: bold;
z-index: 4;
line-height: 1;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
}
.small {
max-height: 100px;
}
+
video {
- max-height: 500px;
+ max-height: 160px;
height: 100%;
- width: 100%;
z-index: 0;
}
audio {
width: 100%;
}
img.media-upload {
line-height: 0;
- max-height: 300px;
+ max-height: 160px;
max-width: 100%;
}
.oembed {
line-height: 1.2em;
flex: 1 0 100%;
width: 100%;
margin-right: 15px;
display: flex;
img {
width: 100%;
}
.image {
flex: 1;
img {
border: 0px;
border-radius: 5px;
height: 100%;
object-fit: cover;
}
}
.text {
flex: 2;
margin: 8px;
word-break: break-all;
h1 {
font-size: 14px;
margin: 0px;
}
}
}
.image-attachment {
display: flex;
flex: 1;
&.hidden {
display: none;
}
.still-image {
- width: 100%;
height: 100%;
}
.small {
img {
- max-height: 100px;
+ max-height: 80px;
}
}
img {
- object-fit: contain;
- width: 100%;
+ object-fit: cover;
height: 100%; /* If this isn't here, chrome will stretch the images */
- max-height: 500px;
+ height: 160px;
image-orientation: from-image;
}
}
}
</style>
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
new file mode 100644
index 0000000000..9160151545
--- /dev/null
+++ b/src/components/media_modal/media_modal.js
@@ -0,0 +1,51 @@
+import StillImage from '../still-image/still-image.vue'
+import fileTypeService from '../../services/file_type/file_type.service.js'
+
+const MediaModal = {
+ data () {
+ return {
+ loopVideo: this.$store.state.config.loopVideo
+ }
+ },
+ components: {
+ StillImage
+ },
+ computed: {
+ showing () {
+ return this.$store.state.mediaViewer.activated
+ },
+ currentIndex () {
+ return this.$store.state.mediaViewer.currentIndex
+ },
+ currentMedia () {
+ return this.$store.state.mediaViewer.media[this.currentIndex]
+ },
+ type () {
+ return this.currentMedia ? fileTypeService.fileType(this.currentMedia.mimetype) : null
+ }
+ },
+ methods: {
+ hide () {
+ this.$store.dispatch('closeMediaViewer')
+ },
+ onVideoDataLoad (e) {
+ if (typeof e.srcElement.webkitAudioDecodedByteCount !== 'undefined') {
+ // non-zero if video has audio track
+ if (e.srcElement.webkitAudioDecodedByteCount > 0) {
+ this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
+ }
+ } else if (typeof e.srcElement.mozHasAudio !== 'undefined') {
+ // true if video has audio track
+ if (e.srcElement.mozHasAudio) {
+ this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
+ }
+ } else if (typeof e.srcElement.audioTracks !== 'undefined') {
+ if (e.srcElement.audioTracks.length > 0) {
+ this.loopVideo = this.loopVideo && !this.$store.state.config.loopVideoSilentOnly
+ }
+ }
+ }
+ }
+}
+
+export default MediaModal
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
new file mode 100644
index 0000000000..6e291ac576
--- /dev/null
+++ b/src/components/media_modal/media_modal.vue
@@ -0,0 +1,40 @@
+<template>
+ <div class="modal-view" v-if="showing" @click.prevent="hide">
+ <img class="modal-image" v-if="type === 'image'" :src="currentMedia.url"></img>
+ <video
+ class="modal-image"
+ v-if="type === 'video'"
+ :src="currentMedia.url"
+ @click.stop=""
+ controls autoplay
+ :loop="loopVideo"
+ @loadeddata="onVideoDataLoad">
+ </video>
+ </div>
+</template>
+
+<script src="./media_modal.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.modal-view {
+ z-index: 1005;
+ position: fixed;
+ width: 100vw;
+ height: 100vh;
+ top: 0;
+ left: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: rgba(0, 0, 0, 0.5);
+ cursor: pointer;
+}
+
+.modal-image {
+ max-width: 90%;
+ max-height: 90%;
+ box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
+}
+</style>
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 73d53694e6..8058e1bb39 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,316 +1,322 @@
import Attachment from '../attachment/attachment.vue'
import FavoriteButton from '../favorite_button/favorite_button.vue'
import RetweetButton from '../retweet_button/retweet_button.vue'
import DeleteButton from '../delete_button/delete_button.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import UserCardContent from '../user_card_content/user_card_content.vue'
import StillImage from '../still-image/still-image.vue'
import { filter, find } from 'lodash'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const Status = {
name: 'Status',
props: [
'statusoid',
'expandable',
'inConversation',
'focused',
'highlight',
'compact',
'replies',
'noReplyLinks',
'noHeading',
'inlineExpanded'
],
data () {
return {
replying: false,
expanded: false,
unmuted: false,
userExpanded: false,
preview: null,
showPreview: false,
showingTall: false,
expandingSubject: typeof this.$store.state.config.collapseMessageWithSubject === 'undefined'
? !this.$store.state.instance.collapseMessageWithSubject
: !this.$store.state.config.collapseMessageWithSubject,
- betterShadow: this.$store.state.interface.browserSupport.cssFilter
+ betterShadow: this.$store.state.interface.browserSupport.cssFilter,
+ maxAttachments: 9
}
},
computed: {
localCollapseSubjectDefault () {
return typeof this.$store.state.config.collapseMessageWithSubject === 'undefined'
? this.$store.state.instance.collapseMessageWithSubject
: this.$store.state.config.collapseMessageWithSubject
},
muteWords () {
return this.$store.state.config.muteWords
},
repeaterClass () {
const user = this.statusoid.user
return highlightClass(user)
},
userClass () {
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
return highlightClass(user)
},
deleted () {
return this.statusoid.deleted
},
repeaterStyle () {
const user = this.statusoid.user
const highlight = this.$store.state.config.highlight
return highlightStyle(highlight[user.screen_name])
},
userStyle () {
if (this.noHeading) return
const user = this.retweet ? (this.statusoid.retweeted_status.user) : this.statusoid.user
const highlight = this.$store.state.config.highlight
return highlightStyle(highlight[user.screen_name])
},
hideAttachments () {
return (this.$store.state.config.hideAttachments && !this.inConversation) ||
(this.$store.state.config.hideAttachmentsInConv && this.inConversation)
},
retweet () { return !!this.statusoid.retweeted_status },
retweeter () { return this.statusoid.user.name },
retweeterHtml () { return this.statusoid.user.name_html },
status () {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
loggedIn () {
return !!this.$store.state.users.currentUser
},
muteWordHits () {
const statusText = this.status.text.toLowerCase()
const hits = filter(this.muteWords, (muteWord) => {
return statusText.includes(muteWord.toLowerCase())
})
return hits
},
muted () { return !this.unmuted && (this.status.user.muted || this.muteWordHits.length > 0) },
isFocused () {
// retweet or root of an expanded conversation
if (this.focused) {
return true
} else if (!this.inConversation) {
return false
}
// use conversation highlight only when in conversation
return this.status.id === this.highlight
},
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
tallStatus () {
const lengthScore = this.status.statusnet_html.split(/<p|<br/).length + this.status.text.length / 80
return lengthScore > 20
},
isReply () {
if (this.status.in_reply_to_status_id) {
return true
}
// For private replies where we can't see the OP, in_reply_to_status_id will be null.
// So instead, check that the post starts with a @mention.
if (this.status.visibility === 'private') {
var textBody = this.status.text
if (this.status.summary !== null) {
textBody = textBody.substring(this.status.summary.length, textBody.length)
}
return textBody.startsWith('@')
}
return false
},
hideReply () {
if (this.$store.state.config.replyVisibility === 'all') {
return false
}
if (this.inlineExpanded || this.expanded || this.inConversation || !this.isReply) {
return false
}
if (this.status.user.id === this.$store.state.users.currentUser.id) {
return false
}
if (this.status.activity_type === 'repeat') {
return false
}
var checkFollowing = this.$store.state.config.replyVisibility === 'following'
for (var i = 0; i < this.status.attentions.length; ++i) {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
if (checkFollowing && this.status.attentions[i].following) {
return false
}
if (this.status.attentions[i].id === this.$store.state.users.currentUser.id) {
return false
}
}
return this.status.attentions.length > 0
},
hideSubjectStatus () {
if (this.tallStatus && !this.localCollapseSubjectDefault) {
return false
}
return !this.expandingSubject && this.status.summary
},
hideTallStatus () {
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
if (this.showingTall) {
return false
}
return this.tallStatus
},
showingMore () {
return this.showingTall || (this.status.summary && this.expandingSubject)
},
nsfwClickthrough () {
if (!this.status.nsfw) {
return false
}
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
replySubject () {
if (!this.status.summary) return ''
const behavior = typeof this.$store.state.config.subjectLineBehavior === 'undefined'
? this.$store.state.instance.subjectLineBehavior
: this.$store.state.config.subjectLineBehavior
const startsWithRe = this.status.summary.match(/^re[: ]/i)
if (behavior !== 'noop' && startsWithRe || behavior === 'masto') {
return this.status.summary
} else if (behavior === 'email') {
return 're: '.concat(this.status.summary)
} else if (behavior === 'noop') {
return ''
}
},
attachmentSize () {
if ((this.$store.state.config.hideAttachments && !this.inConversation) ||
- (this.$store.state.config.hideAttachmentsInConv && this.inConversation)) {
+ (this.$store.state.config.hideAttachmentsInConv && this.inConversation) ||
+ (this.status.attachments.length > this.maxAttachments)) {
return 'hide'
} else if (this.compact) {
return 'small'
}
return 'normal'
}
},
components: {
Attachment,
FavoriteButton,
RetweetButton,
DeleteButton,
PostStatusForm,
UserCardContent,
StillImage
},
methods: {
visibilityIcon (visibility) {
switch (visibility) {
case 'private':
return 'icon-lock'
case 'unlisted':
return 'icon-lock-open-alt'
case 'direct':
return 'icon-mail-alt'
default:
return 'icon-globe'
}
},
linkClicked ({target}) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
toggleReplying () {
this.replying = !this.replying
},
gotoOriginal (id) {
// only handled by conversation, not status_or_conversation
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded () {
this.$emit('toggleExpanded')
},
toggleMute () {
this.unmuted = !this.unmuted
},
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
toggleShowMore () {
if (this.showingTall) {
this.showingTall = false
} else if (this.expandingSubject) {
this.expandingSubject = false
} else if (this.hideTallStatus) {
this.showingTall = true
} else if (this.hideSubjectStatus) {
this.expandingSubject = true
}
},
replyEnter (id, event) {
this.showPreview = true
const targetId = Number(id)
const statuses = this.$store.state.statuses.allStatuses
if (!this.preview) {
// if we have the status somewhere already
this.preview = find(statuses, { 'id': targetId })
// or if we have to fetch it
if (!this.preview) {
this.$store.state.api.backendInteractor.fetchStatus({id}).then((status) => {
this.preview = status
})
}
} else if (this.preview.id !== targetId) {
this.preview = find(statuses, { 'id': targetId })
}
},
replyLeave () {
this.showPreview = false
},
userProfileLink (id, name) {
return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
+ },
+ setMedia () {
+ const attachments = this.status.attachments
+ return () => this.$store.dispatch('setMedia', attachments)
}
},
watch: {
'highlight': function (id) {
id = Number(id)
if (this.status.id === id) {
let rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
window.scrollBy(0, rect.top - 200)
} else if (rect.bottom > window.innerHeight - 50) {
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
}
},
filters: {
capitalize: function (str) {
return str.charAt(0).toUpperCase() + str.slice(1)
}
}
}
export default Status
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index af7568013c..f88b5afbdd 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -1,587 +1,594 @@
<template>
<div class="status-el" v-if="!hideReply && !deleted" :class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]">
<template v-if="muted && !noReplyLinks">
<div class="media status container muted">
<small>
<router-link :to="userProfileLink(status.user.id, status.user.screen_name)">
{{status.user.screen_name}}
</router-link>
</small>
<small class="muteWords">{{muteWordHits.join(', ')}}</small>
<a href="#" class="unmute" @click.prevent="toggleMute"><i class="button-icon icon-eye-off"></i></a>
</div>
</template>
<template v-else>
<div v-if="retweet && !noHeading" :class="[repeaterClass, { highlighted: repeaterStyle }]" :style="[repeaterStyle]" class="media container retweet-info">
<StillImage v-if="retweet" class='avatar' :class='{ "better-shadow": betterShadow }' :src="statusoid.user.profile_image_url_original"/>
<div class="media-body faint">
<a v-if="retweeterHtml" :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name" v-html="retweeterHtml"></a>
<a v-else :href="statusoid.user.statusnet_profile_url" class="user-name" :title="'@'+statusoid.user.screen_name">{{retweeter}}</a>
<i class='fa icon-retweet retweeted' :title="$t('tool_tip.repeat')"></i>
{{$t('timeline.repeated')}}
</div>
</div>
<div :class="[userClass, { highlighted: userStyle, 'is-retweet': retweet }]" :style="[ userStyle ]" class="media status">
<div v-if="!noHeading" class="media-left">
<a :href="status.user.statusnet_profile_url" @click.stop.prevent.capture="toggleUserExpanded">
<StillImage class='avatar' :class="{'avatar-compact': compact, 'better-shadow': betterShadow}" :src="status.user.profile_image_url_original"/>
</a>
</div>
<div class="status-body">
<div class="usercard media-body" v-if="userExpanded">
<user-card-content :user="status.user" :switcher="false"></user-card-content>
</div>
<div v-if="!noHeading" class="media-body container media-heading">
<div class="media-heading-left">
<div class="name-and-links">
<h4 class="user-name" v-if="status.user.name_html" v-html="status.user.name_html"></h4>
<h4 class="user-name" v-else>{{status.user.name}}</h4>
<span class="links">
<router-link :to="userProfileLink(status.user.id, status.user.screen_name)">
{{status.user.screen_name}}
</router-link>
<span v-if="status.in_reply_to_screen_name" class="faint reply-info">
<i class="icon-right-open"></i>
<router-link :to="userProfileLink(status.in_reply_to_user_id, status.in_reply_to_screen_name)">
{{status.in_reply_to_screen_name}}
</router-link>
</span>
<a v-if="isReply && !noReplyLinks" href="#" @click.prevent="gotoOriginal(status.in_reply_to_status_id)" :title="$t('tool_tip.reply')">
<i class="button-icon icon-reply" @mouseenter="replyEnter(status.in_reply_to_status_id, $event)" @mouseout="replyLeave()"></i>
</a>
</span>
</div>
<h4 class="replies" v-if="inConversation && !noReplyLinks">
<small v-if="replies.length">Replies:</small>
<small class="reply-link" v-for="reply in replies">
<a href="#" @click.prevent="gotoOriginal(reply.id)" @mouseenter="replyEnter(reply.id, $event)" @mouseout="replyLeave()">{{reply.name}}&nbsp;</a>
</small>
</h4>
</div>
<div class="media-heading-right">
<router-link class="timeago" :to="{ name: 'conversation', params: { id: status.id } }">
<timeago :since="status.created_at" :auto-update="60"></timeago>
</router-link>
<div class="button-icon visibility-icon" v-if="status.visibility">
<i :class="visibilityIcon(status.visibility)" :title="status.visibility | capitalize"></i>
</div>
<a :href="status.external_url" target="_blank" v-if="!status.is_local" class="source_url" title="Source">
<i class="button-icon icon-link-ext-alt"></i>
</a>
<template v-if="expandable">
<a href="#" @click.prevent="toggleExpanded" title="Expand">
<i class="button-icon icon-plus-squared"></i>
</a>
</template>
<a href="#" @click.prevent="toggleMute" v-if="unmuted"><i class="button-icon icon-eye-off"></i></a>
</div>
</div>
<div v-if="showPreview" class="status-preview-container">
<status class="status-preview" v-if="preview" :noReplyLinks="true" :statusoid="preview" :compact=true></status>
<div class="status-preview status-preview-loading" v-else>
<i class="icon-spin4 animate-spin"></i>
</div>
</div>
<div :class="{'tall-status': hideTallStatus}" class="status-content-wrapper">
<a class="tall-status-hider" :class="{ 'tall-status-hider_focused': isFocused }" v-if="hideTallStatus" href="#" @click.prevent="toggleShowMore">Show more</a>
<div @click.prevent="linkClicked" class="status-content media-body" v-html="status.statusnet_html" v-if="!hideSubjectStatus"></div>
<div @click.prevent="linkClicked" class="status-content media-body" v-html="status.summary" v-else></div>
<a v-if="hideSubjectStatus" href="#" class="cw-status-hider" @click.prevent="toggleShowMore">Show more</a>
<a v-if="showingMore" href="#" class="status-unhider" @click.prevent="toggleShowMore">Show less</a>
</div>
<div v-if='status.attachments && !hideSubjectStatus' class='attachments media-body'>
- <attachment :size="attachmentSize" :status-id="status.id" :nsfw="nsfwClickthrough" :attachment="attachment" v-for="attachment in status.attachments" :key="attachment.id">
+ <attachment
+ :size="attachmentSize"
+ :status-id="status.id"
+ :nsfw="nsfwClickthrough"
+ :attachment="attachment"
+ :set-media="setMedia()"
+ v-for="attachment in status.attachments"
+ :key="attachment.id">
</attachment>
</div>
<div v-if="!noHeading && !noReplyLinks" class='status-actions media-body'>
<div v-if="loggedIn">
<a href="#" v-on:click.prevent="toggleReplying" :title="$t('tool_tip.reply')">
<i class="button-icon icon-reply" :class="{'icon-reply-active': replying}"></i>
</a>
</div>
<retweet-button :visibility='status.visibility' :loggedIn='loggedIn' :status='status'></retweet-button>
<favorite-button :loggedIn='loggedIn' :status='status'></favorite-button>
<delete-button :status='status'></delete-button>
</div>
</div>
</div>
<div class="container" v-if="replying">
<div class="reply-left"/>
<post-status-form class="reply-body" :reply-to="status.id" :attentions="status.attentions" :repliedUser="status.user" :copy-message-scope="status.visibility" :subject="replySubject" v-on:posted="toggleReplying"/>
</div>
</template>
</div>
</template>
<script src="./status.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.status-body {
flex: 1;
min-width: 0;
}
.status-preview.status-el {
border-style: solid;
border-width: 1px;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
.status-preview-container {
position: relative;
max-width: 100%;
}
.status-preview {
position: absolute;
max-width: 95%;
display: flex;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-style: solid;
border-width: 1px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow);
margin-top: 0.25em;
margin-left: 0.5em;
z-index: 50;
.status {
flex: 1;
border: 0;
min-width: 15em;
}
}
.status-preview-loading {
display: block;
min-width: 15em;
padding: 1em;
text-align: center;
border-width: 1px;
border-style: solid;
i {
font-size: 2em;
}
}
.status-el {
hyphens: auto;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px;
line-height: 18px;
min-width: 0;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
border-left: 4px $fallback--cRed;
border-left: 4px var(--cRed, $fallback--cRed);
&_focused {
background-color: $fallback--lightBg;
background-color: var(--lightBg, $fallback--lightBg);
}
.timeline & {
border-bottom-width: 1px;
border-bottom-style: solid;
}
.media-body {
flex: 1;
padding: 0;
margin: 0 0 0.25em 0.8em;
}
.usercard {
margin-bottom: .7em
}
.media-heading {
flex-wrap: nowrap;
line-height: 18px;
}
.media-heading-left {
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
small {
font-weight: lighter;
}
h4 {
white-space: nowrap;
font-size: 14px;
margin-right: 0.25em;
overflow: hidden;
text-overflow: ellipsis;
}
.name-and-links {
padding: 0;
flex: 1 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
.user-name {
margin-right: .45em;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
}
.links {
display: flex;
font-size: 12px;
color: $fallback--link;
color: var(--link, $fallback--link);
max-width: 100%;
a {
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.reply-info {
display: flex;
}
.replies {
line-height: 16px;
}
.reply-link {
margin-right: 0.2em;
}
}
.media-heading-right {
display: inline-flex;
flex-shrink: 0;
flex-wrap: nowrap;
margin-left: .25em;
align-self: baseline;
.timeago {
margin-right: 0.2em;
font-size: 12px;
align-self: last baseline;
}
> * {
margin-left: 0.2em;
}
a:hover i {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
a {
display: inline-block;
word-break: break-all;
}
.tall-status {
position: relative;
height: 220px;
overflow-x: hidden;
overflow-y: hidden;
}
.tall-status-hider {
position: absolute;
height: 70px;
margin-top: 150px;
width: 100%;
text-align: center;
line-height: 110px;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--bg 80%);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--bg, $fallback--bg) 80%);
&_focused {
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), $fallback--lightBg 80%);
background: linear-gradient(to bottom, rgba(0, 0, 0, 0), var(--lightBg, $fallback--lightBg) 80%);
}
}
.status-unhider, .cw-status-hider {
width: 100%;
text-align: center;
}
.status-content {
margin-right: 0.5em;
font-family: var(--postFont, sans-serif);
img, video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
}
blockquote {
margin: 0.2em 0 0.2em 2em;
font-style: italic;
}
pre {
overflow: auto;
}
code, samp, kbd, var, pre {
font-family: var(--postCodeFont, monospace);
}
p {
margin: 0;
margin-top: 0.2em;
margin-bottom: 0.5em;
}
h1 {
font-size: 1.1em;
line-height: 1.2em;
margin: 1.4em 0;
}
h2 {
font-size: 1.1em;
margin: 1.0em 0;
}
h3 {
font-size: 1em;
margin: 1.2em 0;
}
h4 {
margin: 1.1em 0;
}
}
.retweet-info {
padding: 0.4em 0.6em 0 0.6em;
margin: 0;
.avatar {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
margin-left: 28px;
width: 20px;
height: 20px;
}
.media-body {
font-size: 1em;
line-height: 22px;
display: flex;
align-content: center;
flex-wrap: wrap;
.user-name {
font-weight: bold;
img {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
i {
padding: 0 0.2em;
}
a {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.status-fadein {
animation-duration: 0.4s;
animation-name: fadein;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.greentext {
color: green;
}
.status-conversation {
border-left-style: solid;
}
.status-actions {
width: 100%;
display: flex;
div, favorite-button {
padding-top: 0.25em;
max-width: 6em;
flex: 1;
}
}
.icon-reply:hover {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.icon-reply.icon-reply-active {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.status .avatar-compact {
width: 32px;
height: 32px;
box-shadow: var(--avatarStatusShadow);
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
&.better-shadow {
box-shadow: var(--avatarStatusShadowInset);
filter: var(--avatarStatusShadowFilter)
}
}
.avatar.still-image {
width: 48px;
height: 48px;
box-shadow: var(--avatarStatusShadow);
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
overflow: hidden;
position: relative;
&.better-shadow {
box-shadow: var(--avatarStatusShadowInset);
filter: var(--avatarStatusShadowFilter)
}
img {
width: 100%;
height: 100%;
}
&.animated::before {
display: none;
}
&.retweeted {
}
}
.status:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
.status {
display: flex;
padding: 0.6em;
&.is-retweet {
padding-top: 0.1em;
}
}
.status-conversation:last-child {
border-bottom: none;
}
.muted {
padding: 0.25em 0.5em;
button {
margin-left: auto;
}
.muteWords {
margin-left: 10px;
}
}
a.unmute {
display: block;
margin-left: auto;
}
.reply-left {
flex: 0;
min-width: 48px;
}
.reply-body {
flex: 1;
}
.timeline > {
.status-el:last-child {
border-bottom-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
border-bottom: none;
}
}
@media all and (max-width: 960px) {
.status-el {
.retweet-info {
.avatar {
margin-left: 20px;
}
}
}
.status {
max-width: 100%;
}
.status .avatar {
width: 40px;
height: 40px;
}
.status .avatar-compact {
width: 32px;
height: 32px;
}
}
</style>
diff --git a/src/main.js b/src/main.js
index f87ef9dac3..adeb055043 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,79 +1,81 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Vuex from 'vuex'
import interfaceModule from './modules/interface.js'
import instanceModule from './modules/instance.js'
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 oauthModule from './modules/oauth.js'
+import mediaViewerModule from './modules/media_viewer.js'
import VueTimeago from 'vue-timeago'
import VueI18n from 'vue-i18n'
import createPersistedState from './lib/persisted_state.js'
import pushNotifications from './lib/push_notifications_plugin.js'
import messages from './i18n/messages.js'
import VueChatScroll from 'vue-chat-scroll'
import afterStoreSetup from './boot/after_store.js'
const currentLocale = (window.navigator.language || 'en').split('-')[0]
Vue.use(Vuex)
Vue.use(VueRouter)
Vue.use(VueTimeago, {
locale: currentLocale === 'ja' ? 'ja' : 'en',
locales: {
'en': require('../static/timeago-en.json'),
'ja': require('../static/timeago-ja.json')
}
})
Vue.use(VueI18n)
Vue.use(VueChatScroll)
const i18n = new VueI18n({
// By default, use the browser locale, we will update it if neccessary
locale: currentLocale,
fallbackLocale: 'en',
messages
})
const persistedStateOptions = {
paths: [
'config',
'users.lastLoginName',
'oauth'
]
}
createPersistedState(persistedStateOptions).then((persistedState) => {
const store = new Vuex.Store({
modules: {
interface: interfaceModule,
instance: instanceModule,
statuses: statusesModule,
users: usersModule,
api: apiModule,
config: configModule,
chat: chatModule,
- oauth: oauthModule
+ oauth: oauthModule,
+ mediaViewer: mediaViewerModule
},
plugins: [persistedState, pushNotifications],
strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production'
})
afterStoreSetup({ store, i18n })
})
// These are inlined by webpack's DefinePlugin
/* eslint-disable */
window.___pleromafe_mode = process.env
window.___pleromafe_commit_hash = COMMIT_HASH
window.___pleromafe_dev_overrides = DEV_OVERRIDES
diff --git a/src/modules/media_viewer.js b/src/modules/media_viewer.js
new file mode 100644
index 0000000000..27714bae14
--- /dev/null
+++ b/src/modules/media_viewer.js
@@ -0,0 +1,40 @@
+import fileTypeService from '../services/file_type/file_type.service.js'
+
+const mediaViewer = {
+ state: {
+ media: [],
+ currentIndex: 0,
+ activated: false
+ },
+ mutations: {
+ setMedia (state, media) {
+ state.media = media
+ },
+ setCurrent (state, index) {
+ state.activated = true
+ state.currentIndex = index
+ },
+ close (state) {
+ state.activated = false
+ }
+ },
+ actions: {
+ setMedia ({ commit }, attachments) {
+ const media = attachments.filter(attachment => {
+ const type = fileTypeService.fileType(attachment.mimetype)
+ return type === 'image' || type === 'video'
+ })
+ commit('setMedia', media)
+ },
+ setCurrent ({ commit, state }, current) {
+ const index = state.media.indexOf(current)
+ console.log(index, current)
+ commit('setCurrent', index || 0)
+ },
+ closeMediaViewer ({ commit }) {
+ commit('close')
+ }
+ }
+}
+
+export default mediaViewer

File Metadata

Mime Type
text/x-diff
Expires
Mon, Nov 2, 12:19 AM (13 h, 13 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1803452
Default Alt Text
(48 KB)

Event Timeline