Merge branch 'develop' of git.pleroma.social:pleroma/pleroma-fe into develop
This commit is contained in:
commit
fa2b680855
|
@ -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
|
||||||
|
|
|
@ -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+"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
53
CHANGELOG.md
53
CHANGELOG.md
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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).
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
55
src/App.scss
55
src/App.scss
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 })
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
||||||
|
<template>
|
||||||
|
<Timeline
|
||||||
|
:title="$t('nav.bookmarks')"
|
||||||
|
:timeline="timeline"
|
||||||
|
:timeline-name="'bookmarks'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./bookmark_timeline.js"></script>
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
|
@ -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>
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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
|
|
@ -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);
|
||||||
|
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
|
@ -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>
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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: () => ({
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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%;
|
||||||
|
|
|
@ -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
|
|
@ -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>
|
|
@ -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')"
|
||||||
|
|
|
@ -61,7 +61,8 @@ const mediaUpload = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
props: [
|
props: [
|
||||||
'dropFiles'
|
'dropFiles',
|
||||||
|
'disabled'
|
||||||
],
|
],
|
||||||
watch: {
|
watch: {
|
||||||
'dropFiles': function (fileInfos) {
|
'dropFiles': function (fileInfos) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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") }}
|
||||||
|
|
|
@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 || []
|
||||||
},
|
},
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -37,6 +37,9 @@ const FilteringTab = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true
|
||||||
|
},
|
||||||
|
replyVisibility () {
|
||||||
|
this.$store.dispatch('queueFlushAll')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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') }}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
|
@ -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' }">
|
||||||
|
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,8 @@ const StillImage = {
|
||||||
'referrerpolicy',
|
'referrerpolicy',
|
||||||
'mimetype',
|
'mimetype',
|
||||||
'imageLoadError',
|
'imageLoadError',
|
||||||
'imageLoadHandler'
|
'imageLoadHandler',
|
||||||
|
'alt'
|
||||||
],
|
],
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue