Merge branch 'develop' of git.pleroma.social:pleroma/pleroma-fe into develop

This commit is contained in:
Maksim Pechnikov 2020-09-07 09:47:17 +03:00
commit fa2b680855
171 changed files with 7648 additions and 1724 deletions

View File

@ -1,7 +1,7 @@
# This file is a template, and might need editing before it works on your project. # This file is a template, and might need editing before it works on your project.
# Official framework image. Look for the different tagged releases at: # Official framework image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/node/tags/ # https://hub.docker.com/r/library/node/tags/
image: node:8 image: node:10
stages: stages:
- lint - lint
@ -14,6 +14,7 @@ lint:
script: script:
- yarn - yarn
- npm run lint - npm run lint
- npm run stylelint
test: test:
stage: test stage: test

19
.stylelintrc.json Normal file
View File

@ -0,0 +1,19 @@
{
"extends": [
"stylelint-rscss/config",
"stylelint-config-recommended",
"stylelint-config-standard"
],
"rules": {
"declaration-no-important": true,
"rscss/no-descendant-combinator": false,
"rscss/class-format": [
true,
{
"component": "pascal-case",
"variant": "^-[a-z]\\w+",
"element": "^[a-z]\\w+"
}
]
}
}

View File

@ -2,30 +2,40 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
## [Unreleased patch]
### Changed ### Changed
- Greentext now has separate color slot for it - Polls will be hidden with status content if "Collapse posts with subjects" is enabled and the post is collapsed.
- Removed the use of with_move parameters when fetching notifications
- Push notifications now are the same as normal notfication, and are localized.
### Fixed ### Fixed
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully) - Autocomplete won't stop at the second @, so it'll still work with "@lain@l" and not start over.
- Multiple issues with muted statuses/notifications - Fixed weird autocomplete behavior when you write ":custom_emoji: ?"
## [Unreleased patch] ## [2.1.0] - 2020-08-28
### Add ### Added
- Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu)
- Autocomplete domains from list of known instances - Autocomplete domains from list of known instances
- 'Bot' settings option and badge - 'Bot' settings option and badge
- Added profile meta data fields that can be set in profile settings - Added profile meta data fields that can be set in profile settings
- Added option to reset avatar/banner in profile settings
- Descriptions can be set on uploaded files before posting
- Added status preview option to preview your statuses before posting
- When a post is a reply to an unavailable post, the 'Reply to'-text has a strike-through style
- Added ability to see all favoriting or repeating users when hovering the number on highlighted statuses
- Bookmarks
### Changed ### Changed
- Registration page no longer requires email if the server is configured not to require it
- Change heart to thumbs up in reaction picker - Change heart to thumbs up in reaction picker
- Close the media modal on navigation events - Close the media modal on navigation events
- Add colons to the emoji alt text, to make them copyable - Add colons to the emoji alt text, to make them copyable
- Add better visual indication for drag-and-drop for files - Add better visual indication for drag-and-drop for files
- When disabling attachments, the placeholder links now show an icon and the description instead of just IMAGE or VIDEO etc
- Remove unnecessary options for 'automatic loading when loading older' and 'reply previews'
- Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications
- Push notifications now are the same as normal notfication, and are localized.
- Updated Notification Settings to match new BE API
### Fixed ### Fixed
- Custom Emoji will display in poll options now. - Custom Emoji will display in poll options now.
@ -37,6 +47,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Subject field now appears disabled when posting - Subject field now appears disabled when posting
- Fix status ellipsis menu being cut off in notifications column - Fix status ellipsis menu being cut off in notifications column
- Fixed autocomplete sometimes not returning the right user when there's already some results - Fixed autocomplete sometimes not returning the right user when there's already some results
- Videos and audio and misc files show description as alt/title properly now
- Clicking on non-image/video files no longer opens an empty modal
- Audio files can now be played back in the frontend with hidden attachments
- Videos are not cropped awkwardly in the uploads section anymore
- Reply filtering options in Settings -> Filtering now work again using filtering on server
- Don't show just blank-screen when cookies are disabled
- Add status idempotency to prevent accidental double posting when posting returns an error
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
- Multiple issues with muted statuses/notifications
## [2.0.5] - 2020-05-12
### Added
- Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu)
### Changed
- Registration page no longer requires email if the server is configured not to require it
### Fixed
- Status ellipsis menu closes properly when selecting certain options
## [2.0.3] - 2020-05-02 ## [2.0.3] - 2020-05-02
### Fixed ### Fixed
@ -46,7 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed ### Changed
- Emoji autocomplete will match any part of the word and not just start, for example :drool will now helpfully suggest :blobcatdrool: and :blobcatdroolreach: - Emoji autocomplete will match any part of the word and not just start, for example :drool will now helpfully suggest :blobcatdrool: and :blobcatdroolreach:
### Add ### Added
- Follow request notification support - Follow request notification support
## [2.0.2] - 2020-04-08 ## [2.0.2] - 2020-04-08
@ -98,6 +128,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Ability to change user's email - Ability to change user's email
- About page - About page
- Added remote user redirect - Added remote user redirect
- Bookmarks
### Changed ### Changed
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes - changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
### Fixed ### Fixed

View File

@ -2,7 +2,7 @@
> A single column frontend designed for Pleroma. > A single column frontend designed for Pleroma.
![screenshot](https://i.imgur.com/DJVqSJ0.png) ![screenshot](/uploads/796c5ecf985ed1e2b0943ee0df131ed0/DJVqSJ0.png)
# For Translators # For Translators

View File

@ -8,8 +8,6 @@
> >
> --Catbag > --Catbag
Pleroma-FE user interface is modeled after Qvitter which is modeled after older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options.
## Posting, reading, basic functions. ## Posting, reading, basic functions.
After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column. After registering and logging in you're presented with your timeline in right column and new post form with timeline list and notifications in the left column.

8
docs/index.md Normal file
View File

@ -0,0 +1,8 @@
# Introduction to Pleroma-FE
## What is Pleroma-FE?
Pleroma-FE is the default user-facing frontend for Pleroma. It's user interface is modeled after Qvitter which is modeled after an older Twitter design. It provides a simple 2-column interface for microblogging. While being simple by default it also provides many powerful customization options.
## How can I use it?
If your instance uses Pleroma-FE, you can acces it by going to your instance (e.g. <https://pleroma.soykaf.com>). You can read more about it's basic functionality in the [Pleroma-FE User Guide](./USER_GUIDE.md). We also have [a guide for administrators](./CONFIGURATION.md) and for [hackers/contributors](./HACKING.md).

View File

@ -11,6 +11,7 @@
"unit:watch": "karma start test/unit/karma.conf.js --single-run=false", "unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
"e2e": "node test/e2e/runner.js", "e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e", "test": "npm run unit && npm run e2e",
"stylelint": "npx stylelint src/components/status/status.scss",
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs", "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs" "lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
}, },
@ -23,6 +24,7 @@
"diff": "^3.0.1", "diff": "^3.0.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"localforage": "^1.5.0", "localforage": "^1.5.0",
"parse-link-header": "^1.0.1",
"phoenix": "^1.3.0", "phoenix": "^1.3.0",
"portal-vue": "^2.1.4", "portal-vue": "^2.1.4",
"v-click-outside": "^2.1.1", "v-click-outside": "^2.1.1",
@ -35,7 +37,6 @@
"vuex": "^3.0.1" "vuex": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"karma-mocha-reporter": "^2.2.1",
"@babel/core": "^7.7.5", "@babel/core": "^7.7.5",
"@babel/plugin-transform-runtime": "^7.7.6", "@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.6", "@babel/preset-env": "^7.7.6",
@ -79,6 +80,7 @@
"karma-coverage": "^1.1.1", "karma-coverage": "^1.1.1",
"karma-firefox-launcher": "^1.1.0", "karma-firefox-launcher": "^1.1.0",
"karma-mocha": "^1.2.0", "karma-mocha": "^1.2.0",
"karma-mocha-reporter": "^2.2.1",
"karma-sinon-chai": "^2.0.2", "karma-sinon-chai": "^2.0.2",
"karma-sourcemap-loader": "^0.3.7", "karma-sourcemap-loader": "^0.3.7",
"karma-spec-reporter": "0.0.26", "karma-spec-reporter": "0.0.26",
@ -100,6 +102,9 @@
"shelljs": "^0.7.4", "shelljs": "^0.7.4",
"sinon": "^2.1.0", "sinon": "^2.1.0",
"sinon-chai": "^2.8.0", "sinon-chai": "^2.8.0",
"stylelint": "^13.6.1",
"stylelint-config-standard": "^20.0.0",
"stylelint-rscss": "^0.4.0",
"url-loader": "^1.1.2", "url-loader": "^1.1.2",
"vue-loader": "^14.0.0", "vue-loader": "^14.0.0",
"vue-style-loader": "^4.0.0", "vue-style-loader": "^4.0.0",

View File

@ -13,7 +13,8 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil
import MobileNav from './components/mobile_nav/mobile_nav.vue' import MobileNav from './components/mobile_nav/mobile_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue' import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import { windowWidth } from './services/window_utils/window_utils' import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
export default { export default {
name: 'app', name: 'app',
@ -32,7 +33,8 @@ export default {
MobileNav, MobileNav,
SettingsModal, SettingsModal,
UserReportingModal, UserReportingModal,
PostStatusModal PostStatusModal,
GlobalNoticeList
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline', mobileActivePanel: 'timeline',
@ -125,10 +127,12 @@ export default {
}, },
updateMobileState () { updateMobileState () {
const mobileLayout = windowWidth() <= 800 const mobileLayout = windowWidth() <= 800
const layoutHeight = windowHeight()
const changed = mobileLayout !== this.isMobileLayout const changed = mobileLayout !== this.isMobileLayout
if (changed) { if (changed) {
this.$store.dispatch('setMobileLayout', mobileLayout) this.$store.dispatch('setMobileLayout', mobileLayout)
} }
this.$store.dispatch('setLayoutHeight', layoutHeight)
} }
} }
} }

View File

@ -47,6 +47,7 @@ html {
} }
body { body {
overscroll-behavior-y: none;
font-family: sans-serif; font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif); font-family: var(--interfaceFont, sans-serif);
margin: 0; margin: 0;
@ -319,7 +320,7 @@ option {
i[class*=icon-] { i[class*=icon-] {
color: $fallback--icon; color: $fallback--icon;
color: var(--icon, $fallback--icon) color: var(--icon, $fallback--icon);
} }
.btn-block { .btn-block {
@ -858,6 +859,10 @@ nav {
display: block; display: block;
margin-right: 0.8em; margin-right: 0.8em;
} }
.main {
margin-bottom: 7em;
}
} }
.select-multiple { .select-multiple {
@ -924,3 +929,51 @@ nav {
background-color: $fallback--fg; background-color: $fallback--fg;
background-color: var(--panel, $fallback--fg); background-color: var(--panel, $fallback--fg);
} }
.unread-chat-count {
font-size: 0.9em;
font-weight: bolder;
font-style: normal;
position: absolute;
right: 0.6rem;
padding: 0 0.3em;
min-width: 1.3rem;
min-height: 1.3rem;
max-height: 1.3rem;
line-height: 1.3rem;
}
.chat-layout {
// Needed for smoother chat navigation in the desktop Safari (otherwise the chat layout "jumps" as the chat opens).
overflow: hidden;
height: 100%;
// Ensures the fixed position of the mobile browser bars on scroll up / down events.
// Prevents the mobile browser bars from overlapping or hiding the message posting form.
@media all and (max-width: 800px) {
body {
height: 100%;
}
#app {
height: 100%;
overflow: hidden;
min-height: auto;
}
#app_bg_wrapper {
overflow: hidden;
}
.main {
overflow: hidden;
height: 100%;
}
#content {
padding-top: 0;
height: 100%;
overflow: visible;
}
}
}

View File

@ -77,6 +77,7 @@
</div> </div>
</div> </div>
</nav> </nav>
<div class="app-bg-wrapper app-container-wrapper" />
<div <div
id="content" id="content"
class="container underlay" class="container underlay"
@ -112,9 +113,7 @@
{{ $t("login.hint") }} {{ $t("login.hint") }}
</router-link> </router-link>
</div> </div>
<transition name="fade"> <router-view />
<router-view />
</transition>
</div> </div>
<media-modal /> <media-modal />
</div> </div>
@ -128,6 +127,7 @@
<PostStatusModal /> <PostStatusModal />
<SettingsModal /> <SettingsModal />
<portal-target name="modal" /> <portal-target name="modal" />
<GlobalNoticeList />
</div> </div>
</template> </template>

View File

@ -27,5 +27,6 @@ $fallback--tooltipRadius: 5px;
$fallback--avatarRadius: 4px; $fallback--avatarRadius: 4px;
$fallback--avatarAltRadius: 10px; $fallback--avatarAltRadius: 10px;
$fallback--attachmentRadius: 10px; $fallback--attachmentRadius: 10px;
$fallback--chatMessageRadius: 10px;
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset; $fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;

View File

@ -20,15 +20,23 @@ const parsedInitialResults = () => {
return staticInitialResults return staticInitialResults
} }
const decodeUTF8Base64 = (data) => {
const rawData = atob(data)
const array = Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
const text = new TextDecoder().decode(array)
return text
}
const preloadFetch = async (request) => { const preloadFetch = async (request) => {
const data = parsedInitialResults() const data = parsedInitialResults()
if (!data || !data[request]) { if (!data || !data[request]) {
return window.fetch(request) return window.fetch(request)
} }
const requestData = atob(data[request]) const decoded = decodeUTF8Base64(data[request])
const requestData = JSON.parse(decoded)
return { return {
ok: true, ok: true,
json: () => JSON.parse(requestData), json: () => requestData,
text: () => requestData text: () => requestData
} }
} }
@ -215,7 +223,6 @@ const getAppSecret = async ({ store }) => {
const resolveStaffAccounts = ({ store, accounts }) => { const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop()) const nicknames = accounts.map(uri => uri.split('/').pop())
nicknames.map(nickname => store.dispatch('fetchUser', nickname))
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
} }
@ -231,6 +238,7 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') }) store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') }) store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })

View File

@ -2,9 +2,12 @@ import PublicTimeline from 'components/public_timeline/public_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue' import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue' import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue' import ConversationPage from 'components/conversation-page/conversation-page.vue'
import Interactions from 'components/interactions/interactions.vue' import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue' import DMs from 'components/dm_timeline/dm_timeline.vue'
import ChatList from 'components/chat_list/chat_list.vue'
import Chat from 'components/chat/chat.vue'
import UserProfile from 'components/user_profile/user_profile.vue' import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue' import Search from 'components/search/search.vue'
import Registration from 'components/registration/registration.vue' import Registration from 'components/registration/registration.vue'
@ -27,7 +30,7 @@ export default (store) => {
} }
} }
return [ let routes = [
{ name: 'root', { name: 'root',
path: '/', path: '/',
redirect: _to => { redirect: _to => {
@ -40,6 +43,7 @@ export default (store) => {
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline }, { name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute }, { name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ name: 'remote-user-profile-acct', { name: 'remote-user-profile-acct',
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)', path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',
@ -60,11 +64,20 @@ export default (store) => {
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute }, { name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute }, { name: 'notifications', path: '/:username/notifications', component: Notifications, beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm }, { name: 'login', path: '/login', component: AuthForm },
{ name: 'chat', path: '/chat', component: ChatPanel, props: () => ({ floating: false }) }, { name: 'chat-panel', path: '/chat-panel', component: ChatPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) }, { name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) }, { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute }, { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About }, { name: 'about', path: '/about', component: About },
{ name: 'user-profile', path: '/(users/)?:name', component: UserProfile } { name: 'user-profile', path: '/(users/)?:name', component: UserProfile }
] ]
if (store.state.instance.pleromaChatMessagesAvailable) {
routes = routes.concat([
{ name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute },
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }
])
}
return routes
} }

View File

@ -1,3 +1,4 @@
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue' import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue' import Popover from '../popover/popover.vue'
@ -27,7 +28,18 @@ const AccountActions = {
}, },
reportUser () { reportUser () {
this.$store.dispatch('openUserReportingModal', this.user.id) this.$store.dispatch('openUserReportingModal', this.user.id)
},
openChat () {
this.$router.push({
name: 'chat',
params: { recipient_id: this.user.id }
})
} }
},
computed: {
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})
} }
} }

View File

@ -50,6 +50,13 @@
> >
{{ $t('user_card.report') }} {{ $t('user_card.report') }}
</button> </button>
<button
v-if="pleromaChatMessagesAvailable"
class="btn btn-default btn-block dropdown-item"
@click="openChat"
>
{{ $t('user_card.message') }}
</button>
</div> </div>
</div> </div>
<div <div

View File

@ -8,7 +8,6 @@ const Attachment = {
props: [ props: [
'attachment', 'attachment',
'nsfw', 'nsfw',
'statusId',
'size', 'size',
'allowPlay', 'allowPlay',
'setMedia', 'setMedia',
@ -30,9 +29,21 @@ const Attachment = {
VideoAttachment VideoAttachment
}, },
computed: { computed: {
usePlaceHolder () { usePlaceholder () {
return this.size === 'hide' || this.type === 'unknown' return this.size === 'hide' || this.type === 'unknown'
}, },
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass () {
if (this.type === 'image') return 'icon-picture'
if (this.type === 'video') return 'icon-video'
if (this.type === 'audio') return 'icon-music'
return 'icon-doc'
},
referrerpolicy () { referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
}, },
@ -49,7 +60,15 @@ const Attachment = {
return this.size === 'small' return this.size === 'small'
}, },
fullwidth () { fullwidth () {
return this.type === 'html' || this.type === 'audio' if (this.size === 'hide') return false
return this.type === 'html' || this.type === 'audio' || this.type === 'unknown'
},
useModal () {
const modalTypes = this.size === 'hide' ? ['image', 'video', 'audio']
: this.mergedConfig.playVideosInModal
? ['image', 'video']
: ['image']
return modalTypes.includes(this.type)
}, },
...mapGetters(['mergedConfig']) ...mapGetters(['mergedConfig'])
}, },
@ -60,12 +79,7 @@ const Attachment = {
} }
}, },
openModal (event) { openModal (event) {
const modalTypes = this.mergedConfig.playVideosInModal if (this.useModal) {
? ['image', 'video']
: ['image']
if (fileTypeService.fileMatchesSomeType(modalTypes, this.attachment) ||
this.usePlaceHolder
) {
event.stopPropagation() event.stopPropagation()
event.preventDefault() event.preventDefault()
this.setMedia() this.setMedia()

View File

@ -1,6 +1,7 @@
<template> <template>
<div <div
v-if="usePlaceHolder" v-if="usePlaceholder"
:class="{ 'fullwidth': fullwidth }"
@click="openModal" @click="openModal"
> >
<a <a
@ -8,8 +9,11 @@
class="placeholder" class="placeholder"
target="_blank" target="_blank"
:href="attachment.url" :href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
> >
[{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}] <span :class="placeholderIconClass" />
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
</a> </a>
</div> </div>
<div <div
@ -22,6 +26,8 @@
v-if="hidden" v-if="hidden"
class="image-attachment" class="image-attachment"
:href="attachment.url" :href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent="toggleHidden" @click.prevent="toggleHidden"
> >
<img <img
@ -51,14 +57,15 @@
:class="{'hidden': hidden && preloadImage }" :class="{'hidden': hidden && preloadImage }"
:href="attachment.url" :href="attachment.url"
target="_blank" target="_blank"
:title="attachment.description"
@click="openModal" @click="openModal"
> >
<StillImage <StillImage
class="image"
:referrerpolicy="referrerpolicy" :referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype" :mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url" :src="attachment.large_thumb_url || attachment.url"
:image-load-handler="onImageLoad" :image-load-handler="onImageLoad"
:alt="attachment.description"
/> />
</a> </a>
@ -83,6 +90,8 @@
<audio <audio
v-if="type === 'audio'" v-if="type === 'audio'"
:src="attachment.url" :src="attachment.url"
:alt="attachment.description"
:title="attachment.description"
controls controls
/> />
@ -116,22 +125,19 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.attachment.media-upload-container { .non-gallery {
flex: 0 0 auto;
max-height: 200px;
max-width: 100%; max-width: 100%;
display: flex;
align-items: center;
video {
max-width: 100%;
}
} }
.placeholder { .placeholder {
margin-right: 8px; display: inline-block;
margin-bottom: 4px; padding: 0.3em 1em 0.3em 0;
color: $fallback--link; color: $fallback--link;
color: var(--postLink, $fallback--link); color: var(--postLink, $fallback--link);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 100%;
} }
.nsfw-placeholder { .nsfw-placeholder {
@ -276,8 +282,11 @@
} }
.image-attachment { .image-attachment {
width: 100%; &,
height: 100%; & .image {
width: 100%;
height: 100%;
}
&.hidden { &.hidden {
display: none; display: none;

View File

@ -0,0 +1,17 @@
import Timeline from '../timeline/timeline.vue'
const Bookmarks = {
computed: {
timeline () {
return this.$store.state.statuses.timelines.bookmarks
}
},
components: {
Timeline
},
destroyed () {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
}
}
export default Bookmarks

View File

@ -0,0 +1,9 @@
<template>
<Timeline
:title="$t('nav.bookmarks')"
:timeline="timeline"
:timeline-name="'bookmarks'"
/>
</template>
<script src="./bookmark_timeline.js"></script>

333
src/components/chat/chat.js Normal file
View File

@ -0,0 +1,333 @@
import _ from 'lodash'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex'
import ChatMessage from '../chat_message/chat_message.vue'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import ChatTitle from '../chat_title/chat_title.vue'
import chatService from '../../services/chat_service/chat_service.js'
import { getScrollPosition, getNewTopPosition, isBottomedOut, scrollableContainerHeight } from './chat_layout_utils.js'
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 150
const SAFE_RESIZE_TIME_OFFSET = 100
const Chat = {
components: {
ChatMessage,
ChatTitle,
PostStatusForm
},
data () {
return {
jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined,
lastScrollPosition: {},
scrollableContainerHeight: '100%',
errorLoadingChat: false
}
},
created () {
this.startFetching()
window.addEventListener('resize', this.handleLayoutChange)
},
mounted () {
window.addEventListener('scroll', this.handleScroll)
if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false)
}
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.handleResize()
})
this.setChatLayout()
},
destroyed () {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleLayoutChange)
this.unsetChatLayout()
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
this.$store.dispatch('clearCurrentChat')
},
computed: {
recipient () {
return this.currentChat && this.currentChat.account
},
recipientId () {
return this.$route.params.recipient_id
},
formPlaceholder () {
if (this.recipient) {
return this.$t('chats.message_user', { nickname: this.recipient.screen_name })
} else {
return ''
}
},
chatViewItems () {
return chatService.getView(this.currentChatMessageService)
},
newMessageCount () {
return this.currentChatMessageService && this.currentChatMessageService.newMessageCount
},
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
},
...mapGetters([
'currentChat',
'currentChatMessageService',
'findOpenedChatByRecipientId',
'mergedConfig'
]),
...mapState({
backendInteractor: state => state.api.backendInteractor,
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus,
mobileLayout: state => state.interface.mobileLayout,
layoutHeight: state => state.interface.layoutHeight,
currentUser: state => state.users.currentUser
})
},
watch: {
chatViewItems () {
// We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => {
if (bottomedOutBeforeUpdate) {
this.scrollDown({ forceRead: !document.hidden })
}
})
},
'$route': function () {
this.startFetching()
},
layoutHeight () {
this.handleResize({ expand: true })
},
mastoUserSocketStatus (newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
}
},
methods: {
// Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
onMessageHover ({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined
},
onFilesDropped () {
this.$nextTick(() => {
this.handleResize()
this.updateScrollableContainerHeight()
})
},
handleVisibilityChange () {
this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
setChatLayout () {
// This is a hacky way to adjust the global layout to the mobile chat (without modifying the rest of the app).
// This layout prevents empty spaces from being visible at the bottom
// of the chat on iOS Safari (`safe-area-inset`) when
// - the on-screen keyboard appears and the user starts typing
// - the user selects the text inside the input area
// - the user selects and deletes the text that is multiple lines long
// TODO: unify the chat layout with the global layout.
let html = document.querySelector('html')
if (html) {
html.classList.add('chat-layout')
}
this.$nextTick(() => {
this.updateScrollableContainerHeight()
})
},
unsetChatLayout () {
let html = document.querySelector('html')
if (html) {
html.classList.remove('chat-layout')
}
},
handleLayoutChange () {
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.scrollDown()
})
},
// Ensures the proper position of the posting form in the mobile layout (the mobile browser panel does not overlap or hide it)
updateScrollableContainerHeight () {
const header = this.$refs.header
const footer = this.$refs.footer
const inner = this.mobileLayout ? window.document.body : this.$refs.inner
this.scrollableContainerHeight = scrollableContainerHeight(inner, header, footer) + 'px'
},
// Preserves the scroll position when OSK appears or the posting form changes its height.
handleResize (opts = {}) {
const { expand = false, delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
this.$nextTick(() => {
this.updateScrollableContainerHeight()
const { offsetHeight = undefined } = this.lastScrollPosition
this.lastScrollPosition = getScrollPosition(this.$refs.scrollable)
const diff = this.lastScrollPosition.offsetHeight - offsetHeight
if (diff < 0 || (!this.bottomedOut() && expand)) {
this.$nextTick(() => {
this.updateScrollableContainerHeight()
this.$refs.scrollable.scrollTo({
top: this.$refs.scrollable.scrollTop - diff,
left: 0
})
})
}
})
},
scrollDown (options = {}) {
const { behavior = 'auto', forceRead = false } = options
const scrollable = this.$refs.scrollable
if (!scrollable) { return }
this.$nextTick(() => {
scrollable.scrollTo({ top: scrollable.scrollHeight, left: 0, behavior })
})
if (forceRead || this.newMessageCount > 0) {
this.readChat()
}
},
readChat () {
if (!(this.currentChatMessageService && this.currentChatMessageService.lastMessage)) { return }
if (document.hidden) { return }
const lastReadId = this.currentChatMessageService.lastMessage.id
this.$store.dispatch('readChat', { id: this.currentChat.id, lastReadId })
},
bottomedOut (offset) {
return isBottomedOut(this.$refs.scrollable, offset)
},
reachedTop () {
const scrollable = this.$refs.scrollable
return scrollable && scrollable.scrollTop <= 0
},
handleScroll: _.throttle(function () {
if (!this.currentChat) { return }
if (this.reachedTop()) {
this.fetchChat({ maxId: this.currentChatMessageService.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
if (this.newMessageCount > 0) {
this.readChat()
}
} else {
this.jumpToBottomButtonVisible = true
}
}, 100),
handleScrollUp (positionBeforeLoading) {
const positionAfterLoading = getScrollPosition(this.$refs.scrollable)
this.$refs.scrollable.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
left: 0
})
},
fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) {
const chatMessageService = this.currentChatMessageService
if (!chatMessageService) { return }
if (fetchLatest && this.streamingEnabled) { return }
const chatId = chatMessageService.chatId
const fetchOlderMessages = !!maxId
const sinceId = fetchLatest && chatMessageService.lastMessage && chatMessageService.lastMessage.id
this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId })
.then((messages) => {
// Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) {
chatService.clear(chatMessageService)
}
const positionBeforeUpdate = getScrollPosition(this.$refs.scrollable)
this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => {
this.$nextTick(() => {
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
if (isFirstFetch) {
this.updateScrollableContainerHeight()
}
})
})
})
},
async startFetching () {
let chat = this.findOpenedChatByRecipientId(this.recipientId)
if (!chat) {
try {
chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId })
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
}
if (chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.$store.dispatch('addOpenedChat', { chat })
this.doStartFetching()
}
},
doStartFetching () {
this.$store.dispatch('startFetchingCurrentChat', {
fetcher: () => setInterval(() => this.fetchChat({ fetchLatest: true }), 5000)
})
this.fetchChat({ isFirstFetch: true })
},
sendMessage ({ status, media }) {
const params = {
id: this.currentChat.id,
content: status
}
if (media[0]) {
params.mediaId = media[0].id
}
return this.backendInteractor.sendChatMessage(params)
.then(data => {
this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, messages: [data] }).then(() => {
this.$nextTick(() => {
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
// to account for the potential delay in the DOM update.
setTimeout(() => {
this.updateScrollableContainerHeight()
}, SAFE_RESIZE_TIME_OFFSET)
this.scrollDown({ forceRead: true })
})
})
return data
})
.catch(error => {
console.error('Error sending message', error)
return {
error: this.$t('chats.error_sending_message')
}
})
},
goBack () {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } })
}
}
}
export default Chat

View File

@ -0,0 +1,162 @@
.chat-view {
display: flex;
height: calc(100vh - 60px);
width: 100%;
.chat-title {
// prevents chat header jumping on when the user avatar loads
height: 28px;
}
.chat-view-inner {
height: auto;
width: 100%;
overflow: visible;
display: flex;
margin: 0.5em 0.5em 0 0.5em;
}
.chat-view-body {
background-color: var(--chatBg, $fallback--bg);
display: flex;
flex-direction: column;
width: 100%;
overflow: visible;
min-height: 100%;
margin: 0 0 0 0;
border-radius: 10px 10px 0 0;
border-radius: var(--panelRadius, 10px) var(--panelRadius, 10px) 0 0 ;
&::after {
border-radius: 0;
}
}
.scrollable-message-list {
padding: 0 0.8em;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.footer {
position: sticky;
bottom: 0;
}
.chat-view-heading {
align-items: center;
justify-content: space-between;
top: 50px;
display: flex;
z-index: 2;
position: sticky;
overflow: hidden;
}
.go-back-button {
cursor: pointer;
margin-right: 1.4em;
i {
display: flex;
align-items: center;
}
}
.jump-to-bottom-button {
width: 2.5em;
height: 2.5em;
border-radius: 100%;
position: absolute;
right: 1.3em;
top: -3.2em;
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3), 0px 2px 4px rgba(0, 0, 0, 0.3);
z-index: 10;
transition: 0.35s all;
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
opacity: 0;
visibility: hidden;
cursor: pointer;
&.visible {
opacity: 1;
visibility: visible;
}
i {
font-size: 1em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
.unread-message-count {
font-size: 0.8em;
left: 50%;
transform: translate(-50%, 0);
border-radius: 100%;
margin-top: -1rem;
padding: 0;
}
.chat-loading-error {
width: 100%;
display: flex;
align-items: flex-end;
height: 100%;
.error {
width: 100%;
}
}
}
@media all and (max-width: 800px) {
height: 100%;
overflow: hidden;
.chat-view-inner {
overflow: hidden;
height: 100%;
margin-top: 0;
margin-left: 0;
margin-right: 0;
}
.chat-view-body {
display: flex;
min-height: auto;
overflow: hidden;
height: 100%;
margin: 0;
border-radius: 0;
}
.chat-view-heading {
position: static;
z-index: 9999;
top: 0;
margin-top: 0;
border-radius: 0;
}
.scrollable-message-list {
display: unset;
overflow-y: scroll;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.footer {
position: sticky;
bottom: auto;
}
}
}

View File

@ -0,0 +1,100 @@
<template>
<div class="chat-view">
<div class="chat-view-inner">
<div
id="nav"
ref="inner"
class="panel-default panel chat-view-body"
>
<div
ref="header"
class="panel-heading chat-view-heading mobile-hidden"
>
<a
class="go-back-button"
@click="goBack"
>
<i class="button-icon icon-left-open" />
</a>
<div class="title text-center">
<ChatTitle
:user="recipient"
:with-avatar="true"
/>
</div>
</div>
<template>
<div
ref="scrollable"
class="scrollable-message-list"
:style="{ height: scrollableContainerHeight }"
@scroll="handleScroll"
>
<template v-if="!errorLoadingChat">
<ChatMessage
v-for="chatViewItem in chatViewItems"
:key="chatViewItem.id"
:author="recipient"
:chat-view-item="chatViewItem"
:hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
@hover="onMessageHover"
/>
</template>
<div
v-else
class="chat-loading-error"
>
<div class="alert error">
{{ $t('chats.error_loading_chat') }}
</div>
</div>
</div>
<div
ref="footer"
class="panel-body footer"
>
<div
class="jump-to-bottom-button"
:class="{ 'visible': jumpToBottomButtonVisible }"
@click="scrollDown({ behavior: 'smooth' })"
>
<i class="icon-down-open">
<div
v-if="newMessageCount"
class="badge badge-notification unread-chat-count unread-message-count"
>
{{ newMessageCount }}
</div>
</i>
</div>
<PostStatusForm
:disable-subject="true"
:disable-scope-selector="true"
:disable-notice="true"
:disable-lock-warning="true"
:disable-polls="true"
:disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true"
:post-handler="sendMessage"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout"
:placeholder="formPlaceholder"
:file-limit="1"
max-height="160"
emoji-picker-placement="top"
@resize="handleResize"
/>
</div>
</template>
</div>
</div>
</div>
</template>
<script src="./chat.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@import './chat.scss';
</style>

View File

@ -0,0 +1,26 @@
// Captures a scroll position
export const getScrollPosition = (el) => {
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
offsetHeight: el.offsetHeight
}
}
// A helper function that is used to keep the scroll position fixed as the new elements are added to the top
// Takes two scroll positions, before and after the update.
export const getNewTopPosition = (previousPosition, newPosition) => {
return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight)
}
export const isBottomedOut = (el, offset = 0) => {
if (!el) { return }
const scrollHeight = el.scrollTop + offset
const totalHeight = el.scrollHeight - el.offsetHeight
return totalHeight <= scrollHeight
}
// Height of the scrollable container. The dynamic height is needed to ensure the mobile browser panel doesn't overlap or hide the posting form.
export const scrollableContainerHeight = (inner, header, footer) => {
return inner.offsetHeight - header.clientHeight - footer.clientHeight
}

View File

@ -0,0 +1,37 @@
import { mapState, mapGetters } from 'vuex'
import ChatListItem from '../chat_list_item/chat_list_item.vue'
import ChatNew from '../chat_new/chat_new.vue'
import List from '../list/list.vue'
const ChatList = {
components: {
ChatListItem,
List,
ChatNew
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
...mapGetters(['sortedChatList'])
},
data () {
return {
isNew: false
}
},
created () {
this.$store.dispatch('fetchChats', { latest: true })
},
methods: {
cancelNewChat () {
this.isNew = false
this.$store.dispatch('fetchChats', { latest: true })
},
newChat () {
this.isNew = true
}
}
}
export default ChatList

View File

@ -0,0 +1,64 @@
<template>
<div v-if="isNew">
<ChatNew @cancel="cancelNewChat" />
</div>
<div
v-else
class="chat-list panel panel-default"
>
<div class="panel-heading">
<span class="title">
{{ $t("chats.chats") }}
</span>
<button @click="newChat">
{{ $t("chats.new") }}
</button>
</div>
<div class="panel-body">
<div
v-if="sortedChatList.length > 0"
class="timeline"
>
<List :items="sortedChatList">
<template
slot="item"
slot-scope="{item}"
>
<ChatListItem
:key="item.id"
:compact="false"
:chat="item"
/>
</template>
</List>
</div>
<div
v-else
class="emtpy-chat-list-alert"
>
<span>{{ $t('chats.empty_chat_list_placeholder') }}</span>
</div>
</div>
</div>
</template>
<script src="./chat_list.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.chat-list {
min-height: 25em;
margin-bottom: 0;
}
.emtpy-chat-list-alert {
padding: 3em;
font-size: 1.2em;
display: flex;
justify-content: center;
color: $fallback--text;
color: var(--faint, $fallback--text);
}
</style>

View File

@ -0,0 +1,67 @@
import { mapState } from 'vuex'
import StatusContent from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service'
import UserAvatar from '../user_avatar/user_avatar.vue'
import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue'
import ChatTitle from '../chat_title/chat_title.vue'
const ChatListItem = {
name: 'ChatListItem',
props: [
'chat'
],
components: {
UserAvatar,
AvatarList,
Timeago,
ChatTitle,
StatusContent
},
computed: {
...mapState({
currentUser: state => state.users.currentUser
}),
attachmentInfo () {
if (this.chat.lastMessage.attachments.length === 0) { return }
const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype))
if (types.includes('video')) {
return this.$t('file_type.video')
} else if (types.includes('audio')) {
return this.$t('file_type.audio')
} else if (types.includes('image')) {
return this.$t('file_type.image')
} else {
return this.$t('file_type.file')
}
},
messageForStatusContent () {
const message = this.chat.lastMessage
const isYou = message && message.account_id === this.currentUser.id
const content = message ? (this.attachmentInfo || message.content) : ''
const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content
return {
summary: '',
statusnet_html: messagePreview,
text: messagePreview,
attachments: []
}
}
},
methods: {
openChat (_e) {
if (this.chat.id) {
this.$router.push({
name: 'chat',
params: {
username: this.currentUser.screen_name,
recipient_id: this.chat.account.id
}
})
}
}
}
}
export default ChatListItem

View File

@ -0,0 +1,94 @@
.chat-list-item {
display: flex;
flex-direction: row;
padding: 0.75em;
height: 5em;
overflow: hidden;
box-sizing: border-box;
cursor: pointer;
:focus {
outline: none;
}
&:hover {
background-color: var(--selectedPost, $fallback--lightBg);
box-shadow: 0 0 3px 1px rgba(0, 0, 0, 0.1);
}
.chat-list-item-left {
margin-right: 1em;
}
.chat-list-item-center {
width: 100%;
box-sizing: border-box;
overflow: hidden;
word-wrap: break-word;
}
.heading {
width: 100%;
display: inline-flex;
justify-content: space-between;
line-height: 1em;
}
.heading-right {
white-space: nowrap;
}
.name-and-account-name {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
flex-shrink: 1;
line-height: 1.4em;
}
.chat-preview {
display: inline-flex;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
margin: 0.35em 0;
color: $fallback--text;
color: var(--faint, $fallback--text);
width: 100%;
}
a {
color: var(--faintLink, $fallback--link);
text-decoration: none;
pointer-events: none;
}
&:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
.Avatar {
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
}
.StatusContent {
img.emoji {
width: 1.4em;
height: 1.4em;
}
}
.time-wrapper {
line-height: 1.4em;
}
.single-line {
padding-right: 1em;
}
}

View File

@ -0,0 +1,52 @@
<template>
<div
class="chat-list-item"
@click.capture.prevent="openChat"
>
<div class="chat-list-item-left">
<UserAvatar
:user="chat.account"
height="48px"
width="48px"
/>
</div>
<div class="chat-list-item-center">
<div class="heading">
<span
v-if="chat.account"
class="name-and-account-name"
>
<ChatTitle
:user="chat.account"
/>
</span>
<span class="heading-right" />
</div>
<div class="chat-preview">
<StatusContent
:status="messageForStatusContent"
:single-line="true"
/>
<div
v-if="chat.unread > 0"
class="badge badge-notification unread-chat-count"
>
{{ chat.unread }}
</div>
</div>
</div>
<div class="time-wrapper">
<Timeago
:time="chat.updated_at"
:auto-update="60"
/>
</div>
</div>
</template>
<script src="./chat_list_item.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@import './chat_list_item.scss';
</style>

View File

@ -0,0 +1,96 @@
import { mapState, mapGetters } from 'vuex'
import Popover from '../popover/popover.vue'
import Attachment from '../attachment/attachment.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import StatusContent from '../status_content/status_content.vue'
import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const ChatMessage = {
name: 'ChatMessage',
props: [
'author',
'edited',
'noHeading',
'chatViewItem',
'hoveredMessageChain'
],
components: {
Popover,
Attachment,
StatusContent,
UserAvatar,
Gallery,
LinkPreview,
ChatMessageDate
},
computed: {
// Returns HH:MM (hours and minutes) in local time.
createdAt () {
const time = this.chatViewItem.data.created_at
return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false })
},
isCurrentUser () {
return this.message.account_id === this.currentUser.id
},
message () {
return this.chatViewItem.data
},
userProfileLink () {
return generateProfileLink(this.author.id, this.author.screen_name, this.$store.state.instance.restrictedNicknames)
},
isMessage () {
return this.chatViewItem.type === 'message'
},
messageForStatusContent () {
return {
summary: '',
statusnet_html: this.message.content,
text: this.message.content,
attachments: this.message.attachments
}
},
hasAttachment () {
return this.message.attachments.length > 0
},
...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter,
currentUser: state => state.users.currentUser,
restrictedNicknames: state => state.instance.restrictedNicknames
}),
popoverMarginStyle () {
if (this.isCurrentUser) {
return {}
} else {
return { left: 50 }
}
},
...mapGetters(['mergedConfig', 'findUser'])
},
data () {
return {
hovered: false,
menuOpened: false
}
},
methods: {
onHover (bool) {
this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId })
},
async deleteMessage () {
const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) {
await this.$store.dispatch('deleteChatMessage', {
messageId: this.chatViewItem.data.id,
chatId: this.chatViewItem.data.chat_id
})
}
this.hovered = false
this.menuOpened = false
}
}
}
export default ChatMessage

View File

@ -0,0 +1,164 @@
@import '../../_variables.scss';
.chat-message-wrapper {
&.hovered-message-chain {
.animated.Avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
}
.chat-message-menu {
transition: opacity 0.1s;
opacity: 0;
position: absolute;
top: -0.8em;
button {
padding-top: 0.2em;
padding-bottom: 0.2em;
}
}
.icon-ellipsis {
cursor: pointer;
&:hover, .extra-button-popover.open & {
color: $fallback--text;
color: var(--text, $fallback--text);
}
border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
}
.popover {
width: 12em;
}
.chat-message {
display: flex;
padding-bottom: 0.5em;
}
.avatar-wrapper {
margin-right: 0.72em;
width: 32px;
}
.link-preview, .attachments {
margin-bottom: 1em;
}
.chat-message-inner {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 80%;
min-width: 10em;
width: 100%;
&.with-media {
width: 100%;
.gallery-row {
overflow: hidden;
}
.status {
width: 100%;
}
}
}
.status {
border-radius: $fallback--chatMessageRadius;
border-radius: var(--chatMessageRadius, $fallback--chatMessageRadius);
display: flex;
padding: 0.75em;
}
.created-at {
position: relative;
float: right;
font-size: 0.8em;
margin: -1em 0 -0.5em 0;
font-style: italic;
opacity: 0.8;
}
.without-attachment {
.status-content {
&::after {
margin-right: 5.4em;
content: " ";
display: inline-block;
}
}
}
.incoming {
a {
color: var(--chatMessageIncomingLink, $fallback--link);
}
.status {
color: var(--chatMessageIncomingText, $fallback--text);
background-color: var(--chatMessageIncomingBg, $fallback--bg);
border: 1px solid var(--chatMessageIncomingBorder, --border);
}
.created-at {
a {
color: var(--chatMessageIncomingText, $fallback--text);
}
}
.chat-message-menu {
left: 0.4rem;
}
}
.outgoing {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-content: end;
justify-content: flex-end;
a {
color: var(--chatMessageOutgoingLink, $fallback--link);
}
.status {
color: var(--chatMessageOutgoingText, $fallback--text);
background-color: var(--chatMessageOutgoingBg, $fallback--lightBg);
border: 1px solid var(--chatMessageOutgoingBorder, --lightBg);
}
.chat-message-inner {
align-items: flex-end;
}
.chat-message-menu {
right: 0.4rem;
}
}
.visible {
opacity: 1;
}
}
.chat-message-date-separator {
text-align: center;
margin: 1.4em 0;
font-size: 0.9em;
user-select: none;
color: $fallback--text;
color: var(--faintedText, $fallback--text);
}

View File

@ -0,0 +1,99 @@
<template>
<div
v-if="isMessage"
class="chat-message-wrapper"
:class="{ 'hovered-message-chain': hoveredMessageChain }"
@mouseover="onHover(true)"
@mouseleave="onHover(false)"
>
<div
class="chat-message"
:class="[{ 'outgoing': isCurrentUser, 'incoming': !isCurrentUser }]"
>
<div
v-if="!isCurrentUser"
class="avatar-wrapper"
>
<router-link
v-if="chatViewItem.isHead"
:to="userProfileLink"
>
<UserAvatar
:compact="true"
:better-shadow="betterShadow"
:user="author"
/>
</router-link>
</div>
<div class="chat-message-inner">
<div
class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }"
>
<div
class="media status"
:class="{ 'without-attachment': !hasAttachment }"
style="position: relative"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<div
class="chat-message-menu"
:class="{ 'visible': hovered || menuOpened }"
>
<Popover
trigger="click"
placement="top"
:bound-to-selector="isCurrentUser ? '' : '.scrollable-message-list'"
:bound-to="{ x: 'container' }"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
>
<div slot="content">
<div class="dropdown-menu">
<button
class="dropdown-item dropdown-item-icon"
@click="deleteMessage"
>
<i class="icon-cancel" /> {{ $t("chats.delete") }}
</button>
</div>
</div>
<button
slot="trigger"
:title="$t('chats.more')"
>
<i class="icon-ellipsis" />
</button>
</Popover>
</div>
<StatusContent
:status="messageForStatusContent"
:full-content="true"
>
<span
slot="footer"
class="created-at"
>
{{ createdAt }}
</span>
</StatusContent>
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="chat-message-date-separator"
>
<ChatMessageDate :date="chatViewItem.date" />
</div>
</template>
<script src="./chat_message.js" ></script>
<style lang="scss">
@import './chat_message.scss';
</style>

View File

@ -0,0 +1,24 @@
<template>
<time>
{{ displayDate }}
</time>
</template>
<script>
export default {
name: 'Timeago',
props: ['date'],
computed: {
displayDate () {
const today = new Date()
today.setHours(0, 0, 0, 0)
if (this.date.getTime() === today.getTime()) {
return this.$t('display_date.today')
} else {
return this.date.toLocaleDateString('en', { day: 'numeric', month: 'long' })
}
}
}
}
</script>

View File

@ -0,0 +1,73 @@
import { mapState, mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
const chatNew = {
components: {
BasicUserCard,
UserAvatar
},
data () {
return {
suggestions: [],
userIds: [],
loading: false,
query: ''
}
},
async created () {
const { chats } = await this.backendInteractor.chats()
chats.forEach(chat => this.suggestions.push(chat.account))
},
computed: {
users () {
return this.userIds.map(userId => this.findUser(userId))
},
availableUsers () {
if (this.query.length !== 0) {
return this.users
} else {
return this.suggestions
}
},
...mapState({
currentUser: state => state.users.currentUser,
backendInteractor: state => state.api.backendInteractor
}),
...mapGetters(['findUser'])
},
methods: {
goBack () {
this.$emit('cancel')
},
goToChat (user) {
this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
},
onInput () {
this.search(this.query)
},
addUser (user) {
this.selectedUserIds.push(user.id)
this.query = ''
},
removeUser (userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId)
},
search (query) {
if (!query) {
this.loading = false
return
}
this.loading = true
this.userIds = []
this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' })
.then(data => {
this.loading = false
this.userIds = data.accounts.map(a => a.id)
})
}
}
}
export default chatNew

View File

@ -0,0 +1,29 @@
.chat-new {
.input-wrap {
display: flex;
margin: 0.7em 0.5em 0.7em 0.5em;
input {
width: 100%;
}
}
.icon-search {
font-size: 1.5em;
float: right;
margin-right: 0.3em;
}
.member-list {
padding-bottom: 0.7rem;
}
.basic-user-card:hover {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
.go-back-button {
cursor: pointer;
}
}

View File

@ -0,0 +1,46 @@
<template>
<div
id="nav"
class="panel-default panel chat-new"
>
<div
ref="header"
class="panel-heading"
>
<a
class="go-back-button"
@click="goBack"
>
<i class="button-icon icon-left-open" />
</a>
</div>
<div class="input-wrap">
<div class="input-search">
<i class="button-icon icon-search" />
</div>
<input
ref="search"
v-model="query"
placeholder="Search people"
@input="onInput"
>
</div>
<div class="member-list">
<div
v-for="user in availableUsers"
:key="user.id"
class="member"
>
<div @click.capture.prevent="goToChat(user)">
<BasicUserCard :user="user" />
</div>
</div>
</div>
</div>
</template>
<script src="./chat_new.js"></script>
<style lang="scss">
@import '../../_variables.scss';
@import './chat_new.scss';
</style>

View File

@ -10,7 +10,7 @@
@click.stop.prevent="togglePanel" @click.stop.prevent="togglePanel"
> >
<div class="title"> <div class="title">
<span>{{ $t('chat.title') }}</span> <span>{{ $t('shoutbox.title') }}</span>
<i <i
v-if="floating" v-if="floating"
class="icon-cancel" class="icon-cancel"
@ -64,7 +64,7 @@
> >
<div class="title"> <div class="title">
<i class="icon-comment-empty" /> <i class="icon-comment-empty" />
{{ $t('chat.title') }} {{ $t('shoutbox.title') }}
</div> </div>
</div> </div>
</div> </div>
@ -84,54 +84,56 @@
max-width: 25em; max-width: 25em;
} }
.chat-heading {
cursor: pointer;
.icon-comment-empty {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.chat-window {
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.chat-window-container {
height: 100%;
}
.chat-message {
display: flex;
padding: 0.2em 0.5em
}
.chat-avatar {
img {
height: 24px;
width: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
margin-right: 0.5em;
margin-top: 0.25em;
}
}
.chat-input {
display: flex;
textarea {
flex: 1;
margin: 0.6em;
min-height: 3.5em;
resize: none;
}
}
.chat-panel { .chat-panel {
.title { .chat-heading {
cursor: pointer;
.icon-comment-empty {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.chat-window {
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.chat-window-container {
height: 100%;
}
.chat-message {
display: flex; display: flex;
justify-content: space-between; padding: 0.2em 0.5em
}
.chat-avatar {
img {
height: 24px;
width: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
margin-right: 0.5em;
margin-top: 0.25em;
}
}
.chat-input {
display: flex;
textarea {
flex: 1;
margin: 0.6em;
min-height: 3.5em;
resize: none;
}
}
.chat-panel {
.title {
display: flex;
justify-content: space-between;
}
} }
} }
</style> </style>

View File

@ -0,0 +1,26 @@
import Vue from 'vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import UserAvatar from '../user_avatar/user_avatar.vue'
export default Vue.component('chat-title', {
name: 'ChatTitle',
components: {
UserAvatar
},
props: [
'user', 'withAvatar'
],
computed: {
title () {
return this.user ? this.user.screen_name : ''
},
htmlTitle () {
return this.user ? this.user.name_html : ''
}
},
methods: {
getUserProfileLink (user) {
return generateProfileLink(user.id, user.screen_name)
}
}
})

View File

@ -0,0 +1,67 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div
class="chat-title"
:title="title"
>
<router-link
v-if="withAvatar && user"
:to="getUserProfileLink(user)"
>
<UserAvatar
:user="user"
width="23px"
height="23px"
/>
</router-link>
<span
class="username"
v-html="htmlTitle"
/>
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<script src="./chat_title.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.chat-title {
display: flex;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
align-items: center;
.username {
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
display: inline;
word-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
.Avatar {
width: 23px;
height: 23px;
margin-right: 0.5em;
border-radius: $fallback--avatarAltRadius;
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
&.animated::before {
display: none;
}
}
}
</style>

View File

@ -52,7 +52,7 @@ export default {
right: 0; right: 0;
top: 0; top: 0;
display: block; display: block;
content: ''; content: '';
transition: color 200ms; transition: color 200ms;
width: 1.1em; width: 1.1em;
height: 1.1em; height: 1.1em;

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
class="timeline panel-default" class="Conversation"
:class="[isExpanded ? 'panel' : 'panel-disabled']" :class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
> >
<div <div
v-if="isExpanded" v-if="isExpanded"
@ -28,7 +28,7 @@
:replies="getReplies(status.id)" :replies="getReplies(status.id)"
:in-profile="inProfile" :in-profile="inProfile"
:profile-user-id="profileUserId" :profile-user-id="profileUserId"
class="status-fadein panel-body" class="conversation-status status-fadein panel-body"
@goto="setHighlight" @goto="setHighlight"
@toggleExpanded="toggleExpanded" @toggleExpanded="toggleExpanded"
/> />
@ -40,14 +40,27 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.timeline { .Conversation {
.panel-disabled { .conversation-status {
.status-el { border-left: none;
border-left: none; border-bottom-width: 1px;
border-bottom-width: 1px; border-bottom-style: solid;
border-bottom-style: solid; border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
}
&.-expanded {
.conversation-status {
border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
border-radius: 0; border-left: 4px solid $fallback--cRed;
border-left: 4px solid var(--cRed, $fallback--cRed);
}
.conversation-status:last-child {
border-bottom: none;
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
} }
} }
} }

View File

@ -79,6 +79,20 @@ const EmojiInput = {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false
},
placement: {
/**
* Forces the panel to take a specific position relative to the input element.
* The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
*/
required: false,
type: String, // 'auto', 'top', 'bottom'
default: 'auto'
},
newlineOnCtrlEnter: {
required: false,
type: Boolean,
default: false
} }
}, },
data () { data () {
@ -162,6 +176,11 @@ const EmojiInput = {
input.elm.removeEventListener('input', this.onInput) input.elm.removeEventListener('input', this.onInput)
} }
}, },
watch: {
showSuggestions: function (newValue) {
this.$emit('shown', newValue)
}
},
methods: { methods: {
triggerShowPicker () { triggerShowPicker () {
this.showPicker = true this.showPicker = true
@ -190,7 +209,7 @@ const EmojiInput = {
this.$emit('input', newValue) this.$emit('input', newValue)
this.caret = 0 this.caret = 0
}, },
insert ({ insertion, keepOpen }) { insert ({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.value.substring(0, this.caret) || '' const before = this.value.substring(0, this.caret) || ''
const after = this.value.substring(this.caret) || '' const after = this.value.substring(this.caret) || ''
@ -209,8 +228,8 @@ const EmojiInput = {
* them, masto seem to be rendering :emoji::emoji: correctly now so why not * them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/ */
const isSpaceRegex = /\s/ const isSpaceRegex = /\s/
const spaceBefore = !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0 ? ' ' : '' const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
const spaceAfter = !isSpaceRegex.exec(after[0]) && this.padEmoji ? ' ' : '' const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
const newValue = [ const newValue = [
before, before,
@ -367,6 +386,18 @@ const EmojiInput = {
}, },
onKeyDown (e) { onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e const { ctrlKey, shiftKey, key } = e
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
this.insert({ insertion: '\n', surroundingSpace: false })
// Ensure only one new line is added on macos
e.stopPropagation()
e.preventDefault()
// Scroll the input element to the position of the cursor
this.$nextTick(() => {
this.input.elm.blur()
this.input.elm.focus()
})
}
// Disable suggestions hotkeys if suggestions are hidden // Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) { if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') { if (key === 'Tab') {
@ -425,15 +456,29 @@ const EmojiInput = {
this.caret = selectionStart this.caret = selectionStart
}, },
resize () { resize () {
const { panel, picker } = this.$refs const panel = this.$refs.panel
if (!panel) return if (!panel) return
const picker = this.$refs.picker.$el
const panelBody = this.$refs['panel-body']
const { offsetHeight, offsetTop } = this.input.elm const { offsetHeight, offsetTop } = this.input.elm
const offsetBottom = offsetTop + offsetHeight const offsetBottom = offsetTop + offsetHeight
panel.style.top = offsetBottom + 'px' this.setPlacement(panelBody, panel, offsetBottom)
if (!picker) return this.setPlacement(picker, picker, offsetBottom)
picker.$el.style.top = offsetBottom + 'px' },
picker.$el.style.bottom = 'auto' setPlacement (container, target, offsetBottom) {
if (!container || !target) return
target.style.top = offsetBottom + 'px'
target.style.bottom = 'auto'
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
target.style.top = 'auto'
target.style.bottom = this.input.elm.offsetHeight + 'px'
}
},
overflowsBottom (el) {
return el.getBoundingClientRect().bottom > window.innerHeight
} }
} }
} }

View File

@ -29,7 +29,10 @@
class="autocomplete-panel" class="autocomplete-panel"
:class="{ hide: !showSuggestions }" :class="{ hide: !showSuggestions }"
> >
<div class="autocomplete-panel-body"> <div
ref="panel-body"
class="autocomplete-panel-body"
>
<div <div
v-for="(suggestion, index) in suggestions" v-for="(suggestion, index) in suggestions"
:key="index" :key="index"

View File

@ -1,5 +1,5 @@
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import Popover from '../popover/popover.vue' import UserListPopover from '../user_list_popover/user_list_popover.vue'
const EMOJI_REACTION_COUNT_CUTOFF = 12 const EMOJI_REACTION_COUNT_CUTOFF = 12
@ -7,7 +7,7 @@ const EmojiReactions = {
name: 'EmojiReactions', name: 'EmojiReactions',
components: { components: {
UserAvatar, UserAvatar,
Popover UserListPopover
}, },
props: ['status'], props: ['status'],
data: () => ({ data: () => ({

View File

@ -1,44 +1,11 @@
<template> <template>
<div class="emoji-reactions"> <div class="emoji-reactions">
<Popover <UserListPopover
v-for="(reaction) in emojiReactions" v-for="(reaction) in emojiReactions"
:key="reaction.name" :key="reaction.name"
trigger="hover" :users="accountsForEmoji[reaction.name]"
placement="top"
:offset="{ y: 5 }"
> >
<div
slot="content"
class="reacted-users"
>
<div v-if="accountsForEmoji[reaction.name].length">
<div
v-for="(account) in accountsForEmoji[reaction.name]"
:key="account.id"
class="reacted-user"
>
<UserAvatar
:user="account"
class="avatar-small"
:compact="true"
/>
<div class="reacted-user-names">
<!-- eslint-disable vue/no-v-html -->
<span
class="reacted-user-name"
v-html="account.name_html"
/>
<!-- eslint-enable vue/no-v-html -->
<span class="reacted-user-screen-name">{{ account.screen_name }}</span>
</div>
</div>
</div>
<div v-else>
<i class="icon-spin4 animate-spin" />
</div>
</div>
<button <button
slot="trigger"
class="emoji-reaction btn btn-default" class="emoji-reaction btn btn-default"
:class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }" :class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
@click="emojiOnClick(reaction.name, $event)" @click="emojiOnClick(reaction.name, $event)"
@ -47,7 +14,7 @@
<span class="reaction-emoji">{{ reaction.name }}</span> <span class="reaction-emoji">{{ reaction.name }}</span>
<span>{{ reaction.count }}</span> <span>{{ reaction.count }}</span>
</button> </button>
</Popover> </UserListPopover>
<a <a
v-if="tooManyReactions" v-if="tooManyReactions"
class="emoji-reaction-expand faint" class="emoji-reaction-expand faint"
@ -69,32 +36,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.reacted-users {
padding: 0.5em;
}
.reacted-user {
padding: 0.25em;
display: flex;
flex-direction: row;
.reacted-user-names {
display: flex;
flex-direction: column;
margin-left: 0.5em;
min-width: 5em;
img {
width: 1em;
height: 1em;
}
}
.reacted-user-screen-name {
font-size: 9px;
}
}
.emoji-reaction { .emoji-reaction {
padding: 0 0.5em; padding: 0 0.5em;
margin-right: 0.5em; margin-right: 0.5em;

View File

@ -34,6 +34,16 @@ const ExtraButtons = {
navigator.clipboard.writeText(this.statusLink) navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess')) .then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error)) .catch(err => this.$emit('onError', err.error.error))
},
bookmarkStatus () {
this.$store.dispatch('bookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
unbookmarkStatus () {
this.$store.dispatch('unbookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
} }
}, },
computed: { computed: {

View File

@ -40,6 +40,22 @@
> >
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span> <i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</button> </button>
<button
v-if="!status.bookmarked"
class="dropdown-item dropdown-item-icon"
@click.prevent="bookmarkStatus"
@click="close"
>
<i class="icon-bookmark-empty" /><span>{{ $t("status.bookmark") }}</span>
</button>
<button
v-if="status.bookmarked"
class="dropdown-item dropdown-item-icon"
@click.prevent="unbookmarkStatus"
@click="close"
>
<i class="icon-bookmark" /><span>{{ $t("status.unbookmark") }}</span>
</button>
<button <button
v-if="canDelete" v-if="canDelete"
class="dropdown-item dropdown-item-icon" class="dropdown-item dropdown-item-icon"

View File

@ -1,6 +1,7 @@
const FeaturesPanel = { const FeaturesPanel = {
computed: { computed: {
chat: function () { return this.$store.state.instance.chatAvailable }, chat: function () { return this.$store.state.instance.chatAvailable },
pleromaChatMessages: function () { return this.$store.state.instance.pleromaChatMessagesAvailable },
gopher: function () { return this.$store.state.instance.gopherAvailable }, gopher: function () { return this.$store.state.instance.gopherAvailable },
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled }, whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable }, mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },

View File

@ -11,6 +11,9 @@
<li v-if="chat"> <li v-if="chat">
{{ $t('features_panel.chat') }} {{ $t('features_panel.chat') }}
</li> </li>
<li v-if="pleromaChatMessages">
{{ $t('features_panel.pleroma_chat_messages') }}
</li>
<li v-if="gopher"> <li v-if="gopher">
{{ $t('features_panel.gopher') }} {{ $t('features_panel.gopher') }}
</li> </li>

View File

@ -50,9 +50,7 @@
align-content: stretch; align-content: stretch;
} }
// FIXME: specificity problem with this and .attachments.attachment .gallery-row-inner .attachment {
// we shouldn't have the need for .image here
.attachment.image {
margin: 0 0.5em 0 0; margin: 0 0.5em 0 0;
flex-grow: 1; flex-grow: 1;
height: 100%; height: 100%;

View File

@ -0,0 +1,15 @@
const GlobalNoticeList = {
computed: {
notices () {
return this.$store.state.interface.globalNotices
}
},
methods: {
closeNotice (notice) {
this.$store.dispatch('removeGlobalNotice', notice)
}
}
}
export default GlobalNoticeList

View File

@ -0,0 +1,77 @@
<template>
<div class="global-notice-list">
<div
v-for="(notice, index) in notices"
:key="index"
class="alert global-notice"
:class="{ ['global-' + notice.level]: true }"
>
<div class="notice-message">
{{ $t(notice.messageKey, notice.messageArgs) }}
</div>
<i
class="button-icon icon-cancel"
@click="closeNotice(notice)"
/>
</div>
</div>
</template>
<script src="./global_notice_list.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.global-notice-list {
position: fixed;
top: 50px;
width: 100%;
pointer-events: none;
z-index: 1001;
display: flex;
flex-direction: column;
align-items: center;
.global-notice {
pointer-events: auto;
text-align: center;
width: 40em;
max-width: calc(100% - 3em);
display: flex;
padding-left: 1.5em;
line-height: 2em;
.notice-message {
flex: 1 1 100%;
}
i {
flex: 0 0;
width: 1.5em;
cursor: pointer;
}
}
.global-error {
background-color: var(--alertPopupError, $fallback--cRed);
color: var(--alertPopupErrorText, $fallback--text);
i {
color: var(--alertPopupErrorText, $fallback--text);
}
}
.global-warning {
background-color: var(--alertPopupWarning, $fallback--cOrange);
color: var(--alertPopupWarningText, $fallback--text);
i {
color: var(--alertPopupWarningText, $fallback--text);
}
}
.global-info {
background-color: var(--alertPopupNeutral, $fallback--fg);
color: var(--alertPopupNeutralText, $fallback--text);
i {
color: var(--alertPopupNeutralText, $fallback--text);
}
}
}
</style>

View File

@ -8,6 +8,8 @@
v-if="type === 'image'" v-if="type === 'image'"
class="modal-image" class="modal-image"
:src="currentMedia.url" :src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
@touchstart.stop="mediaTouchStart" @touchstart.stop="mediaTouchStart"
@touchmove.stop="mediaTouchMove" @touchmove.stop="mediaTouchMove"
@click="hide" @click="hide"
@ -18,6 +20,14 @@
:attachment="currentMedia" :attachment="currentMedia"
:controls="true" :controls="true"
/> />
<audio
v-if="type === 'audio'"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
controls
/>
<button <button
v-if="canNavigate" v-if="canNavigate"
:title="$t('media_modal.previous')" :title="$t('media_modal.previous')"

View File

@ -61,7 +61,8 @@ const mediaUpload = {
} }
}, },
props: [ props: [
'dropFiles' 'dropFiles',
'disabled'
], ],
watch: { watch: {
'dropFiles': function (fileInfos) { 'dropFiles': function (fileInfos) {

View File

@ -1,5 +1,8 @@
<template> <template>
<div class="media-upload"> <div
class="media-upload"
:class="{ disabled: disabled }"
>
<label <label
class="label" class="label"
:title="$t('tool_tip.media_upload')" :title="$t('tool_tip.media_upload')"
@ -14,6 +17,7 @@
/> />
<input <input
v-if="uploadReady" v-if="uploadReady"
:disabled="disabled"
type="file" type="file"
style="position: fixed; top: -100em" style="position: fixed; top: -100em"
multiple="true" multiple="true"
@ -26,6 +30,8 @@
<script src="./media_upload.js" ></script> <script src="./media_upload.js" ></script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss';
.media-upload { .media-upload {
.label { .label {
display: inline-block; display: inline-block;

View File

@ -2,6 +2,7 @@ import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue' import Notifications from '../notifications/notifications.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service' import GestureService from '../../services/gesture_service/gesture_service'
import { mapGetters } from 'vuex'
const MobileNav = { const MobileNav = {
components: { components: {
@ -30,7 +31,11 @@ const MobileNav = {
return this.unseenNotifications.length return this.unseenNotifications.length
}, },
hideSitename () { return this.$store.state.instance.hideSitename }, hideSitename () { return this.$store.state.instance.hideSitename },
sitename () { return this.$store.state.instance.name } sitename () { return this.$store.state.instance.name },
isChat () {
return this.$route.name === 'chat'
},
...mapGetters(['unreadChatCount'])
}, },
methods: { methods: {
toggleMobileSidebar () { toggleMobileSidebar () {
@ -64,7 +69,7 @@ const MobileNav = {
this.$refs.notifications.markAsSeen() this.$refs.notifications.markAsSeen()
}, },
onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) { onScroll ({ target: { scrollTop, clientHeight, scrollHeight } }) {
if (this.$store.getters.mergedConfig.autoLoad && scrollTop + clientHeight >= scrollHeight) { if (scrollTop + clientHeight >= scrollHeight) {
this.$refs.notifications.fetchOlderNotifications() this.$refs.notifications.fetchOlderNotifications()
} }
} }

View File

@ -3,6 +3,7 @@
<nav <nav
id="nav" id="nav"
class="nav-bar container" class="nav-bar container"
:class="{ 'mobile-hidden': isChat }"
> >
<div <div
class="mobile-inner-nav" class="mobile-inner-nav"
@ -15,6 +16,10 @@
@click.stop.prevent="toggleMobileSidebar()" @click.stop.prevent="toggleMobileSidebar()"
> >
<i class="button-icon icon-menu" /> <i class="button-icon icon-menu" />
<div
v-if="unreadChatCount"
class="alert-dot"
/>
</a> </a>
<router-link <router-link
v-if="!hideSitename" v-if="!hideSitename"

View File

@ -1,5 +1,10 @@
import { debounce } from 'lodash' import { debounce } from 'lodash'
const HIDDEN_FOR_PAGES = new Set([
'chats',
'chat'
])
const MobilePostStatusButton = { const MobilePostStatusButton = {
data () { data () {
return { return {
@ -27,6 +32,8 @@ const MobilePostStatusButton = {
return !!this.$store.state.users.currentUser return !!this.$store.state.users.currentUser
}, },
isHidden () { isHidden () {
if (HIDDEN_FOR_PAGES.has(this.$route.name)) { return true }
return this.autohideFloatingPostButton && (this.hidden || this.inputActive) return this.autohideFloatingPostButton && (this.hidden || this.inputActive)
}, },
autohideFloatingPostButton () { autohideFloatingPostButton () {

View File

@ -1,4 +1,5 @@
import { mapState } from 'vuex' import { timelineNames } from '../timeline_menu/timeline_menu.js'
import { mapState, mapGetters } from 'vuex'
const NavPanel = { const NavPanel = {
created () { created () {
@ -6,13 +7,25 @@ const NavPanel = {
this.$store.dispatch('startFetchingFollowRequests') this.$store.dispatch('startFetchingFollowRequests')
} }
}, },
computed: mapState({ computed: {
currentUser: state => state.users.currentUser, onTimelineRoute () {
chat: state => state.chat.channel, return !!timelineNames()[this.$route.name]
followRequestCount: state => state.api.followRequests.length, },
privateMode: state => state.instance.private, timelinesRoute () {
federating: state => state.instance.federating if (this.$store.state.interface.lastTimeline) {
}) return this.$store.state.interface.lastTimeline
}
return this.currentUser ? 'friends' : 'public-timeline'
},
...mapState({
currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length,
privateMode: state => state.instance.private,
federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}),
...mapGetters(['unreadChatCount'])
}
} }
export default NavPanel export default NavPanel

View File

@ -2,9 +2,12 @@
<div class="nav-panel"> <div class="nav-panel">
<div class="panel panel-default"> <div class="panel panel-default">
<ul> <ul>
<li v-if="currentUser"> <li v-if="currentUser || !privateMode">
<router-link :to="{ name: 'friends' }"> <router-link
<i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }} :to="{ name: timelinesRoute }"
:class="onTimelineRoute && 'router-link-active'"
>
<i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }}
</router-link> </router-link>
</li> </li>
<li v-if="currentUser"> <li v-if="currentUser">
@ -12,9 +15,15 @@
<i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }} <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
</router-link> </router-link>
</li> </li>
<li v-if="currentUser"> <li v-if="currentUser && pleromaChatMessagesAvailable">
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} <div
v-if="unreadChatCount"
class="badge badge-notification unread-chat-count"
>
{{ unreadChatCount }}
</div>
<i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
</router-link> </router-link>
</li> </li>
<li v-if="currentUser && currentUser.locked"> <li v-if="currentUser && currentUser.locked">
@ -28,16 +37,6 @@
</span> </span>
</router-link> </router-link>
</li> </li>
<li v-if="currentUser || !privateMode">
<router-link :to="{ name: 'public-timeline' }">
<i class="button-icon icon-users" /> {{ $t("nav.public_tl") }}
</router-link>
</li>
<li v-if="federating && (currentUser || !privateMode)">
<router-link :to="{ name: 'public-external-timeline' }">
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
</router-link>
</li>
<li> <li>
<router-link :to="{ name: 'about' }"> <router-link :to="{ name: 'about' }">
<i class="button-icon icon-info-circled" /> {{ $t("nav.about") }} <i class="button-icon icon-info-circled" /> {{ $t("nav.about") }}

View File

@ -1,4 +1,5 @@
import StatusContent from '../status_content/status_content.vue' import StatusContent from '../status_content/status_content.vue'
import { mapState } from 'vuex'
import Status from '../status/status.vue' import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
@ -81,7 +82,10 @@ const Notification = {
}, },
isStatusNotification () { isStatusNotification () {
return isStatusNotification(this.notification.type) return isStatusNotification(this.notification.type)
} },
...mapState({
currentUser: state => state.users.currentUser
})
} }
} }

View File

@ -0,0 +1,52 @@
// TODO Copypaste from Status, should unify it somehow
.Notification {
&.-muted {
padding: 0.25em 0.6em;
height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
& .status-username,
& .mute-thread,
& .mute-words {
word-wrap: normal;
word-break: normal;
white-space: nowrap;
}
& .status-username,
& .mute-words {
text-overflow: ellipsis;
overflow: hidden;
}
.status-username {
font-weight: normal;
flex: 0 1 auto;
margin-right: 0.2em;
font-size: smaller;
}
.mute-thread {
flex: 0 0 auto;
}
.mute-words {
flex: 1 0 5em;
margin-left: 0.2em;
&::before {
content: ' ';
}
}
.unmute {
flex: 0 0 auto;
margin-left: auto;
display: block;
}
}
}

View File

@ -7,7 +7,7 @@
<div v-else> <div v-else>
<div <div
v-if="needMute && !unmuted" v-if="needMute && !unmuted"
class="container muted" class="Notification container -muted"
> >
<small> <small>
<router-link :to="userProfileLink"> <router-link :to="userProfileLink">
@ -168,3 +168,4 @@
</template> </template>
<script src="./notification.js"></script> <script src="./notification.js"></script>
<style src="./notification.scss" lang="scss"></style>

View File

@ -1,3 +1,4 @@
import { mapGetters } from 'vuex'
import Notification from '../notification/notification.vue' import Notification from '../notification/notification.vue'
import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js' import notificationsFetcher from '../../services/notifications_fetcher/notifications_fetcher.service.js'
import { import {
@ -27,6 +28,11 @@ const Notifications = {
seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT seenToDisplayCount: DEFAULT_SEEN_TO_DISPLAY_COUNT
} }
}, },
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
notificationsFetcher.fetchAndUpdate({ store, credentials })
},
computed: { computed: {
mainClass () { mainClass () {
return this.minimalMode ? '' : 'panel panel-default' return this.minimalMode ? '' : 'panel panel-default'
@ -46,23 +52,22 @@ const Notifications = {
unseenCount () { unseenCount () {
return this.unseenNotifications.length return this.unseenNotifications.length
}, },
unseenCountTitle () {
return this.unseenCount + (this.unreadChatCount)
},
loading () { loading () {
return this.$store.state.statuses.notifications.loading return this.$store.state.statuses.notifications.loading
}, },
notificationsToDisplay () { notificationsToDisplay () {
return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount) return this.filteredNotifications.slice(0, this.unseenCount + this.seenToDisplayCount)
} },
...mapGetters(['unreadChatCount'])
}, },
components: { components: {
Notification Notification
}, },
created () {
const { dispatch } = this.$store
dispatch('fetchAndUpdateNotifications')
},
watch: { watch: {
unseenCount (count) { unseenCountTitle (count) {
if (count > 0) { if (count > 0) {
this.$store.dispatch('setPageTitle', `(${count})`) this.$store.dispatch('setPageTitle', `(${count})`)
} else { } else {

View File

@ -39,7 +39,7 @@
word-wrap: break-word; word-wrap: break-word;
word-break: break-word; word-break: break-word;
&:hover .animated.avatar { &:hover .animated.Avatar {
canvas { canvas {
display: none; display: none;
} }
@ -60,16 +60,8 @@
height: 32px; height: 32px;
} }
.status-body { --link: var(--faintLink);
color: $fallback--faint; --text: var(--faint);
color: var(--faint, $fallback--faint);
a {
color: var(--faintLink);
}
.status-content a {
color: var(--postFaintLink);
}
}
} }
.follow-request-accept { .follow-request-accept {
@ -106,7 +98,8 @@
} }
} }
.status-el { /* TODO cleanup this */
.Status {
flex: 1; flex: 1;
} }
@ -118,6 +111,11 @@
flex: 1; flex: 1;
padding-left: 0.8em; padding-left: 0.8em;
min-width: 0; min-width: 0;
.timeago {
min-width: 3em;
text-align: right;
}
} }
.emoji-reaction-emoji { .emoji-reaction-emoji {

View File

@ -47,11 +47,6 @@ const passwordReset = {
if (status === 204) { if (status === 204) {
this.success = true this.success = true
this.error = null this.error = null
} else if (status === 404 || status === 400) {
this.error = this.$t('password_reset.not_found')
this.$nextTick(() => {
this.$refs.email.focus()
})
} else if (status === 429) { } else if (status === 429) {
this.throttled = true this.throttled = true
this.error = this.$t('password_reset.too_many_requests') this.error = this.$t('password_reset.too_many_requests')

View File

@ -17,7 +17,7 @@
<span class="result-percentage"> <span class="result-percentage">
{{ percentageForOption(option.votes_count) }}% {{ percentageForOption(option.votes_count) }}%
</span> </span>
<span v-html="option.title_html"></span> <span v-html="option.title_html" />
</div> </div>
<div <div
class="result-fill" class="result-fill"
@ -96,6 +96,7 @@
align-items: center; align-items: center;
padding: 0.1em 0.25em; padding: 0.1em 0.25em;
z-index: 1; z-index: 1;
word-break: break-word;
} }
.result-percentage { .result-percentage {
width: 3.5em; width: 3.5em;

View File

@ -75,6 +75,7 @@ export default {
deleteOption (index, event) { deleteOption (index, event) {
if (this.options.length > 2) { if (this.options.length > 2) {
this.options.splice(index, 1) this.options.splice(index, 1)
this.updatePollToParent()
} }
}, },
convertExpiryToUnit (unit, amount) { convertExpiryToUnit (unit, amount) {

View File

@ -18,7 +18,9 @@ const Popover = {
// Takes a x/y object and tells how many pixels to offset from // Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis // anchor point on either axis
offset: Object, offset: Object,
// Additional styles you may want for the popover container // Replaces the classes you may want for the popover container.
// Use 'popover-default' in addition to get the default popover
// styles with your custom class.
popoverClass: String popoverClass: String
}, },
data () { data () {
@ -106,7 +108,7 @@ const Popover = {
// single translate or translate3d resulted in blurry text. // single translate or translate3d resulted in blurry text.
this.styles = { this.styles = {
opacity: 1, opacity: 1,
transform: `translateX(${Math.floor(translateX)}px) translateY(${Math.floor(translateY)}px)` transform: `translateX(${Math.round(translateX)}px) translateY(${Math.round(translateY)}px)`
} }
}, },
showPopover () { showPopover () {

View File

@ -14,7 +14,7 @@
ref="content" ref="content"
:style="styles" :style="styles"
class="popover" class="popover"
:class="popoverClass" :class="popoverClass || 'popover-default'"
> >
<slot <slot
name="content" name="content"
@ -34,6 +34,9 @@
z-index: 8; z-index: 8;
position: absolute; position: absolute;
min-width: 0; min-width: 0;
}
.popover-default {
transition: opacity 0.3s; transition: opacity 0.3s;
box-shadow: 1px 1px 4px rgba(0,0,0,.6); box-shadow: 1px 1px 4px rgba(0,0,0,.6);

View File

@ -3,11 +3,13 @@ import MediaUpload from '../media_upload/media_upload.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue' import ScopeSelector from '../scope_selector/scope_selector.vue'
import EmojiInput from '../emoji_input/emoji_input.vue' import EmojiInput from '../emoji_input/emoji_input.vue'
import PollForm from '../poll/poll_form.vue' import PollForm from '../poll/poll_form.vue'
import Attachment from '../attachment/attachment.vue'
import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { reject, map, uniqBy } from 'lodash' import { reject, map, uniqBy, debounce } from 'lodash'
import suggestor from '../emoji_input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import { mapGetters } from 'vuex' import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
const buildMentionsString = ({ user, attentions = [] }, currentUser) => { const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
@ -25,27 +27,54 @@ const buildMentionsString = ({ user, attentions = [] }, currentUser) => {
return mentions.length > 0 ? mentions.join(' ') + ' ' : '' return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
} }
// Converts a string with px to a number like '2px' -> 2
const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const PostStatusForm = { const PostStatusForm = {
props: [ props: [
'replyTo', 'replyTo',
'repliedUser', 'repliedUser',
'attentions', 'attentions',
'copyMessageScope', 'copyMessageScope',
'subject' 'subject',
'disableSubject',
'disableScopeSelector',
'disableNotice',
'disableLockWarning',
'disablePolls',
'disableSensitivityCheckbox',
'disableSubmit',
'disablePreview',
'placeholder',
'maxHeight',
'postHandler',
'preserveFocus',
'autoFocus',
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement'
], ],
components: { components: {
MediaUpload, MediaUpload,
EmojiInput, EmojiInput,
PollForm, PollForm,
ScopeSelector, ScopeSelector,
Checkbox Checkbox,
Attachment,
StatusContent
}, },
mounted () { mounted () {
this.updateIdempotencyKey()
this.resize(this.$refs.textarea) this.resize(this.$refs.textarea)
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
if (this.replyTo) { if (this.replyTo) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.replyTo || this.autoFocus) {
this.$refs.textarea.focus() this.$refs.textarea.focus()
} }
}, },
@ -68,7 +97,7 @@ const PostStatusForm = {
return { return {
dropFiles: [], dropFiles: [],
submitDisabled: false, uploadingFiles: false,
error: null, error: null,
posting: false, posting: false,
highlighted: 0, highlighted: 0,
@ -78,13 +107,18 @@ const PostStatusForm = {
nsfw: false, nsfw: false,
files: [], files: [],
poll: {}, poll: {},
mediaDescriptions: {},
visibility: scope, visibility: scope,
contentType contentType
}, },
caret: 0, caret: 0,
pollFormVisible: false, pollFormVisible: false,
showDropIcon: 'hide', showDropIcon: 'hide',
dropStopTimeout: null dropStopTimeout: null,
preview: null,
previewLoading: false,
emojiInputShown: false,
idempotencyKey: ''
} }
}, },
computed: { computed: {
@ -153,28 +187,81 @@ const PostStatusForm = {
}, },
pollsAvailable () { pollsAvailable () {
return this.$store.state.instance.pollsAvailable && return this.$store.state.instance.pollsAvailable &&
this.$store.state.instance.pollLimits.max_options >= 2 this.$store.state.instance.pollLimits.max_options >= 2 &&
this.disablePolls !== true
}, },
hideScopeNotice () { hideScopeNotice () {
return this.$store.getters.mergedConfig.hideScopeNotice return this.disableNotice || this.$store.getters.mergedConfig.hideScopeNotice
}, },
pollContentError () { pollContentError () {
return this.pollFormVisible && return this.pollFormVisible &&
this.newStatus.poll && this.newStatus.poll &&
this.newStatus.poll.error this.newStatus.poll.error
}, },
...mapGetters(['mergedConfig']) showPreview () {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
emptyStatus () {
return this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
},
uploadFileLimitReached () {
return this.newStatus.files.length >= this.fileLimit
},
...mapGetters(['mergedConfig']),
...mapState({
mobileLayout: state => state.interface.mobileLayout
})
},
watch: {
'newStatus': {
deep: true,
handler () {
this.statusChanged()
}
}
}, },
methods: { methods: {
postStatus (newStatus) { statusChanged () {
this.autoPreview()
this.updateIdempotencyKey()
},
clearStatus () {
const newStatus = this.newStatus
this.newStatus = {
status: '',
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {},
mediaDescriptions: {}
}
this.pollFormVisible = false
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
let el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
},
async postStatus (event, newStatus, opts = {}) {
if (this.posting) { return } if (this.posting) { return }
if (this.submitDisabled) { return } if (this.disableSubmit) { return }
if (this.emojiInputShown) { return }
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (this.newStatus.status === '') { if (this.emptyStatus) {
if (this.newStatus.files.length === 0) { this.error = this.$t('post_status.empty_status_error')
this.error = 'Cannot post an empty status with no files' return
return
}
} }
const poll = this.pollFormVisible ? this.newStatus.poll : {} const poll = this.pollFormVisible ? this.newStatus.poll : {}
@ -184,7 +271,16 @@ const PostStatusForm = {
} }
this.posting = true this.posting = true
statusPoster.postStatus({
try {
await this.setAllMediaDescriptions()
} catch (e) {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const postingOptions = {
status: newStatus.status, status: newStatus.status,
spoilerText: newStatus.spoilerText || null, spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility, visibility: newStatus.visibility,
@ -193,52 +289,98 @@ const PostStatusForm = {
store: this.$store, store: this.$store,
inReplyToStatusId: this.replyTo, inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll poll,
}).then((data) => { idempotencyKey: this.idempotencyKey
}
const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
postHandler(postingOptions).then((data) => {
if (!data.error) { if (!data.error) {
this.newStatus = { this.clearStatus()
status: '', this.$emit('posted', data)
spoilerText: '',
files: [],
visibility: newStatus.visibility,
contentType: newStatus.contentType,
poll: {}
}
this.pollFormVisible = false
this.$refs.mediaUpload.clearFile()
this.clearPollForm()
this.$emit('posted')
let el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
} else { } else {
this.error = data.error this.error = data.error
} }
this.posting = false this.posting = false
}) })
}, },
previewStatus () {
if (this.emptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
const newStatus = this.newStatus
this.previewLoading = true
statusPoster.postStatus({
status: newStatus.status,
spoilerText: newStatus.spoilerText || null,
visibility: newStatus.visibility,
sensitive: newStatus.nsfw,
media: [],
store: this.$store,
inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType,
poll: {},
preview: true
}).then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
if (!data.error) {
this.preview = data
} else {
this.preview = { error: data.error }
}
}).catch((error) => {
this.preview = { error }
}).finally(() => {
this.previewLoading = false
})
},
debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
autoPreview () {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview () {
this.preview = null
this.previewLoading = false
},
togglePreview () {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
addMediaFile (fileInfo) { addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo) this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
}, },
removeMediaFile (fileInfo) { removeMediaFile (fileInfo) {
let index = this.newStatus.files.indexOf(fileInfo) let index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1) this.newStatus.files.splice(index, 1)
this.$emit('resize')
}, },
uploadFailed (errString, templateArgs) { uploadFailed (errString, templateArgs) {
templateArgs = templateArgs || {} templateArgs = templateArgs || {}
this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs) this.error = this.$t('upload.error.base') + ' ' + this.$t('upload.error.' + errString, templateArgs)
}, },
disableSubmit () { startedUploadingFiles () {
this.submitDisabled = true this.uploadingFiles = true
}, },
enableSubmit () { finishedUploadingFiles () {
this.submitDisabled = false this.$emit('resize')
this.uploadingFiles = false
}, },
type (fileInfo) { type (fileInfo) {
return fileTypeService.fileType(fileInfo.mimetype) return fileTypeService.fileType(fileInfo.mimetype)
}, },
paste (e) { paste (e) {
this.autoPreview()
this.resize(e) this.resize(e)
if (e.clipboardData.files.length > 0) { if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text // prevent pasting of file as text
@ -266,7 +408,7 @@ const PostStatusForm = {
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500) this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
}, },
fileDrag (e) { fileDrag (e) {
e.dataTransfer.dropEffect = 'copy' e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) { if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
clearTimeout(this.dropStopTimeout) clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show' this.showDropIcon = 'show'
@ -284,6 +426,7 @@ const PostStatusForm = {
// Reset to default height for empty form, nothing else to do here. // Reset to default height for empty form, nothing else to do here.
if (target.value === '') { if (target.value === '') {
target.style.height = null target.style.height = null
this.$emit('resize')
this.$refs['emoji-input'].resize() this.$refs['emoji-input'].resize()
return return
} }
@ -295,7 +438,7 @@ const PostStatusForm = {
* scroll is different for `Window` and `Element`s * scroll is different for `Window` and `Element`s
*/ */
const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom'] const bottomBottomPaddingStr = window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = Number(bottomBottomPaddingStr.substring(0, bottomBottomPaddingStr.length - 2)) const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef = this.$el.closest('.sidebar-scroller') || const scrollerRef = this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') || this.$el.closest('.post-form-modal-view') ||
@ -304,10 +447,12 @@ const PostStatusForm = {
// Getting info about padding we have to account for, removing 'px' part // Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top'] const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom'] const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
const topPadding = Number(topPaddingStr.substring(0, topPaddingStr.length - 2)) const topPadding = pxStringToNumber(topPaddingStr)
const bottomPadding = Number(bottomPaddingStr.substring(0, bottomPaddingStr.length - 2)) const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding const vertPadding = topPadding + bottomPadding
const oldHeight = pxStringToNumber(target.style.height)
/* Explanation: /* Explanation:
* *
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight * https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
@ -336,8 +481,15 @@ const PostStatusForm = {
// BEGIN content size update // BEGIN content size update
target.style.height = 'auto' target.style.height = 'auto'
const newHeight = target.scrollHeight - vertPadding const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
let newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
// This is a bit of a hack to combat target.scrollHeight being different on every other input
// on some browsers for whatever reason. Don't change the height if difference is 1px or less.
if (Math.abs(newHeight - oldHeight) <= 1) {
newHeight = oldHeight
}
target.style.height = `${newHeight}px` target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update // END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset // We check where the bottom border of form-bottom element is, this uses findOffset
@ -388,6 +540,24 @@ const PostStatusForm = {
}, },
dismissScopeNotice () { dismissScopeNotice () {
this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true }) this.$store.dispatch('setOption', { name: 'hideScopeNotice', value: true })
},
setMediaDescription (id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({ store: this.$store, id, description })
},
setAllMediaDescriptions () {
const ids = this.newStatus.files.map(file => file.id)
return Promise.all(ids.map(id => this.setMediaDescription(id)))
},
handleEmojiInputShow (value) {
this.emojiInputShown = value
},
updateIdempotencyKey () {
this.idempotencyKey = Date.now().toString()
},
openProfileTab () {
this.$store.dispatch('openSettingsModalTab', 'profile')
} }
} }
} }

View File

@ -5,26 +5,30 @@
> >
<form <form
autocomplete="off" autocomplete="off"
@submit.prevent="postStatus(newStatus)" @submit.prevent
@dragover.prevent="fileDrag" @dragover.prevent="fileDrag"
> >
<div <div
v-show="showDropIcon !== 'hide'" v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }" :style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator icon-upload" class="drop-indicator"
:class="[uploadFileLimitReached ? 'icon-block' : 'icon-upload']"
@dragleave="fileDragStop" @dragleave="fileDragStop"
@drop.stop="fileDrop" @drop.stop="fileDrop"
/> />
<div class="form-group"> <div class="form-group">
<i18n <i18n
v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private'" v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
path="post_status.account_not_locked_warning" path="post_status.account_not_locked_warning"
tag="p" tag="p"
class="visibility-notice" class="visibility-notice"
> >
<router-link :to="{ name: 'user-settings' }"> <a
href="#"
@click="openProfileTab"
>
{{ $t('post_status.account_not_locked_warning_link') }} {{ $t('post_status.account_not_locked_warning_link') }}
</router-link> </a>
</i18n> </i18n>
<p <p
v-if="!hideScopeNotice && newStatus.visibility === 'public'" v-if="!hideScopeNotice && newStatus.visibility === 'public'"
@ -69,15 +73,52 @@
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span> <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span> <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p> </p>
<div
v-if="!disablePreview"
class="preview-heading faint"
>
<a
class="preview-toggle faint"
@click.stop.prevent="togglePreview"
>
{{ $t('post_status.preview') }}
<i :class="showPreview ? 'icon-left-open' : 'icon-right-open'" />
</a>
<i
v-show="previewLoading"
class="icon-spin3 animate-spin"
/>
</div>
<div
v-if="showPreview"
class="preview-container"
>
<div
v-if="!preview"
class="preview-status"
>
{{ $t('general.loading') }}
</div>
<div
v-else-if="preview.error"
class="preview-status preview-error"
>
{{ preview.error }}
</div>
<StatusContent
v-else
:status="preview"
class="preview-status"
/>
</div>
<EmojiInput <EmojiInput
v-if="newStatus.spoilerText || alwaysShowSubject" v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
enable-emoji-picker enable-emoji-picker
:suggest="emojiSuggestor" :suggest="emojiSuggestor"
class="form-control" class="form-control"
> >
<input <input
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
@ -89,23 +130,29 @@
ref="emoji-input" ref="emoji-input"
v-model="newStatus.status" v-model="newStatus.status"
:suggest="emojiUserSuggestor" :suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="form-control main-input" class="form-control main-input"
enable-emoji-picker enable-emoji-picker
hide-emoji-button hide-emoji-button
:newline-on-ctrl-enter="submitOnEnter"
enable-sticker-picker enable-sticker-picker
@input="onEmojiInputInput" @input="onEmojiInputInput"
@sticker-uploaded="addMediaFile" @sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed" @sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
> >
<textarea <textarea
ref="textarea" ref="textarea"
v-model="newStatus.status" v-model="newStatus.status"
:placeholder="$t('post_status.default')" :placeholder="placeholder || $t('post_status.default')"
rows="1" rows="1"
cols="1"
:disabled="posting" :disabled="posting"
class="form-post-body" class="form-post-body"
@keydown.meta.enter="postStatus(newStatus)" :class="{ 'scrollable-form': !!maxHeight }"
@keydown.ctrl.enter="postStatus(newStatus)" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
@keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize" @input="resize"
@compositionupdate="resize" @compositionupdate="resize"
@paste="paste" @paste="paste"
@ -118,7 +165,10 @@
{{ charactersLeft }} {{ charactersLeft }}
</p> </p>
</EmojiInput> </EmojiInput>
<div class="visibility-tray"> <div
v-if="!disableScopeSelector"
class="visibility-tray"
>
<scope-selector <scope-selector
:show-all="showAllScopes" :show-all="showAllScopes"
:user-default="userDefaultScope" :user-default="userDefaultScope"
@ -176,10 +226,11 @@
ref="mediaUpload" ref="mediaUpload"
class="media-upload-icon" class="media-upload-icon"
:drop-files="dropFiles" :drop-files="dropFiles"
@uploading="disableSubmit" :disabled="uploadFileLimitReached"
@uploading="startedUploadingFiles"
@uploaded="addMediaFile" @uploaded="addMediaFile"
@upload-failed="uploadFailed" @upload-failed="uploadFailed"
@all-uploaded="enableSubmit" @all-uploaded="finishedUploadingFiles"
/> />
<div <div
class="emoji-icon" class="emoji-icon"
@ -216,11 +267,13 @@
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
<!-- touchstart is used to keep the OSK at the same position after a message send -->
<button <button
v-else v-else
:disabled="submitDisabled" :disabled="uploadingFiles || disableSubmit"
type="submit"
class="btn btn-default" class="btn btn-default"
@touchstart.stop.prevent="postStatus($event, newStatus)"
@click.stop.prevent="postStatus($event, newStatus)"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
@ -245,31 +298,22 @@
class="fa button-icon icon-cancel" class="fa button-icon icon-cancel"
@click="removeMediaFile(file)" @click="removeMediaFile(file)"
/> />
<div class="media-upload-container attachment"> <attachment
<img :attachment="file"
v-if="type(file) === 'image'" :set-media="() => $store.dispatch('setMedia', newStatus.files)"
class="thumbnail media-upload" size="small"
:src="file.url" allow-play="false"
> />
<video <input
v-if="type(file) === 'video'" v-model="newStatus.mediaDescriptions[file.id]"
:src="file.url" type="text"
controls :placeholder="$t('post_status.media_description')"
/> @keydown.enter.prevent=""
<audio >
v-if="type(file) === 'audio'"
:src="file.url"
controls
/>
<a
v-if="type(file) === 'unknown'"
:href="file.url"
>{{ file.url }}</a>
</div>
</div> </div>
</div> </div>
<div <div
v-if="newStatus.files.length > 0" v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings" class="upload_settings"
> >
<Checkbox v-model="newStatus.nsfw"> <Checkbox v-model="newStatus.nsfw">
@ -303,14 +347,8 @@
} }
.post-status-form { .post-status-form {
.visibility-tray { position: relative;
display: flex;
justify-content: space-between;
padding-top: 5px;
}
}
.post-status-form {
.form-bottom { .form-bottom {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -336,6 +374,51 @@
max-width: 10em; max-width: 10em;
} }
.preview-heading {
padding-left: 0.5em;
display: flex;
width: 100%;
.icon-spin3 {
margin-left: auto;
}
}
.preview-toggle {
display: flex;
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
i {
margin-left: 0.2em;
font-size: 0.8em;
transform: rotate(90deg);
}
}
.preview-container {
margin-bottom: 1em;
}
.preview-error {
font-style: italic;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
}
.preview-status {
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
padding: 0.5em;
margin: 0;
line-height: 1.4em;
}
.text-format { .text-format {
.only-format { .only-format {
color: $fallback--faint; color: $fallback--faint;
@ -343,6 +426,12 @@
} }
} }
.visibility-tray {
display: flex;
justify-content: space-between;
padding-top: 5px;
}
.media-upload-icon, .poll-icon, .emoji-icon { .media-upload-icon, .poll-icon, .emoji-icon {
font-size: 26px; font-size: 26px;
flex: 1; flex: 1;
@ -354,6 +443,19 @@
color: var(--lightText, $fallback--lightText); color: var(--lightText, $fallback--lightText);
} }
} }
&.disabled {
i {
cursor: not-allowed;
color: $fallback--icon;
color: var(--btnDisabledText, $fallback--icon);
&:hover {
color: $fallback--icon;
color: var(--btnDisabledText, $fallback--icon);
}
}
}
} }
// Order is not necessary but a good indicator // Order is not necessary but a good indicator
@ -381,11 +483,9 @@
} }
.media-upload-wrapper { .media-upload-wrapper {
flex: 0 0 auto;
max-width: 100%;
min-width: 50px;
margin-right: .2em; margin-right: .2em;
margin-bottom: .5em; margin-bottom: .5em;
width: 18em;
.icon-cancel { .icon-cancel {
display: inline-block; display: inline-block;
@ -399,6 +499,20 @@
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
} }
img, video {
object-fit: contain;
max-height: 10em;
}
.video {
max-height: 10em;
}
input {
flex: 1;
width: 100%;
}
} }
.status-input-wrapper { .status-input-wrapper {
@ -408,28 +522,13 @@
flex-direction: column; flex-direction: column;
} }
.attachments { .media-upload-wrapper .attachments {
padding: 0 0.5em; padding: 0 0.5em;
.attachment { .attachment {
margin: 0; margin: 0;
padding: 0;
position: relative; position: relative;
flex: 0 0 auto;
border: 1px solid $fallback--border;
border: 1px solid var(--border, $fallback--border);
text-align: center;
audio {
min-width: 300px;
flex: 1 0 auto;
}
a {
display: block;
text-align: left;
line-height: 1.2;
padding: .5em;
}
} }
i { i {
@ -482,6 +581,10 @@
padding-bottom: 1.75em; padding-bottom: 1.75em;
min-height: 1px; min-height: 1px;
box-sizing: content-box; box-sizing: content-box;
&.scrollable-form {
overflow-y: auto;
}
} }
.main-input { .main-input {
@ -544,4 +647,11 @@
border: 2px dashed var(--text, $fallback--text); border: 2px dashed var(--text, $fallback--text);
} }
} }
// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
img.media-upload, .media-upload-container > video {
line-height: 0;
max-height: 200px;
max-width: 100%;
}
</style> </style>

View File

@ -28,7 +28,10 @@ const ReactButton = {
}, },
emojis () { emojis () {
if (this.filterWord !== '') { if (this.filterWord !== '') {
return this.$store.state.instance.emoji.filter(emoji => emoji.displayText.includes(this.filterWord)) const filterWordLowercase = this.filterWord.toLowerCase()
return this.$store.state.instance.emoji.filter(emoji =>
emoji.displayText.toLowerCase().includes(filterWordLowercase)
)
} }
return this.$store.state.instance.emoji || [] return this.$store.state.instance.emoji || []
}, },

View File

@ -13,6 +13,13 @@
* - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible * - 50px - leaving tiny amount of space so that titlebar + tiny amount of modal is visible
*/ */
transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px)); transform: translateY(calc(((100vh - 100%) / 2 + 100%) - 50px));
@media all and (max-width: 800px) {
/* For mobile, the modal takes 100% of the available screen.
This ensures the minimized modal is always 50px above the browser bottom bar regardless of whether or not it is visible.
*/
transform: translateY(calc(100% - 50px));
}
} }
} }
@ -27,10 +34,10 @@
@media all and (max-width: 800px) { @media all and (max-width: 800px) {
max-width: 100vw; max-width: 100vw;
height: 100vh; height: 100%;
} }
.panel-body { >.panel-body {
height: 100%; height: 100%;
overflow-y: hidden; overflow-y: hidden;

View File

@ -27,6 +27,34 @@ const SettingsModalContent = {
computed: { computed: {
isLoggedIn () { isLoggedIn () {
return !!this.$store.state.users.currentUser return !!this.$store.state.users.currentUser
},
open () {
return this.$store.state.interface.settingsModalState !== 'hidden'
}
},
methods: {
onOpen () {
const targetTab = this.$store.state.interface.settingsModalTargetTab
// We're being told to open in specific tab
if (targetTab) {
const tabIndex = this.$refs.tabSwitcher.$slots.default.findIndex(elm => {
return elm.data && elm.data.attrs['data-tab-name'] === targetTab
})
if (tabIndex >= 0) {
this.$refs.tabSwitcher.setTab(tabIndex)
}
}
// Clear the state of target tab, so that next time settings is opened
// it doesn't force it.
this.$store.dispatch('clearSettingsModalTargetTab')
}
},
mounted () {
this.onOpen()
},
watch: {
open: function (value) {
if (value) this.onOpen()
} }
} }
} }

View File

@ -8,6 +8,7 @@
<div <div
:label="$t('settings.general')" :label="$t('settings.general')"
icon="wrench" icon="wrench"
data-tab-name="general"
> >
<GeneralTab /> <GeneralTab />
</div> </div>
@ -15,6 +16,7 @@
v-if="isLoggedIn" v-if="isLoggedIn"
:label="$t('settings.profile_tab')" :label="$t('settings.profile_tab')"
icon="user" icon="user"
data-tab-name="profile"
> >
<ProfileTab /> <ProfileTab />
</div> </div>
@ -22,18 +24,21 @@
v-if="isLoggedIn" v-if="isLoggedIn"
:label="$t('settings.security_tab')" :label="$t('settings.security_tab')"
icon="lock" icon="lock"
data-tab-name="security"
> >
<SecurityTab /> <SecurityTab />
</div> </div>
<div <div
:label="$t('settings.filtering')" :label="$t('settings.filtering')"
icon="filter" icon="filter"
data-tab-name="filtering"
> >
<FilteringTab /> <FilteringTab />
</div> </div>
<div <div
:label="$t('settings.theme')" :label="$t('settings.theme')"
icon="brush" icon="brush"
data-tab-name="theme"
> >
<ThemeTab /> <ThemeTab />
</div> </div>
@ -41,6 +46,7 @@
v-if="isLoggedIn" v-if="isLoggedIn"
:label="$t('settings.notifications')" :label="$t('settings.notifications')"
icon="bell-ringing-o" icon="bell-ringing-o"
data-tab-name="notifications"
> >
<NotificationsTab /> <NotificationsTab />
</div> </div>
@ -48,6 +54,7 @@
v-if="isLoggedIn" v-if="isLoggedIn"
:label="$t('settings.data_import_export_tab')" :label="$t('settings.data_import_export_tab')"
icon="download" icon="download"
data-tab-name="dataImportExport"
> >
<DataImportExportTab /> <DataImportExportTab />
</div> </div>
@ -56,12 +63,14 @@
:label="$t('settings.mutes_and_blocks')" :label="$t('settings.mutes_and_blocks')"
:fullHeight="true" :fullHeight="true"
icon="eye-off" icon="eye-off"
data-tab-name="mutesAndBlocks"
> >
<MutesAndBlocksTab /> <MutesAndBlocksTab />
</div> </div>
<div <div
:label="$t('settings.version.title')" :label="$t('settings.version.title')"
icon="info-circled" icon="info-circled"
data-tab-name="version"
> >
<VersionTab /> <VersionTab />
</div> </div>

View File

@ -37,6 +37,9 @@ const FilteringTab = {
}) })
}, },
deep: true deep: true
},
replyVisibility () {
this.$store.dispatch('queueFlushAll')
} }
} }
} }

View File

@ -53,16 +53,6 @@
</small> </small>
</Checkbox> </Checkbox>
</li> </li>
<li>
<Checkbox v-model="autoLoad">
{{ $t('settings.autoload') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="hoverPreview">
{{ $t('settings.reply_link_preview') }}
</Checkbox>
</li>
<li> <li>
<Checkbox v-model="emojiReactionsOnTimeline"> <Checkbox v-model="emojiReactionsOnTimeline">
{{ $t('settings.emoji_reactions_on_timeline') }} {{ $t('settings.emoji_reactions_on_timeline') }}

View File

@ -2,38 +2,18 @@
<div :label="$t('settings.notifications')"> <div :label="$t('settings.notifications')">
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.notification_setting_filters') }}</h2> <h2>{{ $t('settings.notification_setting_filters') }}</h2>
<div class="select-multiple"> <p>
<span class="label">{{ $t('settings.notification_setting') }}</span> <Checkbox v-model="notificationSettings.block_from_strangers">
<ul class="option-list"> {{ $t('settings.notification_setting_block_from_strangers') }}
<li> </Checkbox>
<Checkbox v-model="notificationSettings.follows"> </p>
{{ $t('settings.notification_setting_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.followers">
{{ $t('settings.notification_setting_followers') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_follows">
{{ $t('settings.notification_setting_non_follows') }}
</Checkbox>
</li>
<li>
<Checkbox v-model="notificationSettings.non_followers">
{{ $t('settings.notification_setting_non_followers') }}
</Checkbox>
</li>
</ul>
</div>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.notification_setting_privacy') }}</h2> <h2>{{ $t('settings.notification_setting_privacy') }}</h2>
<p> <p>
<Checkbox v-model="notificationSettings.privacy_option"> <Checkbox v-model="notificationSettings.hide_notification_contents">
{{ $t('settings.notification_setting_privacy_option') }} {{ $t('settings.notification_setting_hide_notification_contents') }}
</Checkbox> </Checkbox>
</p> </p>
</div> </div>

View File

@ -77,6 +77,33 @@ const ProfileTab = {
}, },
maxFields () { maxFields () {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0 return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
defaultAvatar () {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
},
defaultBanner () {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
},
isDefaultAvatar () {
const baseAvatar = this.$store.state.instance.defaultAvatar
return !(this.$store.state.users.currentUser.profile_image_url) ||
this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
},
isDefaultBanner () {
const baseBanner = this.$store.state.instance.defaultBanner
return !(this.$store.state.users.currentUser.cover_photo) ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
},
isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image)
},
avatarImgSrc () {
const src = this.$store.state.users.currentUser.profile_image_url_original
return (!src) ? this.defaultAvatar : src
},
bannerImgSrc () {
const src = this.$store.state.users.currentUser.cover_photo
return (!src) ? this.defaultBanner : src
} }
}, },
methods: { methods: {
@ -150,11 +177,29 @@ const ProfileTab = {
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
}, },
resetAvatar () {
const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm'))
if (confirmed) {
this.submitAvatar(undefined, '')
}
},
resetBanner () {
const confirmed = window.confirm(this.$t('settings.reset_banner_confirm'))
if (confirmed) {
this.submitBanner('')
}
},
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
if (confirmed) {
this.submitBackground('')
}
},
submitAvatar (cropper, file) { submitAvatar (cropper, file) {
const that = this const that = this
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
function updateAvatar (avatar) { function updateAvatar (avatar) {
that.$store.state.api.backendInteractor.updateAvatar({ avatar }) that.$store.state.api.backendInteractor.updateProfileImages({ avatar })
.then((user) => { .then((user) => {
that.$store.commit('addNewUsers', [user]) that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user) that.$store.commit('setCurrentUser', user)
@ -172,11 +217,11 @@ const ProfileTab = {
} }
}) })
}, },
submitBanner () { submitBanner (banner) {
if (!this.bannerPreview) { return } if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true this.bannerUploading = true
this.$store.state.api.backendInteractor.updateBanner({ banner: this.banner }) this.$store.state.api.backendInteractor.updateProfileImages({ banner })
.then((user) => { .then((user) => {
this.$store.commit('addNewUsers', [user]) this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user) this.$store.commit('setCurrentUser', user)
@ -187,11 +232,11 @@ const ProfileTab = {
}) })
.then(() => { this.bannerUploading = false }) .then(() => { this.bannerUploading = false })
}, },
submitBg () { submitBackground (background) {
if (!this.backgroundPreview) { return } if (!this.backgroundPreview && background !== '') { return }
let background = this.background
this.backgroundUploading = true this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => { this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => {
if (!data.error) { if (!data.error) {
this.$store.commit('addNewUsers', [data]) this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data) this.$store.commit('setCurrentUser', data)

View File

@ -13,8 +13,14 @@
height: auto; height: auto;
} }
.banner { .banner-background-preview {
max-width: 100%; max-width: 100%;
width: 300px;
position: relative;
img {
width: 100%;
}
} }
.uploading { .uploading {
@ -26,18 +32,40 @@
width: 100%; width: 100%;
} }
.bg { .current-avatar-container {
max-width: 100%; position: relative;
width: 150px;
height: 150px;
} }
.current-avatar { .current-avatar {
display: block; display: block;
width: 150px; width: 100%;
height: 150px; height: 100%;
border-radius: $fallback--avatarRadius; border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius); border-radius: var(--avatarRadius, $fallback--avatarRadius);
} }
.reset-button {
position: absolute;
top: 0.2em;
right: 0.2em;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
background-color: rgba(0, 0, 0, 0.6);
opacity: 0.7;
color: white;
width: 1.5em;
height: 1.5em;
text-align: center;
line-height: 1.5em;
font-size: 1.5em;
cursor: pointer;
&:hover {
opacity: 1;
}
}
.oauth-tokens { .oauth-tokens {
width: 100%; width: 100%;
@ -86,6 +114,7 @@
&>.emoji-input { &>.emoji-input {
flex: 1 1 auto; flex: 1 1 auto;
margin: 0 .2em .5em; margin: 0 .2em .5em;
min-width: 0;
} }
&>.icon-container { &>.icon-container {

View File

@ -161,11 +161,19 @@
<p class="visibility-notice"> <p class="visibility-notice">
{{ $t('settings.avatar_size_instruction') }} {{ $t('settings.avatar_size_instruction') }}
</p> </p>
<p>{{ $t('settings.current_avatar') }}</p> <div class="current-avatar-container">
<img <img
:src="user.profile_image_url_original" :src="user.profile_image_url_original"
class="current-avatar" class="current-avatar"
> >
<i
v-if="!isDefaultAvatar && pickAvatarBtnVisible"
:title="$t('settings.reset_avatar')"
class="reset-button icon-cancel"
type="button"
@click="resetAvatar"
/>
</div>
<p>{{ $t('settings.set_new_avatar') }}</p> <p>{{ $t('settings.set_new_avatar') }}</p>
<button <button
v-show="pickAvatarBtnVisible" v-show="pickAvatarBtnVisible"
@ -184,15 +192,20 @@
</div> </div>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.profile_banner') }}</h2> <h2>{{ $t('settings.profile_banner') }}</h2>
<p>{{ $t('settings.current_profile_banner') }}</p> <div class="banner-background-preview">
<img <img :src="user.cover_photo">
:src="user.cover_photo" <i
class="banner" v-if="!isDefaultBanner"
> :title="$t('settings.reset_profile_banner')"
class="reset-button icon-cancel"
type="button"
@click="resetBanner"
/>
</div>
<p>{{ $t('settings.set_new_profile_banner') }}</p> <p>{{ $t('settings.set_new_profile_banner') }}</p>
<img <img
v-if="bannerPreview" v-if="bannerPreview"
class="banner" class="banner-background-preview"
:src="bannerPreview" :src="bannerPreview"
> >
<div> <div>
@ -208,7 +221,7 @@
<button <button
v-else-if="bannerPreview" v-else-if="bannerPreview"
class="btn btn-default" class="btn btn-default"
@click="submitBanner" @click="submitBanner(banner)"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>
@ -225,10 +238,20 @@
</div> </div>
<div class="setting-item"> <div class="setting-item">
<h2>{{ $t('settings.profile_background') }}</h2> <h2>{{ $t('settings.profile_background') }}</h2>
<div class="banner-background-preview">
<img :src="user.background_image">
<i
v-if="!isDefaultBackground"
:title="$t('settings.reset_profile_background')"
class="reset-button icon-cancel"
type="button"
@click="resetBackground"
/>
</div>
<p>{{ $t('settings.set_new_profile_background') }}</p> <p>{{ $t('settings.set_new_profile_background') }}</p>
<img <img
v-if="backgroundPreview" v-if="backgroundPreview"
class="bg" class="banner-background-preview"
:src="backgroundPreview" :src="backgroundPreview"
> >
<div> <div>
@ -244,7 +267,7 @@
<button <button
v-else-if="backgroundPreview" v-else-if="backgroundPreview"
class="btn btn-default" class="btn btn-default"
@click="submitBg" @click="submitBackground(background)"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>

View File

@ -99,7 +99,8 @@ export default {
avatarRadiusLocal: '', avatarRadiusLocal: '',
avatarAltRadiusLocal: '', avatarAltRadiusLocal: '',
attachmentRadiusLocal: '', attachmentRadiusLocal: '',
tooltipRadiusLocal: '' tooltipRadiusLocal: '',
chatMessageRadiusLocal: ''
} }
}, },
created () { created () {
@ -214,7 +215,8 @@ export default {
avatar: this.avatarRadiusLocal, avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal, avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal, tooltip: this.tooltipRadiusLocal,
attachment: this.attachmentRadiusLocal attachment: this.attachmentRadiusLocal,
chatMessage: this.chatMessageRadiusLocal
} }
}, },
preview () { preview () {

View File

@ -735,6 +735,65 @@
/> />
<ContrastRatio :contrast="previewContrast.selectedMenuLink" /> <ContrastRatio :contrast="previewContrast.selectedMenuLink" />
</div> </div>
<div class="color-item">
<h4>{{ $t('chats.chats') }}</h4>
<ColorInput
v-model="chatBgColorLocal"
name="chatBgColor"
:fallback="previewTheme.colors.bg || 1"
:label="$t('settings.background')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
<ColorInput
v-model="chatMessageIncomingBgColorLocal"
name="chatMessageIncomingBgColor"
:fallback="previewTheme.colors.bg || 1"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageIncomingTextColorLocal"
name="chatMessageIncomingTextColor"
:fallback="previewTheme.colors.text || 1"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageIncomingLinkColorLocal"
name="chatMessageIncomingLinkColor"
:fallback="previewTheme.colors.link || 1"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageIncomingBorderColorLocal"
name="chatMessageIncomingBorderLinkColor"
:fallback="previewTheme.colors.fg || 1"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
<ColorInput
v-model="chatMessageOutgoingBgColorLocal"
name="chatMessageOutgoingBgColor"
:fallback="previewTheme.colors.bg || 1"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageOutgoingTextColorLocal"
name="chatMessageOutgoingTextColor"
:fallback="previewTheme.colors.text || 1"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageOutgoingLinkColorLocal"
name="chatMessageOutgoingLinkColor"
:fallback="previewTheme.colors.link || 1"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageOutgoingBorderColorLocal"
name="chatMessageOutgoingBorderLinkColor"
:fallback="previewTheme.colors.bg || 1"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
</div>
</div> </div>
<div <div
@ -814,6 +873,14 @@
max="50" max="50"
hard-min="0" hard-min="0"
/> />
<RangeInput
v-model="chatMessageRadiusLocal"
name="chatMessageRadius"
:label="$t('settings.chatMessageRadius')"
:fallback="previewTheme.radii.chatMessage || 2"
max="50"
hard-min="0"
/>
</div> </div>
<div <div

View File

@ -1,3 +1,4 @@
import { mapState, mapGetters } from 'vuex'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service' import GestureService from '../../services/gesture_service/gesture_service'
@ -47,7 +48,17 @@ const SideDrawer = {
}, },
federating () { federating () {
return this.$store.state.instance.federating return this.$store.state.instance.federating
} },
timelinesRoute () {
if (this.$store.state.interface.lastTimeline) {
return this.$store.state.interface.lastTimeline
}
return this.currentUser ? 'friends' : 'public-timeline'
},
...mapState({
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}),
...mapGetters(['unreadChatCount'])
}, },
methods: { methods: {
toggleDrawer () { toggleDrawer () {

View File

@ -40,33 +40,39 @@
</router-link> </router-link>
</li> </li>
<li <li
v-if="currentUser" v-if="currentUser || !privateMode"
@click="toggleDrawer" @click="toggleDrawer"
> >
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: timelinesRoute }">
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} <i class="button-icon icon-home-2" /> {{ $t("nav.timelines") }}
</router-link> </router-link>
</li> </li>
<li <li
v-if="currentUser" v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer" @click="toggleDrawer"
> >
<router-link
:to="{ name: 'chats', params: { username: currentUser.screen_name } }"
style="position: relative"
>
<i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
<span
v-if="unreadChatCount"
class="badge badge-notification unread-chat-count"
>
{{ unreadChatCount }}
</span>
</router-link>
</li>
</ul>
<ul v-if="currentUser">
<li @click="toggleDrawer">
<router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'interactions', params: { username: currentUser.screen_name } }">
<i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }} <i class="button-icon icon-bell-alt" /> {{ $t("nav.interactions") }}
</router-link> </router-link>
</li> </li>
</ul>
<ul>
<li <li
v-if="currentUser" v-if="currentUser.locked"
@click="toggleDrawer"
>
<router-link :to="{ name: 'friends' }">
<i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
</router-link>
</li>
<li
v-if="currentUser && currentUser.locked"
@click="toggleDrawer" @click="toggleDrawer"
> >
<router-link to="/friend-requests"> <router-link to="/friend-requests">
@ -80,23 +86,7 @@
</router-link> </router-link>
</li> </li>
<li <li
v-if="currentUser || !privateMode" v-if="chat"
@click="toggleDrawer"
>
<router-link to="/main/public">
<i class="button-icon icon-users" /> {{ $t("nav.public_tl") }}
</router-link>
</li>
<li
v-if="federating && (currentUser || !privateMode)"
@click="toggleDrawer"
>
<router-link to="/main/all">
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
</router-link>
</li>
<li
v-if="currentUser && chat"
@click="toggleDrawer" @click="toggleDrawer"
> >
<router-link :to="{ name: 'chat' }"> <router-link :to="{ name: 'chat' }">

View File

@ -2,6 +2,10 @@ import map from 'lodash/map'
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const StaffPanel = { const StaffPanel = {
created () {
const nicknames = this.$store.state.instance.staffAccounts
nicknames.forEach(nickname => this.$store.dispatch('fetchUserIfMissing', nickname))
},
components: { components: {
BasicUserCard BasicUserCard
}, },

View File

@ -9,6 +9,7 @@ import AvatarList from '../avatar_list/avatar_list.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import StatusContent from '../status_content/status_content.vue' import StatusContent from '../status_content/status_content.vue'
import StatusPopover from '../status_popover/status_popover.vue' import StatusPopover from '../status_popover/status_popover.vue'
import UserListPopover from '../user_list_popover/user_list_popover.vue'
import EmojiReactions from '../emoji_reactions/emoji_reactions.vue' import EmojiReactions from '../emoji_reactions/emoji_reactions.vue'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
@ -18,6 +19,21 @@ import { mapGetters, mapState } from 'vuex'
const Status = { const Status = {
name: 'Status', name: 'Status',
components: {
FavoriteButton,
ReactButton,
RetweetButton,
ExtraButtons,
PostStatusForm,
UserCard,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent
},
props: [ props: [
'statusoid', 'statusoid',
'expandable', 'expandable',
@ -141,7 +157,7 @@ const Status = {
return this.mergedConfig.hideFilteredStatuses return this.mergedConfig.hideFilteredStatuses
}, },
hideStatus () { hideStatus () {
return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses) return this.deleted || (this.muted && this.hideFilteredStatuses)
}, },
isFocused () { isFocused () {
// retweet or root of an expanded conversation // retweet or root of an expanded conversation
@ -164,37 +180,6 @@ const Status = {
return user && user.screen_name return user && user.screen_name
} }
}, },
hideReply () {
if (this.mergedConfig.replyVisibility === 'all') {
return false
}
if (this.inConversation || !this.isReply) {
return false
}
if (this.status.user.id === this.currentUser.id) {
return false
}
if (this.status.type === 'retweet') {
return false
}
const checkFollowing = this.mergedConfig.replyVisibility === 'following'
for (var i = 0; i < this.status.attentions.length; ++i) {
if (this.status.user.id === this.status.attentions[i].id) {
continue
}
// There's zero guarantee of this working. If we happen to have that user and their
// relationship in store then it will work, but there's kinda little chance of having
// them for people you're not following.
const relationship = this.$store.state.users.relationships[this.status.attentions[i].id]
if (checkFollowing && relationship && relationship.following) {
return false
}
if (this.status.attentions[i].id === this.currentUser.id) {
return false
}
}
return this.status.attentions.length > 0
},
replySubject () { replySubject () {
if (!this.status.summary) return '' if (!this.status.summary) return ''
const decodedSummary = unescape(this.status.summary) const decodedSummary = unescape(this.status.summary)
@ -228,20 +213,6 @@ const Status = {
currentUser: state => state.users.currentUser currentUser: state => state.users.currentUser
}) })
}, },
components: {
FavoriteButton,
ReactButton,
RetweetButton,
ExtraButtons,
PostStatusForm,
UserCard,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
EmojiReactions,
StatusContent
},
methods: { methods: {
visibilityIcon (visibility) { visibilityIcon (visibility) {
switch (visibility) { switch (visibility) {

View File

@ -0,0 +1,414 @@
@import '../../_variables.scss';
$status-margin: 0.75em;
.Status {
min-width: 0;
&:hover {
--still-image-img: visible;
--still-image-canvas: hidden;
}
&.-focused {
background-color: $fallback--lightBg;
background-color: var(--selectedPost, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedPostText, $fallback--text);
--lightText: var(--selectedPostLightText, $fallback--light);
--faint: var(--selectedPostFaintText, $fallback--faint);
--faintLink: var(--selectedPostFaintLink, $fallback--faint);
--postLink: var(--selectedPostPostLink, $fallback--faint);
--postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
--icon: var(--selectedPostIcon, $fallback--icon);
}
.status-container {
display: flex;
padding: $status-margin;
&.-repeat {
padding-top: 0;
}
}
.pin {
padding: $status-margin $status-margin 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.left-side {
margin-right: $status-margin;
}
.right-side {
flex: 1;
min-width: 0;
}
.usercard {
margin-bottom: $status-margin;
}
.status-username {
white-space: nowrap;
font-size: 14px;
overflow: hidden;
max-width: 85%;
font-weight: bold;
flex-shrink: 1;
margin-right: 0.4em;
text-overflow: ellipsis;
.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain;
}
}
.status-favicon {
height: 18px;
width: 18px;
margin-right: 0.4em;
}
.status-heading {
margin-bottom: 0.5em;
}
.heading-name-row {
display: flex;
justify-content: space-between;
line-height: 18px;
a {
display: inline-block;
word-break: break-all;
}
}
.account-name {
min-width: 1.6em;
margin-right: 0.4em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 0;
}
.heading-left {
display: flex;
min-width: 0;
}
.heading-right {
display: flex;
flex-shrink: 0;
}
.timeago {
margin-right: 0.2em;
}
.heading-reply-row {
position: relative;
align-content: baseline;
font-size: 12px;
line-height: 18px;
max-width: 100%;
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.reply-to-and-accountname {
display: flex;
height: 18px;
margin-right: 0.5em;
max-width: 100%;
.reply-to-link {
white-space: nowrap;
word-break: break-word;
text-overflow: ellipsis;
overflow-x: hidden;
}
.icon-reply {
// mirror the icon
transform: scaleX(-1);
}
}
& .reply-to-popover,
& .reply-to-no-popover {
min-width: 0;
margin-right: 0.4em;
flex-shrink: 0;
}
.reply-to-popover {
.reply-to:hover::before {
content: '';
display: block;
position: absolute;
bottom: 0;
width: 100%;
border-bottom: 1px solid var(--faint);
pointer-events: none;
}
.faint-link:hover {
// override default
text-decoration: none;
}
&.-strikethrough {
.reply-to::after {
content: '';
display: block;
position: absolute;
top: 50%;
width: 100%;
border-bottom: 1px solid var(--faint);
pointer-events: none;
}
}
}
.reply-to {
display: flex;
position: relative;
}
.reply-to-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 0.2em;
}
.replies-separator {
margin-left: 0.4em;
}
.replies {
line-height: 18px;
font-size: 12px;
display: flex;
flex-wrap: wrap;
& > * {
margin-right: 0.4em;
}
}
.reply-link {
height: 17px;
}
.repeat-info {
padding: 0.4em $status-margin;
line-height: 22px;
.right-side {
display: flex;
align-content: center;
flex-wrap: wrap;
}
i {
padding: 0 0.2em;
}
}
.repeater-avatar {
border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
margin-left: 28px;
width: 20px;
height: 20px;
}
.repeater-name {
text-overflow: ellipsis;
margin-right: 0;
.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain;
}
}
.status-fadein {
animation-duration: 0.4s;
animation-name: fadein;
}
@keyframes fadein {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.status-actions {
position: relative;
width: 100%;
display: flex;
margin-top: $status-margin;
> * {
max-width: 4em;
flex: 1;
}
}
.button-reply {
&:not(.-disabled) {
cursor: pointer;
}
&:not(.-disabled):hover,
&.-active {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
}
.muted {
padding: 0.25em 0.6em;
height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
& .status-username,
& .mute-thread,
& .mute-words {
word-wrap: normal;
word-break: normal;
white-space: nowrap;
}
& .status-username,
& .mute-words {
text-overflow: ellipsis;
overflow: hidden;
}
.status-username {
font-weight: normal;
flex: 0 1 auto;
margin-right: 0.2em;
font-size: smaller;
}
.mute-thread {
flex: 0 0 auto;
}
.mute-words {
flex: 1 0 5em;
margin-left: 0.2em;
&::before {
content: ' ';
}
}
.unmute {
flex: 0 0 auto;
margin-left: auto;
display: block;
}
}
.reply-form {
padding-top: 0;
padding-bottom: 0;
}
.reply-body {
flex: 1;
}
.favs-repeated-users {
margin-top: $status-margin;
}
.stats {
width: 100%;
display: flex;
line-height: 1em;
}
.avatar-row {
flex: 1;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
&::before {
content: '';
position: absolute;
height: 100%;
width: 1px;
left: 0;
background-color: var(--faint, $fallback--faint);
}
}
.stat-count {
margin-right: $status-margin;
user-select: none;
.stat-title {
color: var(--faint, $fallback--faint);
font-size: 12px;
text-transform: uppercase;
position: relative;
}
.stat-number {
font-weight: bolder;
font-size: 16px;
line-height: 1em;
}
&:hover .stat-title {
text-decoration: underline;
}
}
@media all and (max-width: 800px) {
.repeater-avatar {
margin-left: 20px;
}
.avatar:not(.repeater-avatar) {
width: 40px;
height: 40px;
// TODO define those other way somehow?
// stylelint-disable rscss/class-format
&.avatar-compact {
width: 32px;
height: 32px;
}
}
}
}

View File

@ -2,8 +2,8 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<div <div
v-if="!hideStatus" v-if="!hideStatus"
class="status-el" class="Status"
:class="[{ 'status-el_focused': isFocused }, { 'status-conversation': inlineExpanded }]" :class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
> >
<div <div
v-if="error" v-if="error"
@ -16,8 +16,8 @@
/> />
</div> </div>
<template v-if="muted && !isPreview"> <template v-if="muted && !isPreview">
<div class="media status container muted"> <div class="status-csontainer muted">
<small class="username"> <small class="status-username">
<i <i
v-if="muted && retweet" v-if="muted && retweet"
class="button-icon icon-retweet" class="button-icon icon-retweet"
@ -54,7 +54,7 @@
<template v-else> <template v-else>
<div <div
v-if="showPinned" v-if="showPinned"
class="status-pin" class="pin"
> >
<i class="fa icon-pin faint" /> <i class="fa icon-pin faint" />
<span class="faint">{{ $t('status.pinned') }}</span> <span class="faint">{{ $t('status.pinned') }}</span>
@ -63,16 +63,19 @@
v-if="retweet && !noHeading && !inConversation" v-if="retweet && !noHeading && !inConversation"
:class="[repeaterClass, { highlighted: repeaterStyle }]" :class="[repeaterClass, { highlighted: repeaterStyle }]"
:style="[repeaterStyle]" :style="[repeaterStyle]"
class="media container retweet-info" class="status-container repeat-info"
> >
<UserAvatar <UserAvatar
v-if="retweet" v-if="retweet"
class="media-left" class="left-side repeater-avatar"
:better-shadow="betterShadow" :better-shadow="betterShadow"
:user="statusoid.user" :user="statusoid.user"
/> />
<div class="media-body faint"> <div class="right-side faint">
<span class="user-name"> <span
class="status-username repeater-name"
:title="retweeter"
>
<router-link <router-link
v-if="retweeterHtml" v-if="retweeterHtml"
:to="retweeterProfileLink" :to="retweeterProfileLink"
@ -92,14 +95,14 @@
</div> </div>
<div <div
:class="[userClass, { highlighted: userStyle, 'is-retweet': retweet && !inConversation }]" :class="[userClass, { highlighted: userStyle, '-repeat': retweet && !inConversation }]"
:style="[ userStyle ]" :style="[ userStyle ]"
class="media status" class="status-container"
:data-tags="tags" :data-tags="tags"
> >
<div <div
v-if="!noHeading" v-if="!noHeading"
class="media-left" class="left-side"
> >
<router-link <router-link
:to="userProfileLink" :to="userProfileLink"
@ -112,37 +115,45 @@
/> />
</router-link> </router-link>
</div> </div>
<div class="status-body"> <div class="right-side">
<UserCard <UserCard
v-if="userExpanded" v-if="userExpanded"
:user-id="status.user.id" :user-id="status.user.id"
:rounded="true" :rounded="true"
:bordered="true" :bordered="true"
class="status-usercard" class="usercard"
/> />
<div <div
v-if="!noHeading" v-if="!noHeading"
class="media-heading" class="status-heading"
> >
<div class="heading-name-row"> <div class="heading-name-row">
<div class="name-and-account-name"> <div class="heading-left">
<h4 <h4
v-if="status.user.name_html" v-if="status.user.name_html"
class="user-name" class="status-username"
:title="status.user.name"
v-html="status.user.name_html" v-html="status.user.name_html"
/> />
<h4 <h4
v-else v-else
class="user-name" class="status-username"
:title="status.user.name"
> >
{{ status.user.name }} {{ status.user.name }}
</h4> </h4>
<router-link <router-link
class="account-name" class="account-name"
:title="status.user.screen_name"
:to="userProfileLink" :to="userProfileLink"
> >
{{ status.user.screen_name }} {{ status.user.screen_name }}
</router-link> </router-link>
<img
v-if="!!(status.user && status.user.favicon)"
class="status-favicon"
:src="status.user.favicon"
>
</div> </div>
<span class="heading-right"> <span class="heading-right">
@ -197,9 +208,10 @@
> >
<StatusPopover <StatusPopover
v-if="!isPreview" v-if="!isPreview"
:status-id="status.in_reply_to_status_id" :status-id="status.parent_visible && status.in_reply_to_status_id"
class="reply-to-popover" class="reply-to-popover"
style="min-width: 0" style="min-width: 0"
:class="{ '-strikethrough': !status.parent_visible }"
> >
<a <a
class="reply-to" class="reply-to"
@ -207,17 +219,25 @@
:aria-label="$t('tool_tip.reply')" :aria-label="$t('tool_tip.reply')"
@click.prevent="gotoOriginal(status.in_reply_to_status_id)" @click.prevent="gotoOriginal(status.in_reply_to_status_id)"
> >
<i class="button-icon icon-reply" /> <i class="button-icon reply-button icon-reply" />
<span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span> <span
class="faint-link reply-to-text"
>
{{ $t('status.reply_to') }}
</span>
</a> </a>
</StatusPopover> </StatusPopover>
<span <span
v-else v-else
class="reply-to" class="reply-to-no-popover"
> >
<span class="reply-to-text">{{ $t('status.reply_to') }}</span> <span class="reply-to-text">{{ $t('status.reply_to') }}</span>
</span> </span>
<router-link :to="replyProfileLink"> <router-link
class="reply-to-link"
:title="replyToName"
:to="replyProfileLink"
>
{{ replyToName }} {{ replyToName }}
</router-link> </router-link>
<span <span
@ -260,24 +280,30 @@
class="favs-repeated-users" class="favs-repeated-users"
> >
<div class="stats"> <div class="stats">
<div <UserListPopover
v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0" v-if="statusFromGlobalRepository.rebloggedBy && statusFromGlobalRepository.rebloggedBy.length > 0"
class="stat-count" :users="statusFromGlobalRepository.rebloggedBy"
> >
<a class="stat-title">{{ $t('status.repeats') }}</a> <div class="stat-count">
<div class="stat-number"> <a class="stat-title">{{ $t('status.repeats') }}</a>
{{ statusFromGlobalRepository.rebloggedBy.length }} <div class="stat-number">
{{ statusFromGlobalRepository.rebloggedBy.length }}
</div>
</div> </div>
</div> </UserListPopover>
<div <UserListPopover
v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0" v-if="statusFromGlobalRepository.favoritedBy && statusFromGlobalRepository.favoritedBy.length > 0"
class="stat-count" :users="statusFromGlobalRepository.favoritedBy"
> >
<a class="stat-title">{{ $t('status.favorites') }}</a> <div
<div class="stat-number"> class="stat-count"
{{ statusFromGlobalRepository.favoritedBy.length }} >
<a class="stat-title">{{ $t('status.favorites') }}</a>
<div class="stat-number">
{{ statusFromGlobalRepository.favoritedBy.length }}
</div>
</div> </div>
</div> </UserListPopover>
<div class="avatar-row"> <div class="avatar-row">
<AvatarList :users="combinedFavsAndRepeatsUsers" /> <AvatarList :users="combinedFavsAndRepeatsUsers" />
</div> </div>
@ -292,19 +318,19 @@
<div <div
v-if="!noHeading && !isPreview" v-if="!noHeading && !isPreview"
class="status-actions media-body" class="status-actions"
> >
<div> <div>
<i <i
v-if="loggedIn" v-if="loggedIn"
class="button-icon icon-reply" class="button-icon button-reply icon-reply"
:title="$t('tool_tip.reply')" :title="$t('tool_tip.reply')"
:class="{'button-icon-active': replying}" :class="{'-active': replying}"
@click.prevent="toggleReplying" @click.prevent="toggleReplying"
/> />
<i <i
v-else v-else
class="button-icon button-icon-disabled icon-reply" class="button-icon button-reply -disabled icon-reply"
:title="$t('tool_tip.reply')" :title="$t('tool_tip.reply')"
/> />
<span v-if="status.replies_count > 0">{{ status.replies_count }}</span> <span v-if="status.replies_count > 0">{{ status.replies_count }}</span>
@ -332,7 +358,7 @@
</div> </div>
<div <div
v-if="replying" v-if="replying"
class="container" class="status-container reply-form"
> >
<PostStatusForm <PostStatusForm
class="reply-body" class="reply-body"
@ -350,427 +376,4 @@
</template> </template>
<script src="./status.js" ></script> <script src="./status.js" ></script>
<style lang="scss"> <style src="./status.scss" lang="scss"></style>
@import '../../_variables.scss';
$status-margin: 0.75em;
.status-body {
flex: 1;
min-width: 0;
}
.status-pin {
padding: $status-margin $status-margin 0;
display: flex;
align-items: center;
justify-content: flex-end;
}
.media-left {
margin-right: $status-margin;
}
.status-el {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px;
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(--selectedPost, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedPostText, $fallback--text);
--lightText: var(--selectedPostLightText, $fallback--light);
--faint: var(--selectedPostFaintText, $fallback--faint);
--faintLink: var(--selectedPostFaintLink, $fallback--faint);
--postLink: var(--selectedPostPostLink, $fallback--faint);
--postFaintLink: var(--selectedPostFaintPostLink, $fallback--faint);
--icon: var(--selectedPostIcon, $fallback--icon);
}
.timeline & {
border-bottom-width: 1px;
border-bottom-style: solid;
}
.media-body {
flex: 1;
padding: 0;
}
.status-usercard {
margin-bottom: $status-margin;
}
.user-name {
white-space: nowrap;
font-size: 14px;
overflow: hidden;
flex-shrink: 0;
max-width: 85%;
font-weight: bold;
img.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain
}
}
.media-heading {
padding: 0;
vertical-align: bottom;
flex-basis: 100%;
margin-bottom: 0.5em;
small {
font-weight: lighter;
}
.heading-name-row {
padding: 0;
display: flex;
justify-content: space-between;
line-height: 18px;
a {
display: inline-block;
word-break: break-all;
}
.name-and-account-name {
display: flex;
min-width: 0;
}
.user-name {
flex-shrink: 1;
margin-right: 0.4em;
overflow: hidden;
text-overflow: ellipsis;
}
.account-name {
min-width: 1.6em;
margin-right: 0.4em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 0;
}
}
.heading-right {
display: flex;
flex-shrink: 0;
}
.timeago {
margin-right: 0.2em;
}
.heading-reply-row {
position: relative;
align-content: baseline;
font-size: 12px;
line-height: 18px;
max-width: 100%;
display: flex;
flex-wrap: wrap;
align-items: stretch;
> .reply-to-and-accountname > a {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
word-break: break-all;
}
}
.reply-to-and-accountname {
display: flex;
height: 18px;
margin-right: 0.5em;
max-width: 100%;
.icon-reply {
transform: scaleX(-1);
}
}
.reply-info {
display: flex;
}
.reply-to-popover {
min-width: 0;
}
.reply-to {
display: flex;
}
.reply-to-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 0.4em 0 0.2em;
}
.replies-separator {
margin-left: 0.4em;
}
.replies {
line-height: 18px;
font-size: 12px;
display: flex;
flex-wrap: wrap;
& > * {
margin-right: 0.4em;
}
}
.reply-link {
height: 17px;
}
}
.retweet-info {
padding: 0.4em $status-margin;
margin: 0;
.avatar.still-image {
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;
overflow: hidden;
text-overflow: ellipsis;
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;
}
}
.status-conversation {
border-left-style: solid;
}
.status-actions {
position: relative;
width: 100%;
display: flex;
margin-top: $status-margin;
> * {
max-width: 4em;
flex: 1;
}
}
.button-icon.icon-reply {
&:not(.button-icon-disabled):hover,
&.button-icon-active {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
}
.button-icon.icon-reply {
&:not(.button-icon-disabled) {
cursor: pointer;
}
}
.status:hover .animated.avatar {
canvas {
display: none;
}
img {
visibility: visible;
}
}
.status {
display: flex;
padding: $status-margin;
&.is-retweet {
padding-top: 0;
}
}
.status-conversation:last-child {
border-bottom: none;
}
.muted {
padding: .25em .6em;
height: 1.2em;
line-height: 1.2em;
text-overflow: ellipsis;
overflow: hidden;
display: flex;
flex-wrap: nowrap;
.username, .mute-thread, .mute-words {
word-wrap: normal;
word-break: normal;
white-space: nowrap;
}
.username, .mute-words {
text-overflow: ellipsis;
overflow: hidden;
}
.username {
flex: 0 1 auto;
margin-right: .2em;
}
.mute-thread {
flex: 0 0 auto;
}
.mute-words {
flex: 1 0 5em;
margin-left: .2em;
&::before {
content: ' '
}
}
.unmute {
flex: 0 0 auto;
margin-left: auto;
display: block;
margin-left: auto;
}
}
.reply-body {
flex: 1;
}
.favs-repeated-users {
margin-top: $status-margin;
.stats {
width: 100%;
display: flex;
line-height: 1em;
.stat-count {
margin-right: $status-margin;
.stat-title {
color: var(--faint, $fallback--faint);
font-size: 12px;
text-transform: uppercase;
position: relative;
}
.stat-number {
font-weight: bolder;
font-size: 16px;
line-height: 1em;
}
}
.avatar-row {
flex: 1;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
&::before {
content: '';
position: absolute;
height: 100%;
width: 1px;
left: 0;
background-color: var(--faint, $fallback--faint);
}
}
}
}
@media all and (max-width: 800px) {
.status-el {
.retweet-info {
.avatar.still-image {
margin-left: 20px;
}
}
}
.status {
max-width: 100%;
}
.status .avatar.still-image {
width: 40px;
height: 40px;
&.avatar-compact {
width: 32px;
height: 32px;
}
}
}
</style>

View File

@ -14,11 +14,12 @@ const StatusContent = {
'status', 'status',
'focused', 'focused',
'noHeading', 'noHeading',
'fullContent' 'fullContent',
'singleLine'
], ],
data () { data () {
return { return {
showingTall: this.inConversation && this.focused, showingTall: this.fullContent || (this.inConversation && this.focused),
showingLongSubject: false, showingLongSubject: false,
// not as computed because it sets the initial state which will be changed later // not as computed because it sets the initial state which will be changed later
expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
@ -44,14 +45,14 @@ const StatusContent = {
return lengthScore > 20 return lengthScore > 20
}, },
longSubject () { longSubject () {
return this.status.summary.length > 900 return this.status.summary.length > 240
}, },
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status. // When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject () { mightHideBecauseSubject () {
return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault) return !!this.status.summary && this.localCollapseSubjectDefault
}, },
mightHideBecauseTall () { mightHideBecauseTall () {
return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault) return this.tallStatus && !(this.status.summary && this.localCollapseSubjectDefault)
}, },
hideSubjectStatus () { hideSubjectStatus () {
return this.mightHideBecauseSubject && !this.expandingSubject return this.mightHideBecauseSubject && !this.expandingSubject
@ -99,15 +100,8 @@ const StatusContent = {
file => !fileType.fileMatchesSomeType(this.galleryTypes, file) file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
) )
}, },
hasImageAttachments () { attachmentTypes () {
return this.status.attachments.some( return this.status.attachments.map(file => fileType.fileType(file.mimetype))
file => fileType.fileType(file.mimetype) === 'image'
)
},
hasVideoAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'video'
)
}, },
maxThumbnails () { maxThumbnails () {
return this.mergedConfig.maxThumbnails return this.mergedConfig.maxThumbnails
@ -142,12 +136,6 @@ const StatusContent = {
return html return html
} }
}, },
contentHtml () {
if (!this.status.summary_html) {
return this.postBodyHtml
}
return this.status.summary_html + '<br />' + this.postBodyHtml
},
...mapGetters(['mergedConfig']), ...mapGetters(['mergedConfig']),
...mapState({ ...mapState({
betterShadow: state => state.interface.browserSupport.cssFilter, betterShadow: state => state.interface.browserSupport.cssFilter,

View File

@ -1,47 +1,34 @@
<template> <template>
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<div class="status-body"> <div class="StatusContent">
<slot name="header" /> <slot name="header" />
<div <div
v-if="longSubject" v-if="status.summary_html"
class="status-content-wrapper" class="summary-wrapper"
:class="{ 'tall-status': !showingLongSubject }" :class="{ 'tall-subject': (longSubject && !showingLongSubject) }"
> >
<div
class="media-body summary"
@click.prevent="linkClicked"
v-html="status.summary_html"
/>
<a <a
v-if="!showingLongSubject" v-if="longSubject && showingLongSubject"
class="tall-status-hider" href="#"
:class="{ 'tall-status-hider_focused': focused }" class="tall-subject-hider"
@click.prevent="showingLongSubject=false"
>{{ $t("status.hide_full_subject") }}</a>
<a
v-else-if="longSubject"
class="tall-subject-hider"
:class="{ 'tall-subject-hider_focused': focused }"
href="#" href="#"
@click.prevent="showingLongSubject=true" @click.prevent="showingLongSubject=true"
> >
{{ $t("general.show_more") }} {{ $t("status.show_full_subject") }}
<span
v-if="hasImageAttachments"
class="icon-picture"
/>
<span
v-if="hasVideoAttachments"
class="icon-video"
/>
<span
v-if="status.card"
class="icon-link"
/>
</a> </a>
<div
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<a
v-if="showingLongSubject"
href="#"
class="status-unhider"
@click.prevent="showingLongSubject=false"
>{{ $t("general.show_less") }}</a>
</div> </div>
<div <div
v-else
:class="{'tall-status': hideTallStatus}" :class="{'tall-status': hideTallStatus}"
class="status-content-wrapper" class="status-content-wrapper"
> >
@ -51,34 +38,59 @@
:class="{ 'tall-status-hider_focused': focused }" :class="{ 'tall-status-hider_focused': focused }"
href="#" href="#"
@click.prevent="toggleShowMore" @click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a> >
{{ $t("general.show_more") }}
</a>
<div <div
v-if="!hideSubjectStatus" v-if="!hideSubjectStatus"
:class="{ 'single-line': singleLine }"
class="status-content media-body" class="status-content media-body"
@click.prevent="linkClicked" @click.prevent="linkClicked"
v-html="contentHtml" v-html="postBodyHtml"
/>
<div
v-else
class="status-content media-body"
@click.prevent="linkClicked"
v-html="status.summary_html"
/> />
<a <a
v-if="hideSubjectStatus" v-if="hideSubjectStatus"
href="#" href="#"
class="cw-status-hider" class="cw-status-hider"
@click.prevent="toggleShowMore" @click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a> >
{{ $t("status.show_content") }}
<span
v-if="attachmentTypes.includes('image')"
class="icon-picture"
/>
<span
v-if="attachmentTypes.includes('video')"
class="icon-video"
/>
<span
v-if="attachmentTypes.includes('audio')"
class="icon-music"
/>
<span
v-if="attachmentTypes.includes('unknown')"
class="icon-doc"
/>
<span
v-if="status.poll && status.poll.options"
class="icon-chart-bar"
/>
<span
v-if="status.card"
class="icon-link"
/>
</a>
<a <a
v-if="showingMore" v-if="showingMore && !fullContent"
href="#" href="#"
class="status-unhider" class="status-unhider"
@click.prevent="toggleShowMore" @click.prevent="toggleShowMore"
>{{ $t("general.show_less") }}</a> >
{{ tallStatus ? $t("general.show_less") : $t("status.hide_content") }}
</a>
</div> </div>
<div v-if="status.poll && status.poll.options"> <div v-if="status.poll && status.poll.options && !hideSubjectStatus">
<poll :base-poll="status.poll" /> <poll :base-poll="status.poll" />
</div> </div>
@ -125,10 +137,16 @@
$status-margin: 0.75em; $status-margin: 0.75em;
.status-body { .StatusContent {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
.status-content-wrapper {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
.tall-status { .tall-status {
position: relative; position: relative;
height: 220px; height: 220px;
@ -136,7 +154,7 @@ $status-margin: 0.75em;
overflow-y: hidden; overflow-y: hidden;
z-index: 1; z-index: 1;
.status-content { .status-content {
height: 100%; min-height: 0;
mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat, mask: linear-gradient(to top, white, transparent) bottom/100% 70px no-repeat,
linear-gradient(to top, white, white); linear-gradient(to top, white, white);
/* Autoprefixed seem to ignore this one, and also syntax is different */ /* Autoprefixed seem to ignore this one, and also syntax is different */
@ -176,10 +194,45 @@ $status-margin: 0.75em;
} }
} }
.summary-wrapper {
margin-bottom: 0.5em;
border-style: solid;
border-width: 0 0 1px 0;
border-color: var(--border, $fallback--border);
flex-grow: 0;
}
.summary {
font-style: italic;
padding-bottom: 0.5em;
}
.tall-subject {
position: relative;
.summary {
max-height: 2em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.tall-subject-hider {
display: inline-block;
word-break: break-all;
// position: absolute;
width: 100%;
text-align: center;
padding-bottom: 0.5em;
}
.status-content { .status-content {
font-family: var(--postFont, sans-serif); font-family: var(--postFont, sans-serif);
line-height: 1.4em; line-height: 1.4em;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
blockquote { blockquote {
margin: 0.2em 0 0.2em 2em; margin: 0.2em 0 0.2em 2em;
@ -221,6 +274,13 @@ $status-margin: 0.75em;
h4 { h4 {
margin: 1.1em 0; margin: 1.1em 0;
} }
&.single-line {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.4em;
}
} }
} }
@ -228,13 +288,4 @@ $status-margin: 0.75em;
color: $fallback--cGreen; color: $fallback--cGreen;
color: var(--postGreentext, $fallback--cGreen); color: var(--postGreentext, $fallback--cGreen);
} }
.timeline :not(.panel-disabled) > {
.status-el:last-child {
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
border-bottom: none;
}
}
</style> </style>

View File

@ -22,6 +22,10 @@ const StatusPopover = {
methods: { methods: {
enter () { enter () {
if (!this.status) { if (!this.status) {
if (!this.statusId) {
this.error = true
return
}
this.$store.dispatch('fetchStatus', this.statusId) this.$store.dispatch('fetchStatus', this.statusId)
.then(data => (this.error = false)) .then(data => (this.error = false))
.catch(e => (this.error = true)) .catch(e => (this.error = true))

View File

@ -1,7 +1,7 @@
<template> <template>
<Popover <Popover
trigger="hover" trigger="hover"
popover-class="status-popover" popover-class="popover-default status-popover"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
@show="enter" @show="enter"
> >
@ -38,7 +38,8 @@
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
.status-popover { /* popover styles load on-demand, so we need to override */
.status-popover.popover {
font-size: 1rem; font-size: 1rem;
min-width: 15em; min-width: 15em;
max-width: 95%; max-width: 95%;
@ -52,7 +53,8 @@
box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5); box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
box-shadow: var(--popupShadow); box-shadow: var(--popupShadow);
.status-el.status-el { /* TODO cleanup this */
.Status.Status {
border: none; border: none;
} }

View File

@ -4,7 +4,8 @@ const StillImage = {
'referrerpolicy', 'referrerpolicy',
'mimetype', 'mimetype',
'imageLoadError', 'imageLoadError',
'imageLoadHandler' 'imageLoadHandler',
'alt'
], ],
data () { data () {
return { return {

View File

@ -11,6 +11,8 @@
<img <img
ref="src" ref="src"
:key="src" :key="src"
:alt="alt"
:title="alt"
:src="src" :src="src"
:referrerpolicy="referrerpolicy" :referrerpolicy="referrerpolicy"
@load="onLoad" @load="onLoad"
@ -28,48 +30,9 @@
position: relative; position: relative;
line-height: 0; line-height: 0;
overflow: hidden; overflow: hidden;
width: 100%;
height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
&:hover canvas {
display: none;
}
img {
width: 100%;
min-height: 100%;
object-fit: contain;
}
&.animated {
&:hover::before,
img {
visibility: hidden;
}
&:hover img {
visibility: visible
}
&::before {
content: 'gif';
position: absolute;
line-height: 10px;
font-size: 10px;
top: 5px;
left: 5px;
background: rgba(127,127,127,.5);
color: #FFF;
display: block;
padding: 2px 4px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
z-index: 2;
}
}
canvas { canvas {
position: absolute; position: absolute;
top: 0; top: 0;
@ -79,6 +42,45 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
visibility: var(--still-image-canvas, visible);
}
img {
width: 100%;
min-height: 100%;
object-fit: contain;
}
&.animated {
&::before {
content: 'gif';
position: absolute;
line-height: 10px;
font-size: 10px;
top: 5px;
left: 5px;
background: rgba(127, 127, 127, 0.5);
color: #fff;
display: block;
padding: 2px 4px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
z-index: 2;
visibility: var(--still-image-label-visibility, visible);
}
&:hover canvas {
display: none;
}
&:hover::before,
img {
visibility: var(--still-image-img, hidden);
}
&:hover img {
visibility: visible;
}
} }
} }
</style> </style>

View File

@ -1,4 +1,5 @@
import Vue from 'vue' import Vue from 'vue'
import { mapState } from 'vuex'
import './tab_switcher.scss' import './tab_switcher.scss'
@ -44,7 +45,13 @@ export default Vue.component('tab-switcher', {
} else { } else {
return this.active return this.active
} }
} },
settingsModalVisible () {
return this.settingsModalState === 'visible'
},
...mapState({
settingsModalState: state => state.interface.settingsModalState
})
}, },
beforeUpdate () { beforeUpdate () {
const currentSlot = this.$slots.default[this.active] const currentSlot = this.$slots.default[this.active]
@ -53,16 +60,19 @@ export default Vue.component('tab-switcher', {
} }
}, },
methods: { methods: {
activateTab (index) { clickTab (index) {
return (e) => { return (e) => {
e.preventDefault() e.preventDefault()
if (typeof this.onSwitch === 'function') { this.setTab(index)
this.onSwitch.call(null, this.$slots.default[index].key) }
} },
this.active = index setTab (index) {
if (this.scrollableTabs) { if (typeof this.onSwitch === 'function') {
this.$refs.contents.scrollTop = 0 this.onSwitch.call(null, this.$slots.default[index].key)
} }
this.active = index
if (this.scrollableTabs) {
this.$refs.contents.scrollTop = 0
} }
} }
}, },
@ -81,7 +91,7 @@ export default Vue.component('tab-switcher', {
<div class={classesWrapper.join(' ')}> <div class={classesWrapper.join(' ')}>
<button <button
disabled={slot.data.attrs.disabled} disabled={slot.data.attrs.disabled}
onClick={this.activateTab(index)} onClick={this.clickTab(index)}
class={classesTab.join(' ')}> class={classesTab.join(' ')}>
<img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/> <img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/>
{slot.data.attrs.label ? '' : slot.data.attrs.label} {slot.data.attrs.label ? '' : slot.data.attrs.label}
@ -93,7 +103,7 @@ export default Vue.component('tab-switcher', {
<div class={classesWrapper.join(' ')}> <div class={classesWrapper.join(' ')}>
<button <button
disabled={slot.data.attrs.disabled} disabled={slot.data.attrs.disabled}
onClick={this.activateTab(index)} onClick={this.clickTab(index)}
class={classesTab.join(' ')} class={classesTab.join(' ')}
type="button" type="button"
> >
@ -134,7 +144,7 @@ export default Vue.component('tab-switcher', {
<div class="tabs"> <div class="tabs">
{tabs} {tabs}
</div> </div>
<div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}> <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')} v-body-scroll-lock={this.settingsModalVisible}>
{contents} {contents}
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import Status from '../status/status.vue' import Status from '../status/status.vue'
import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js' import timelineFetcher from '../../services/timeline_fetcher/timeline_fetcher.service.js'
import Conversation from '../conversation/conversation.vue' import Conversation from '../conversation/conversation.vue'
import TimelineMenu from '../timeline_menu/timeline_menu.vue'
import { throttle, keyBy } from 'lodash' import { throttle, keyBy } from 'lodash'
export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => { export const getExcludedStatusIdsByPinning = (statuses, pinnedStatusIds) => {
@ -35,6 +36,11 @@ const Timeline = {
bottomedOut: false bottomedOut: false
} }
}, },
components: {
Status,
Conversation,
TimelineMenu
},
computed: { computed: {
timelineError () { timelineError () {
return this.$store.state.statuses.error return this.$store.state.statuses.error
@ -45,11 +51,15 @@ const Timeline = {
newStatusCount () { newStatusCount () {
return this.timeline.newStatusCount return this.timeline.newStatusCount
}, },
newStatusCountStr () { showLoadButton () {
if (this.timelineError || this.errorData) return false
return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
},
loadButtonString () {
if (this.timeline.flushMarker !== 0) { if (this.timeline.flushMarker !== 0) {
return '' return this.$t('timeline.reload')
} else { } else {
return ` (${this.newStatusCount})` return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
} }
}, },
classes () { classes () {
@ -70,10 +80,6 @@ const Timeline = {
return keyBy(this.pinnedStatusIds) return keyBy(this.pinnedStatusIds)
} }
}, },
components: {
Status,
Conversation
},
created () { created () {
const store = this.$store const store = this.$store
const credentials = store.state.users.currentUser.credentials const credentials = store.state.users.currentUser.credentials
@ -112,8 +118,6 @@ const Timeline = {
if (e.key === '.') this.showNewStatuses() if (e.key === '.') this.showNewStatuses()
}, },
showNewStatuses () { showNewStatuses () {
if (this.newStatusCount === 0) return
if (this.timeline.flushMarker !== 0) { if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true }) this.$store.commit('clearTimeline', { timeline: this.timelineName, excludeUserId: true })
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 }) this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
@ -135,7 +139,7 @@ const Timeline = {
showImmediately: true, showImmediately: true,
userId: this.userId, userId: this.userId,
tag: this.tag tag: this.tag
}).then(statuses => { }).then(({ statuses }) => {
store.commit('setLoading', { timeline: this.timelineName, value: false }) store.commit('setLoading', { timeline: this.timelineName, value: false })
if (statuses && statuses.length === 0) { if (statuses && statuses.length === 0) {
this.bottomedOut = true this.bottomedOut = true
@ -146,7 +150,6 @@ const Timeline = {
const bodyBRect = document.body.getBoundingClientRect() const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -(bodyBRect.y)) const height = Math.max(bodyBRect.height, -(bodyBRect.y))
if (this.timeline.loading === false && if (this.timeline.loading === false &&
this.$store.getters.mergedConfig.autoLoad &&
this.$el.offsetHeight > 0 && this.$el.offsetHeight > 0 &&
(window.innerHeight + window.pageYOffset) >= (height - 750)) { (window.innerHeight + window.pageYOffset) >= (height - 750)) {
this.fetchOlderStatuses() this.fetchOlderStatuses()

View File

@ -1,9 +1,7 @@
<template> <template>
<div :class="classes.root"> <div :class="[classes.root, 'timeline']">
<div :class="classes.header"> <div :class="classes.header">
<div class="title"> <TimelineMenu v-if="!embedded" />
{{ title }}
</div>
<div <div
v-if="timelineError" v-if="timelineError"
class="loadmore-error alert error" class="loadmore-error alert error"
@ -19,14 +17,14 @@
{{ errorData.statusText }} {{ errorData.statusText }}
</div> </div>
<button <button
v-if="timeline.newStatusCount > 0 && !timelineError && !errorData" v-else-if="showLoadButton"
class="loadmore-button" class="loadmore-button"
@click.prevent="showNewStatuses" @click.prevent="showNewStatuses"
> >
{{ $t('timeline.show_new') }}{{ newStatusCountStr }} {{ loadButtonString }}
</button> </button>
<div <div
v-if="!timeline.newStatusCount > 0 && !timelineError && !errorData" v-else
class="loadmore-text faint" class="loadmore-text faint"
@click.prevent @click.prevent
> >
@ -106,4 +104,16 @@
opacity: 1; opacity: 1;
} }
} }
.timeline-heading {
max-width: 100%;
flex-wrap: nowrap;
.loadmore-button {
flex-shrink: 0;
}
.loadmore-text {
flex-shrink: 0;
line-height: 1em;
}
}
</style> </style>

View File

@ -0,0 +1,63 @@
import Popover from '../popover/popover.vue'
import { mapState } from 'vuex'
// Route -> i18n key mapping, exported andnot in the computed
// because nav panel benefits from the same information.
export const timelineNames = () => {
return {
'friends': 'nav.timeline',
'bookmarks': 'nav.bookmarks',
'dms': 'nav.dms',
'public-timeline': 'nav.public_tl',
'public-external-timeline': 'nav.twkn',
'tag-timeline': 'tag'
}
}
const TimelineMenu = {
components: {
Popover
},
data () {
return {
isOpen: false
}
},
created () {
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
if (timelineNames()[this.$route.name]) {
this.$store.dispatch('setLastTimeline', this.$route.name)
}
},
methods: {
openMenu () {
// $nextTick is too fast, animation won't play back but
// instead starts in fully open position. Low values
// like 1-5 work on fast machines but not on mobile, 25
// seems like a good compromise that plays without significant
// added lag.
setTimeout(() => {
this.isOpen = true
}, 25)
},
timelineName () {
const route = this.$route.name
if (route === 'tag-timeline') {
return '#' + this.$route.params.tag
}
const i18nkey = timelineNames()[this.$route.name]
return i18nkey ? this.$t(i18nkey) : route
}
},
computed: {
...mapState({
currentUser: state => state.users.currentUser,
privateMode: state => state.instance.private,
federating: state => state.instance.federating
})
}
}
export default TimelineMenu

View File

@ -0,0 +1,180 @@
<template>
<Popover
trigger="click"
class="timeline-menu"
:class="{ 'open': isOpen }"
:margin="{ left: -15, right: -200 }"
:bound-to="{ x: 'container' }"
popover-class="timeline-menu-popover-wrap"
@show="openMenu"
@close="() => isOpen = false"
>
<div
slot="content"
class="timeline-menu-popover panel panel-default"
>
<ul>
<li v-if="currentUser">
<router-link :to="{ name: 'friends' }">
<i class="button-icon icon-home-2" />{{ $t("nav.timeline") }}
</router-link>
</li>
<li v-if="currentUser">
<router-link :to="{ name: 'bookmarks'}">
<i class="button-icon icon-bookmark" />{{ $t("nav.bookmarks") }}
</router-link>
</li>
<li v-if="currentUser">
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
<i class="button-icon icon-mail-alt" />{{ $t("nav.dms") }}
</router-link>
</li>
<li v-if="currentUser || !privateMode">
<router-link :to="{ name: 'public-timeline' }">
<i class="button-icon icon-users" />{{ $t("nav.public_tl") }}
</router-link>
</li>
<li v-if="federating && (currentUser || !privateMode)">
<router-link :to="{ name: 'public-external-timeline' }">
<i class="button-icon icon-globe" />{{ $t("nav.twkn") }}
</router-link>
</li>
</ul>
</div>
<div
slot="trigger"
class="title timeline-menu-title"
>
<span>{{ timelineName() }}</span>
<i class="icon-down-open" />
</div>
</Popover>
</template>
<script src="./timeline_menu.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
.timeline-menu {
flex-shrink: 1;
margin-right: auto;
min-width: 0;
width: 24rem;
.timeline-menu-popover-wrap {
overflow: hidden;
// Match panel heading padding to line up menu with bottom of heading
margin-top: 0.6rem;
padding: 0 15px 15px 15px;
}
.timeline-menu-popover {
width: 24rem;
max-width: 100vw;
margin: 0;
font-size: 1rem;
border-top-right-radius: 0;
border-top-left-radius: 0;
transform: translateY(-100%);
transition: transform 100ms;
}
.panel::after {
border-top-right-radius: 0;
border-top-left-radius: 0;
}
&.open .timeline-menu-popover {
transform: translateY(0);
}
.timeline-menu-title {
margin: 0;
cursor: pointer;
display: flex;
user-select: none;
width: 100%;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
i {
margin-left: 0.6em;
flex-shrink: 0;
font-size: 1rem;
transition: transform 100ms;
}
}
&.open .timeline-menu-title i {
color: $fallback--text;
color: var(--panelText, $fallback--text);
transform: rotate(180deg);
}
.panel {
box-shadow: var(--popoverShadow);
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
li {
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
padding: 0;
&:last-child a {
border-bottom-right-radius: $fallback--panelRadius;
border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius);
border-bottom-left-radius: $fallback--panelRadius;
border-bottom-left-radius: var(--panelRadius, $fallback--panelRadius);
}
&:last-child {
border: none;
}
i {
margin: 0 0.5em;
}
}
a {
display: block;
padding: 0.6em 0;
&:hover {
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--link;
color: var(--selectedMenuText, $fallback--link);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);
}
&.router-link-active {
font-weight: bolder;
background-color: $fallback--lightBg;
background-color: var(--selectedMenu, $fallback--lightBg);
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
--faint: var(--selectedMenuFaintText, $fallback--faint);
--faintLink: var(--selectedMenuFaintLink, $fallback--faint);
--lightText: var(--selectedMenuLightText, $fallback--lightText);
--icon: var(--selectedMenuIcon, $fallback--icon);
&:hover {
text-decoration: underline;
}
}
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More