Merge branch 'develop' into refactor/notification_settings

This commit is contained in:
Mark Felder 2020-07-13 13:25:23 -05:00
commit da94935aaa
116 changed files with 4446 additions and 544 deletions

View file

@ -19,6 +19,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 'Copy link' button for statuses (in the ellipsis menu) - '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 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
### Changed ### Changed
- Registration page no longer requires email if the server is configured not to require it - Registration page no longer requires email if the server is configured not to require it
@ -26,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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
### Fixed ### Fixed
- Custom Emoji will display in poll options now. - Custom Emoji will display in poll options now.
@ -37,6 +43,12 @@ 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
## [2.0.3] - 2020-05-02 ## [2.0.3] - 2020-05-02
### Fixed ### Fixed
@ -98,6 +110,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Ability to change user's email - Ability to change user's email
- About page - About page
- Added remote user redirect - Added remote user redirect
- Bookmarks
### Changed ### Changed
- changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes - changed the way fading effects for user profile/long statuses works, now uses css-mask instead of gradient background hacks which weren't exactly compatible with semi-transparent themes
### Fixed ### Fixed

View file

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

8
docs/index.md Normal file
View file

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

View file

@ -22,6 +22,7 @@
"cropperjs": "^1.4.3", "cropperjs": "^1.4.3",
"diff": "^3.0.1", "diff": "^3.0.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"parse-link-header": "^1.0.1",
"localforage": "^1.5.0", "localforage": "^1.5.0",
"phoenix": "^1.3.0", "phoenix": "^1.3.0",
"portal-vue": "^2.1.4", "portal-vue": "^2.1.4",

View file

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

View file

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

View file

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

View file

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

View file

@ -8,38 +8,72 @@ import backendInteractorService from '../services/backend_interactor_service/bac
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js' import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js' import { applyTheme } from '../services/style_setter/style_setter.js'
const getStatusnetConfig = async ({ store }) => { let staticInitialResults = null
const parsedInitialResults = () => {
if (!document.getElementById('initial-results')) {
return null
}
if (!staticInitialResults) {
staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent)
}
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 data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
const decoded = decodeUTF8Base64(data[request])
const requestData = JSON.parse(decoded)
return {
ok: true,
json: () => requestData,
text: () => requestData
}
}
const getInstanceConfig = async ({ store }) => {
try { try {
const res = await window.fetch('/api/statusnet/config.json') const res = await preloadFetch('/api/v1/instance')
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = data.site const textlimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
store.dispatch('setInstanceOption', { name: 'name', value: name }) store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
store.dispatch('setInstanceOption', { name: 'server', value: server })
store.dispatch('setInstanceOption', { name: 'safeDM', value: safeDMMentionsEnabled !== '0' })
// TODO: default values for this stuff, added if to not make it break on
// my dev config out of the box.
if (uploadlimit) {
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
}
if (vapidPublicKey) { if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
} }
return data.site.pleromafe
} else { } else {
throw (res) throw (res)
} }
} catch (error) { } catch (error) {
console.error('Could not load statusnet config, potentially fatal') console.error('Could not load instance config, potentially fatal')
console.error(error)
}
}
const getBackendProvidedConfig = async ({ store }) => {
try {
const res = await window.fetch('/api/pleroma/frontend_configurations')
if (res.ok) {
const data = await res.json()
return data.pleroma_fe
} else {
throw (res)
}
} catch (error) {
console.error('Could not load backend-provided frontend config, potentially fatal')
console.error(error) console.error(error)
} }
} }
@ -132,7 +166,7 @@ const getTOS = async ({ store }) => {
const getInstancePanel = async ({ store }) => { const getInstancePanel = async ({ store }) => {
try { try {
const res = await window.fetch('/instance/panel.html') const res = await preloadFetch('/instance/panel.html')
if (res.ok) { if (res.ok) {
const html = await res.text() const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html }) store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
@ -189,24 +223,34 @@ 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 })
} }
const getNodeInfo = async ({ store }) => { const getNodeInfo = async ({ store }) => {
try { try {
const res = await window.fetch('/nodeinfo/2.0.json') const res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const metadata = data.metadata const metadata = data.metadata
const features = metadata.features const features = metadata.features
store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
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: '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 })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) })
store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits })
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames }) store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats }) store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
@ -257,7 +301,7 @@ const getNodeInfo = async ({ store }) => {
const setConfig = async ({ store }) => { const setConfig = async ({ store }) => {
// apiConfig, staticConfig // apiConfig, staticConfig
const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()]) const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0] const apiConfig = configInfos[0]
const staticConfig = configInfos[1] const staticConfig = configInfos[1]
@ -280,6 +324,11 @@ const checkOAuthToken = async ({ store }) => {
const afterStoreSetup = async ({ store, i18n }) => { const afterStoreSetup = async ({ store, i18n }) => {
const width = windowWidth() const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800) store.dispatch('setMobileLayout', width <= 800)
const overrides = window.___pleromafe_dev_overrides || {}
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
store.dispatch('setInstanceOption', { name: 'server', value: server })
await setConfig({ store }) await setConfig({ store })
const { customTheme, customThemeSource } = store.state.config const { customTheme, customThemeSource } = store.state.config
@ -299,16 +348,18 @@ const afterStoreSetup = async ({ store, i18n }) => {
} }
// Now we can try getting the server settings and logging in // Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([ await Promise.all([
checkOAuthToken({ store }), checkOAuthToken({ store }),
getTOS({ store }),
getInstancePanel({ store }), getInstancePanel({ store }),
getStickers({ store }), getNodeInfo({ store }),
getNodeInfo({ store }) getInstanceConfig({ store })
]) ])
// Start fetching things that don't need to block the UI // Start fetching things that don't need to block the UI
store.dispatch('fetchMutes') store.dispatch('fetchMutes')
getTOS({ store })
getStickers({ store })
const router = new VueRouter({ const router = new VueRouter({
mode: 'history', mode: 'history',

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
<template> <template>
<div <div
v-if="usePlaceHolder" v-if="usePlaceholder"
:class="{ 'fullwidth': fullwidth }"
@click="openModal" @click="openModal"
> >
<a <a
@ -8,8 +9,11 @@
class="placeholder" class="placeholder"
target="_blank" target="_blank"
:href="attachment.url" :href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
> >
[{{ nsfw ? "NSFW/" : "" }}{{ type.toUpperCase() }}] <span :class="placeholderIconClass" />
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ placeholderName }}
</a> </a>
</div> </div>
<div <div
@ -22,6 +26,8 @@
v-if="hidden" v-if="hidden"
class="image-attachment" class="image-attachment"
:href="attachment.url" :href="attachment.url"
:alt="attachment.description"
:title="attachment.description"
@click.prevent="toggleHidden" @click.prevent="toggleHidden"
> >
<img <img
@ -51,7 +57,6 @@
: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
@ -59,6 +64,7 @@
: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 +89,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 +124,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 {

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -84,54 +84,56 @@
max-width: 25em; max-width: 25em;
} }
.chat-heading {
cursor: pointer;
.icon-comment-empty {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.chat-window {
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.chat-window-container {
height: 100%;
}
.chat-message {
display: flex;
padding: 0.2em 0.5em
}
.chat-avatar {
img {
height: 24px;
width: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
margin-right: 0.5em;
margin-top: 0.25em;
}
}
.chat-input {
display: flex;
textarea {
flex: 1;
margin: 0.6em;
min-height: 3.5em;
resize: none;
}
}
.chat-panel { .chat-panel {
.title { .chat-heading {
cursor: pointer;
.icon-comment-empty {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.chat-window {
overflow-y: auto;
overflow-x: hidden;
max-height: 20em;
}
.chat-window-container {
height: 100%;
}
.chat-message {
display: flex; display: flex;
justify-content: space-between; padding: 0.2em 0.5em
}
.chat-avatar {
img {
height: 24px;
width: 24px;
border-radius: $fallback--avatarRadius;
border-radius: var(--avatarRadius, $fallback--avatarRadius);
margin-right: 0.5em;
margin-top: 0.25em;
}
}
.chat-input {
display: flex;
textarea {
flex: 1;
margin: 0.6em;
min-height: 3.5em;
resize: none;
}
}
.chat-panel {
.title {
display: flex;
justify-content: space-between;
}
} }
} }
</style> </style>

View file

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

View file

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

View file

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

View file

@ -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,14 +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)
picker.$el.style.top = offsetBottom + 'px' this.setPlacement(picker, picker, offsetBottom)
picker.$el.style.bottom = 'auto' },
setPlacement (container, target, offsetBottom) {
if (!container || !target) return
target.style.top = offsetBottom + 'px'
target.style.bottom = 'auto'
if (this.placement === 'top' || (this.placement === 'auto' && this.overflowsBottom(container))) {
target.style.top = 'auto'
target.style.bottom = this.input.elm.offsetHeight + 'px'
}
},
overflowsBottom (el) {
return el.getBoundingClientRect().bottom > window.innerHeight
} }
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import SideDrawer from '../side_drawer/side_drawer.vue'
import Notifications from '../notifications/notifications.vue' import Notifications from '../notifications/notifications.vue'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils' import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import GestureService from '../../services/gesture_service/gesture_service' import GestureService from '../../services/gesture_service/gesture_service'
import { mapGetters } from 'vuex'
const MobileNav = { const MobileNav = {
components: { components: {
@ -30,7 +31,11 @@ const MobileNav = {
return this.unseenNotifications.length return this.unseenNotifications.length
}, },
hideSitename () { return this.$store.state.instance.hideSitename }, hideSitename () { return this.$store.state.instance.hideSitename },
sitename () { return this.$store.state.instance.name } sitename () { return this.$store.state.instance.name },
isChat () {
return this.$route.name === 'chat'
},
...mapGetters(['unreadChatCount'])
}, },
methods: { methods: {
toggleMobileSidebar () { toggleMobileSidebar () {

View file

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

View file

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

View file

@ -1,4 +1,4 @@
import { mapState } from 'vuex' import { mapState, mapGetters } from 'vuex'
const NavPanel = { const NavPanel = {
created () { created () {
@ -6,13 +6,17 @@ const NavPanel = {
this.$store.dispatch('startFetchingFollowRequests') this.$store.dispatch('startFetchingFollowRequests')
} }
}, },
computed: mapState({ computed: {
currentUser: state => state.users.currentUser, ...mapState({
chat: state => state.chat.channel, currentUser: state => state.users.currentUser,
followRequestCount: state => state.api.followRequests.length, chat: state => state.chat.channel,
privateMode: state => state.instance.private, followRequestCount: state => state.api.followRequests.length,
federating: state => state.instance.federating privateMode: state => state.instance.private,
}) federating: state => state.instance.federating,
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
}),
...mapGetters(['unreadChatCount'])
}
} }
export default NavPanel export default NavPanel

View file

@ -17,6 +17,22 @@
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
</router-link> </router-link>
</li> </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 && pleromaChatMessagesAvailable">
<router-link :to="{ name: 'chats', params: { username: currentUser.screen_name } }">
<div
v-if="unreadChatCount"
class="badge badge-notification unread-chat-count"
>
{{ unreadChatCount }}
</div>
<i class="button-icon icon-chat" /> {{ $t("nav.chats") }}
</router-link>
</li>
<li v-if="currentUser && currentUser.locked"> <li v-if="currentUser && currentUser.locked">
<router-link :to="{ name: 'friend-requests' }"> <router-link :to="{ name: 'friend-requests' }">
<i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }} <i class="button-icon icon-user-plus" /> {{ $t("nav.friend_requests") }}

View file

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

View file

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

View file

@ -118,6 +118,11 @@
flex: 1; flex: 1;
padding-left: 0.8em; padding-left: 0.8em;
min-width: 0; min-width: 0;
.timeago {
min-width: 3em;
text-align: right;
}
} }
.emoji-reaction-emoji { .emoji-reaction-emoji {

View file

@ -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) => {
@ -31,21 +33,42 @@ const PostStatusForm = {
'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.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 +91,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 +101,17 @@ 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
} }
}, },
computed: { computed: {
@ -153,28 +180,52 @@ 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.contentType': function () {
this.autoPreview()
},
'newStatus.spoilerText': function () {
this.autoPreview()
}
}, },
methods: { methods: {
postStatus (newStatus) { 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 +235,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,
@ -194,7 +254,11 @@ const PostStatusForm = {
inReplyToStatusId: this.replyTo, inReplyToStatusId: this.replyTo,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll poll
}).then((data) => { }
const postHandler = this.postHandler ? this.postHandler : statusPoster.postStatus
postHandler(postingOptions).then((data) => {
if (!data.error) { if (!data.error) {
this.newStatus = { this.newStatus = {
status: '', status: '',
@ -202,43 +266,105 @@ const PostStatusForm = {
files: [], files: [],
visibility: newStatus.visibility, visibility: newStatus.visibility,
contentType: newStatus.contentType, contentType: newStatus.contentType,
poll: {} poll: {},
mediaDescriptions: {}
} }
this.pollFormVisible = false this.pollFormVisible = false
this.$refs.mediaUpload.clearFile() this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm() this.clearPollForm()
this.$emit('posted') this.$emit('posted', data)
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
let el = this.$el.querySelector('textarea') let el = this.$el.querySelector('textarea')
el.style.height = 'auto' el.style.height = 'auto'
el.style.height = undefined el.style.height = undefined
this.error = null this.error = null
if (this.preview) this.previewStatus()
} 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,13 +392,14 @@ 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'
} }
}, },
onEmojiInputInput (e) { onEmojiInputInput (e) {
this.autoPreview()
this.$nextTick(() => { this.$nextTick(() => {
this.resize(this.$refs['textarea']) this.resize(this.$refs['textarea'])
}) })
@ -284,6 +411,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
} }
@ -336,8 +464,10 @@ 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 = target.scrollHeight - vertPadding
const newHeight = this.maxHeight ? Math.min(heightWithoutPadding, this.maxHeight) : heightWithoutPadding
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 +518,18 @@ 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
} }
} }
} }

View file

@ -5,19 +5,20 @@
> >
<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"
@ -69,15 +70,55 @@
<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="icon-down-open"
:style="{ transform: showPreview ? 'rotate(0deg)' : 'rotate(-90deg)' }"
/>
</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,28 @@
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"
: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 +164,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 +225,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 +266,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 +297,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 +346,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 +373,48 @@
max-width: 10em; max-width: 10em;
} }
.preview-heading {
display: flex;
width: 100%;
.icon-spin3 {
margin-left: auto;
}
}
.preview-toggle {
display: flex;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.icon-down-open {
transition: transform 0.1s;
}
.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 +422,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 +439,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 +479,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 +495,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 +518,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 +577,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 +643,11 @@
border: 2px dashed var(--text, $fallback--text); border: 2px dashed var(--text, $fallback--text);
} }
} }
// todo: unify with attachment.vue (otherwise the uploaded images are not minified unless a status with an attachment was displayed before)
img.media-upload, .media-upload-container > video {
line-height: 0;
max-height: 200px;
max-width: 100%;
}
</style> </style>

View file

@ -30,7 +30,7 @@
height: 100vh; height: 100vh;
} }
.panel-body { >.panel-body {
height: 100%; height: 100%;
overflow-y: hidden; overflow-y: hidden;

View file

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

View file

@ -1,4 +1,5 @@
import unescape from 'lodash/unescape' import unescape from 'lodash/unescape'
import merge from 'lodash/merge'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue' import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue' import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js' import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
@ -16,6 +17,7 @@ const ProfileTab = {
newLocked: this.$store.state.users.currentUser.locked, newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text, newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope, newDefaultScope: this.$store.state.users.currentUser.default_scope,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
hideFollows: this.$store.state.users.currentUser.hide_follows, hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers, hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count, hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
@ -63,6 +65,45 @@ const ProfileTab = {
...this.$store.state.instance.emoji, ...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji ...this.$store.state.instance.customEmoji
] }) ] })
},
userSuggestor () {
return suggestor({
users: this.$store.state.users.users,
updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
})
},
fieldsLimits () {
return this.$store.state.instance.fieldsLimits
},
maxFields () {
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: {
@ -75,6 +116,7 @@ const ProfileTab = {
// Backend notation. // Backend notation.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
display_name: this.newName, display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
default_scope: this.newDefaultScope, default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText, no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows, hide_follows: this.hideFollows,
@ -87,6 +129,8 @@ const ProfileTab = {
show_role: this.showRole show_role: this.showRole
/* eslint-enable camelcase */ /* eslint-enable camelcase */
} }).then((user) => { } }).then((user) => {
this.newFields.splice(user.fields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user]) this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user) this.$store.commit('setCurrentUser', user)
}) })
@ -94,6 +138,16 @@ const ProfileTab = {
changeVis (visibility) { changeVis (visibility) {
this.newDefaultScope = visibility this.newDefaultScope = visibility
}, },
addField () {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
return true
}
return false
},
deleteField (index, event) {
this.$delete(this.newFields, index)
},
uploadFile (slot, e) { uploadFile (slot, e) {
const file = e.target.files[0] const file = e.target.files[0]
if (!file) { return } if (!file) { return }
@ -123,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)
@ -145,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)
@ -160,11 +232,11 @@ const ProfileTab = {
}) })
.then(() => { this.bannerUploading = false }) .then(() => { this.bannerUploading = false })
}, },
submitBg () { submitBackground (background) {
if (!this.backgroundPreview) { return } if (!this.backgroundPreview && background !== '') { return }
let background = this.background
this.backgroundUploading = true this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateBg({ background }).then((data) => { this.$store.state.api.backendInteractor.updateProfileImages({ background }).then((data) => {
if (!data.error) { if (!data.error) {
this.$store.commit('addNewUsers', [data]) this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data) this.$store.commit('setCurrentUser', data)

View file

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

View file

@ -95,6 +95,54 @@
{{ $t('settings.discoverable') }} {{ $t('settings.discoverable') }}
</Checkbox> </Checkbox>
</p> </p>
<div v-if="maxFields > 0">
<p>{{ $t('settings.profile_fields.label') }}</p>
<div
v-for="(_, i) in newFields"
:key="i"
class="profile-fields"
>
<EmojiInput
v-model="newFields[i].name"
enable-emoji-picker
hide-emoji-button
:suggest="userSuggestor"
>
<input
v-model="newFields[i].name"
:placeholder="$t('settings.profile_fields.name')"
>
</EmojiInput>
<EmojiInput
v-model="newFields[i].value"
enable-emoji-picker
hide-emoji-button
:suggest="userSuggestor"
>
<input
v-model="newFields[i].value"
:placeholder="$t('settings.profile_fields.value')"
>
</EmojiInput>
<div
class="icon-container"
>
<i
v-show="newFields.length > 1"
class="icon-cancel"
@click="deleteField(i)"
/>
</div>
</div>
<a
v-if="newFields.length < maxFields"
class="add-field faint"
@click="addField"
>
<i class="icon-plus" />
{{ $t("settings.profile_fields.add_field") }}
</a>
</div>
<p> <p>
<Checkbox v-model="bot"> <Checkbox v-model="bot">
{{ $t('settings.bot') }} {{ $t('settings.bot') }}
@ -113,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"
@ -136,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>
@ -160,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>
@ -177,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>
@ -196,7 +267,7 @@
<button <button
v-else-if="backgroundPreview" v-else-if="backgroundPreview"
class="btn btn-default" class="btn btn-default"
@click="submitBg" @click="submitBackground(background)"
> >
{{ $t('general.submit') }} {{ $t('general.submit') }}
</button> </button>

View file

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

View file

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

View file

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

View file

@ -40,12 +40,24 @@
</router-link> </router-link>
</li> </li>
<li <li
v-if="currentUser" v-if="currentUser && pleromaChatMessagesAvailable"
@click="toggleDrawer" @click="toggleDrawer"
> >
<router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }"> <router-link :to="{ name: 'dms', params: { username: currentUser.screen_name } }">
<i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }} <i class="button-icon icon-mail-alt" /> {{ $t("nav.dms") }}
</router-link> </router-link>
<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> </li>
<li <li
v-if="currentUser" v-if="currentUser"
@ -65,6 +77,14 @@
<i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }} <i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
</router-link> </router-link>
</li> </li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'bookmarks'}">
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
</router-link>
</li>
<li <li
v-if="currentUser && currentUser.locked" v-if="currentUser && currentUser.locked"
@click="toggleDrawer" @click="toggleDrawer"
@ -95,14 +115,6 @@
<i class="button-icon icon-globe" /> {{ $t("nav.twkn") }} <i class="button-icon icon-globe" /> {{ $t("nav.twkn") }}
</router-link> </router-link>
</li> </li>
<li
v-if="currentUser && chat"
@click="toggleDrawer"
>
<router-link :to="{ name: 'chat' }">
<i class="button-icon icon-chat" /> {{ $t("nav.chat") }}
</router-link>
</li>
</ul> </ul>
<ul> <ul>
<li <li

View file

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

View file

@ -141,7 +141,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 +164,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)

View file

@ -197,7 +197,7 @@
> >
<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"
> >
@ -208,7 +208,12 @@
@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 icon-reply" />
<span class="faint-link reply-to-text">{{ $t('status.reply_to') }}</span> <span
class="faint-link reply-to-text"
:class="{ 'strikethrough': !status.parent_visible }"
>
{{ $t('status.reply_to') }}
</span>
</a> </a>
</StatusPopover> </StatusPopover>
<span <span
@ -372,9 +377,6 @@ $status-margin: 0.75em;
} }
.status-el { .status-el {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
border-left-width: 0px; border-left-width: 0px;
min-width: 0; min-width: 0;
border-color: $fallback--border; border-color: $fallback--border;
@ -526,6 +528,10 @@ $status-margin: 0.75em;
margin: 0 0.4em 0 0.2em; margin: 0 0.4em 0 0.2em;
} }
.strikethrough {
text-decoration: line-through;
}
.replies-separator { .replies-separator {
margin-left: 0.4em; margin-left: 0.4em;
} }

View file

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

View file

@ -3,45 +3,32 @@
<div class="status-body"> <div class="status-body">
<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,31 +38,52 @@
: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.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">
@ -129,6 +137,12 @@ $status-margin: 0.75em;
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 +150,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 +190,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 +270,12 @@ $status-margin: 0.75em;
h4 { h4 {
margin: 1.1em 0; margin: 1.1em 0;
} }
&.single-line {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
} }
} }

View file

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

View file

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

View file

@ -11,6 +11,8 @@
<img <img
ref="src" ref="src"
:key="src" :key="src"
:alt="alt"
:title="alt"
:src="src" :src="src"
:referrerpolicy="referrerpolicy" :referrerpolicy="referrerpolicy"
@load="onLoad" @load="onLoad"

View file

@ -45,11 +45,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 () {
@ -112,8 +116,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 +137,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

View file

@ -19,14 +19,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
> >

View file

@ -8,26 +8,20 @@ const UserAvatar = {
], ],
data () { data () {
return { return {
showPlaceholder: false showPlaceholder: false,
defaultAvatar: `${this.$store.state.instance.server + this.$store.state.instance.defaultAvatar}`
} }
}, },
components: { components: {
StillImage StillImage
}, },
computed: {
imgSrc () {
return this.showPlaceholder ? '/images/avi.png' : this.user.profile_image_url_original
}
},
methods: { methods: {
imgSrc (src) {
return (!src || this.showPlaceholder) ? this.defaultAvatar : src
},
imageLoadError () { imageLoadError () {
this.showPlaceholder = true this.showPlaceholder = true
} }
},
watch: {
src () {
this.showPlaceholder = false
}
} }
} }

View file

@ -3,7 +3,7 @@
class="avatar" class="avatar"
:alt="user.screen_name" :alt="user.screen_name"
:title="user.screen_name" :title="user.screen_name"
:src="imgSrc" :src="imgSrc(user.profile_image_url_original)"
:class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }" :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
:image-load-error="imageLoadError" :image-load-error="imageLoadError"
/> />

View file

@ -10,9 +10,7 @@
:hide-bio="true" :hide-bio="true"
rounded="top" rounded="top"
/> />
<div class="panel-footer"> <PostStatusForm />
<PostStatusForm />
</div>
</div> </div>
<auth-form <auth-form
v-else v-else

View file

@ -4,6 +4,8 @@
:src="attachment.url" :src="attachment.url"
:loop="loopVideo" :loop="loopVideo"
:controls="controls" :controls="controls"
:alt="attachment.description"
:title="attachment.description"
playsinline playsinline
@loadeddata="onVideoDataLoad" @loadeddata="onVideoDataLoad"
/> />

View file

@ -7,7 +7,7 @@ function showWhoToFollow (panel, reply) {
panel.usersToFollow.forEach((toFollow, index) => { panel.usersToFollow.forEach((toFollow, index) => {
let user = shuffled[index] let user = shuffled[index]
let img = user.avatar || '/images/avi.png' let img = user.avatar || this.$store.state.instance.defaultAvatar
let name = user.acct let name = user.acct
toFollow.img = img toFollow.img = img
@ -38,13 +38,7 @@ function getWhoToFollow (panel) {
const WhoToFollowPanel = { const WhoToFollowPanel = {
data: () => ({ data: () => ({
usersToFollow: new Array(3).fill().map(x => ( usersToFollow: []
{
img: '/images/avi.png',
name: '',
id: 0
}
))
}), }),
computed: { computed: {
user: function () { user: function () {
@ -68,6 +62,13 @@ const WhoToFollowPanel = {
}, },
mounted: mounted:
function () { function () {
this.usersToFollow = new Array(3).fill().map(x => (
{
img: this.$store.state.instance.defaultAvatar,
name: '',
id: 0
}
))
if (this.suggestionsEnabled) { if (this.suggestionsEnabled) {
getWhoToFollow(this) getWhoToFollow(this)
} }

View file

@ -12,5 +12,9 @@
.error { .error {
font-size: 14px; font-size: 14px;
} }
a {
cursor: pointer;
}
} }
} }

View file

@ -44,6 +44,7 @@
}, },
"features_panel": { "features_panel": {
"chat": "Chat", "chat": "Chat",
"pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher", "gopher": "Gopher",
"media_proxy": "Media proxy", "media_proxy": "Media proxy",
"scope_options": "Scope options", "scope_options": "Scope options",
@ -120,10 +121,12 @@
"public_tl": "Public Timeline", "public_tl": "Public Timeline",
"timeline": "Timeline", "timeline": "Timeline",
"twkn": "The Whole Known Network", "twkn": "The Whole Known Network",
"bookmarks": "Bookmarks",
"user_search": "User Search", "user_search": "User Search",
"search": "Search", "search": "Search",
"who_to_follow": "Who to follow", "who_to_follow": "Who to follow",
"preferences": "Preferences" "preferences": "Preferences",
"chats": "Chats"
}, },
"notifications": { "notifications": {
"broken_favorite": "Unknown status, searching for it…", "broken_favorite": "Unknown status, searching for it…",
@ -163,6 +166,9 @@
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.", "load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji" "load_all": "Loading all {emojiAmount} emoji"
}, },
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
},
"interactions": { "interactions": {
"favs_repeats": "Repeats and Favorites", "favs_repeats": "Repeats and Favorites",
"follows": "New follows", "follows": "New follows",
@ -174,6 +180,7 @@
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.", "account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked", "account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive", "attachments_sensitive": "Mark attachments as sensitive",
"media_description": "Media description",
"content_type": { "content_type": {
"text/plain": "Plain text", "text/plain": "Plain text",
"text/html": "HTML", "text/html": "HTML",
@ -185,6 +192,10 @@
"direct_warning_to_all": "This post will be visible to all the mentioned users.", "direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.", "direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
"posting": "Posting", "posting": "Posting",
"preview": "Preview",
"preview_empty": "Empty",
"empty_status_error": "Can't post an empty status with no files",
"media_description_error": "Failed to update media, try again",
"scope_notice": { "scope_notice": {
"public": "This post will be visible to everyone", "public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only", "private": "This post will be visible to your followers only",
@ -278,12 +289,11 @@
"change_password": "Change Password", "change_password": "Change Password",
"change_password_error": "There was an issue changing your password.", "change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!", "changed_password": "Password changed successfully!",
"chatMessageRadius": "Chat message",
"collapse_subject": "Collapse posts with subjects", "collapse_subject": "Collapse posts with subjects",
"composing": "Composing", "composing": "Composing",
"confirm_new_password": "Confirm new password", "confirm_new_password": "Confirm new password",
"current_avatar": "Your current avatar",
"current_password": "Current password", "current_password": "Current password",
"current_profile_banner": "Your current profile banner",
"mutes_and_blocks": "Mutes and Blocks", "mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data Import / Export", "data_import_export_tab": "Data Import / Export",
"default_vis": "Default visibility scope", "default_vis": "Default visibility scope",
@ -334,6 +344,12 @@
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")", "loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes", "mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame", "play_videos_in_modal": "Play videos in a popup frame",
"profile_fields": {
"label": "Profile metadata",
"add_field": "Add Field",
"name": "Label",
"value": "Content"
},
"use_contain_fit": "Don't crop the attachment in thumbnails", "use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name", "name": "Name",
"name_bio": "Name & Bio", "name_bio": "Name & Bio",
@ -384,6 +400,12 @@
"set_new_avatar": "Set new avatar", "set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background", "set_new_profile_background": "Set new profile background",
"set_new_profile_banner": "Set new profile banner", "set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar",
"reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?",
"reset_banner_confirm": "Do you really want to reset the banner?",
"reset_background_confirm": "Do you really want to reset the background?",
"settings": "Settings", "settings": "Settings",
"subject_input_always_show": "Always show subject field", "subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying", "subject_line_behavior": "Copy subject when replying",
@ -496,7 +518,12 @@
"selectedMenu": "Selected menu item", "selectedMenu": "Selected menu item",
"disabled": "Disabled", "disabled": "Disabled",
"toggled": "Toggled", "toggled": "Toggled",
"tabs": "Tabs" "tabs": "Tabs",
"chat": {
"incoming": "Incoming",
"outgoing": "Outgoing",
"border": "Border"
}
}, },
"radii": { "radii": {
"_tab_label": "Roundness" "_tab_label": "Roundness"
@ -608,6 +635,7 @@
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated", "no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated", "repeated": "repeated",
"show_new": "Show new", "show_new": "Show new",
"reload": "Reload",
"up_to_date": "Up-to-date", "up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses", "no_more_statuses": "No more statuses",
"no_statuses": "No statuses" "no_statuses": "No statuses"
@ -619,6 +647,8 @@
"pin": "Pin on profile", "pin": "Pin on profile",
"unpin": "Unpin from profile", "unpin": "Unpin from profile",
"pinned": "Pinned", "pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?", "delete_confirm": "Do you really want to delete this status?",
"reply_to": "Reply to", "reply_to": "Reply to",
"replies_list": "Replies:", "replies_list": "Replies:",
@ -627,7 +657,11 @@
"status_unavailable": "Status unavailable", "status_unavailable": "Status unavailable",
"copy_link": "Copy link to status", "copy_link": "Copy link to status",
"thread_muted": "Thread muted", "thread_muted": "Thread muted",
"thread_muted_and_words": ", has words:" "thread_muted_and_words": ", has words:",
"show_full_subject": "Show full subject",
"hide_full_subject": "Hide full subject",
"show_content": "Show content",
"hide_content": "Hide content"
}, },
"user_card": { "user_card": {
"approve": "Approve", "approve": "Approve",
@ -648,6 +682,7 @@
"its_you": "It's you!", "its_you": "It's you!",
"media": "Media", "media": "Media",
"mention": "Mention", "mention": "Mention",
"message": "Message",
"mute": "Mute", "mute": "Mute",
"muted": "Muted", "muted": "Muted",
"per_day": "per day", "per_day": "per day",
@ -710,7 +745,8 @@
"add_reaction": "Add Reaction", "add_reaction": "Add Reaction",
"user_settings": "User Settings", "user_settings": "User Settings",
"accept_follow_request": "Accept follow request", "accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request" "reject_follow_request": "Reject follow request",
"bookmark": "Bookmark"
}, },
"upload": { "upload": {
"error": { "error": {
@ -745,5 +781,27 @@
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.", "password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
"password_reset_required": "You must reset your password to log in.", "password_reset_required": "You must reset your password to log in.",
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator." "password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
},
"chats": {
"you": "You:",
"message_user": "Message {nickname}",
"delete": "Delete",
"chats": "Chats",
"new": "New Chat",
"empty_message_error": "Cannot post empty message",
"more": "More",
"delete_confirm": "Do you really want to delete this message?",
"error_loading_chat": "Something went wrong when loading the chat.",
"error_sending_message": "Something went wrong when sending the message.",
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
},
"file_type": {
"audio": "Audio",
"video": "Video",
"image": "Image",
"file": "File"
},
"display_date": {
"today": "Today"
} }
} }

View file

@ -28,7 +28,12 @@
"disable": "Poista käytöstä", "disable": "Poista käytöstä",
"confirm": "Hyväksy", "confirm": "Hyväksy",
"verify": "Varmenna", "verify": "Varmenna",
"enable": "Ota käyttöön" "enable": "Ota käyttöön",
"loading": "Ladataan…",
"error_retry": "Yritä uudelleen",
"retry": "Yritä uudelleen",
"close": "Sulje",
"peek": "Kurkkaa"
}, },
"login": { "login": {
"login": "Kirjaudu sisään", "login": "Kirjaudu sisään",
@ -63,10 +68,11 @@
"who_to_follow": "Seurausehdotukset", "who_to_follow": "Seurausehdotukset",
"preferences": "Asetukset", "preferences": "Asetukset",
"administration": "Ylläpito", "administration": "Ylläpito",
"search": "Haku" "search": "Haku",
"bookmarks": "Kirjanmerkit"
}, },
"notifications": { "notifications": {
"broken_favorite": "Viestiä ei löydetty...", "broken_favorite": "Viestiä ei löydetty",
"favorited_you": "tykkäsi viestistäsi", "favorited_you": "tykkäsi viestistäsi",
"followed_you": "seuraa sinua", "followed_you": "seuraa sinua",
"load_older": "Lataa vanhempia ilmoituksia", "load_older": "Lataa vanhempia ilmoituksia",
@ -101,7 +107,7 @@
}, },
"post_status": { "post_status": {
"new_status": "Uusi viesti", "new_status": "Uusi viesti",
"account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi", "account_not_locked_warning": "Tilisi ei ole {0}. Kuka vain voi seurata sinua nähdäksesi 'vain-seuraajille' -viestisi.",
"account_not_locked_warning_link": "lukittu", "account_not_locked_warning_link": "lukittu",
"attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi", "attachments_sensitive": "Merkkaa liitteet arkaluonteisiksi",
"content_type": { "content_type": {
@ -126,7 +132,12 @@
"public": "Tämä viesti näkyy kaikille", "public": "Tämä viesti näkyy kaikille",
"private": "Tämä viesti näkyy vain sinun seuraajillesi", "private": "Tämä viesti näkyy vain sinun seuraajillesi",
"unlisted": "Tämä viesti ei näy Julkisella Aikajanalla tai Koko Tunnettu Verkosto -aikajanalla" "unlisted": "Tämä viesti ei näy Julkisella Aikajanalla tai Koko Tunnettu Verkosto -aikajanalla"
} },
"preview": "Esikatselu",
"preview_empty": "Tyhjä",
"empty_status_error": "Tyhjää viestiä ilman tiedostoja ei voi lähettää",
"media_description": "Tiedoston kuvaus",
"media_description_error": "Tiedostojen päivitys epäonnistui, yritä uudelleen"
}, },
"registration": { "registration": {
"bio": "Kuvaus", "bio": "Kuvaus",
@ -175,7 +186,7 @@
"data_import_export_tab": "Tietojen tuonti / vienti", "data_import_export_tab": "Tietojen tuonti / vienti",
"default_vis": "Oletusnäkyvyysrajaus", "default_vis": "Oletusnäkyvyysrajaus",
"delete_account": "Poista tili", "delete_account": "Poista tili",
"delete_account_description": "Poista tilisi ja viestisi pysyvästi.", "delete_account_description": "Poista tietosi ja lukitse tili pysyvästi.",
"delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.", "delete_account_error": "Virhe poistaessa tiliäsi. Jos virhe jatkuu, ota yhteyttä palvelimesi ylläpitoon.",
"delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.", "delete_account_instructions": "Syötä salasanasi vahvistaaksesi tilin poiston.",
"emoji_reactions_on_timeline": "Näytä emojireaktiot aikajanalla", "emoji_reactions_on_timeline": "Näytä emojireaktiot aikajanalla",
@ -288,7 +299,7 @@
"authentication_methods": "Todennus", "authentication_methods": "Todennus",
"warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.", "warning_of_generate_new_codes": "Luodessasi uudet palautuskoodit, vanhat koodisi lakkaavat toimimasta.",
"recovery_codes": "Palautuskoodit.", "recovery_codes": "Palautuskoodit.",
"waiting_a_recovery_codes": "Odotetaan palautuskoodeja...", "waiting_a_recovery_codes": "Odotetaan palautuskoodeja",
"recovery_codes_warning": "Kirjoita koodit ylös tai tallenna ne turvallisesti, muuten et näe niitä uudestaan. Jos et voi käyttää monivaihetodennusta ja sinulla ei ole palautuskoodeja, et voi enää kirjautua sisään tilillesi.", "recovery_codes_warning": "Kirjoita koodit ylös tai tallenna ne turvallisesti, muuten et näe niitä uudestaan. Jos et voi käyttää monivaihetodennusta ja sinulla ei ole palautuskoodeja, et voi enää kirjautua sisään tilillesi.",
"scan": { "scan": {
"title": "Skannaa", "title": "Skannaa",
@ -329,7 +340,7 @@
"post_status_content_type": "Uuden viestin sisällön muoto", "post_status_content_type": "Uuden viestin sisällön muoto",
"user_mutes": "Käyttäjät", "user_mutes": "Käyttäjät",
"useStreamingApiWarning": "(Kokeellinen)", "useStreamingApiWarning": "(Kokeellinen)",
"type_domains_to_mute": "Syötä mykistettäviä sivustoja", "type_domains_to_mute": "Etsi mykistettäviä sivustoja",
"upload_a_photo": "Lataa kuva", "upload_a_photo": "Lataa kuva",
"fun": "Hupi", "fun": "Hupi",
"greentext": "Meeminuolet", "greentext": "Meeminuolet",
@ -490,7 +501,21 @@
"title": "Versio", "title": "Versio",
"backend_version": "Palvelimen versio", "backend_version": "Palvelimen versio",
"frontend_version": "Käyttöliittymän versio" "frontend_version": "Käyttöliittymän versio"
} },
"reset_profile_background": "Nollaa taustakuva",
"reset_background_confirm": "Haluatko todella nollata taustakuvan?",
"mutes_and_blocks": "Mykistykset ja Estot",
"bot": "Tämä on bottitili",
"profile_fields": {
"label": "Profiilin metatiedot",
"add_field": "Lisää kenttä",
"name": "Nimi",
"value": "Sisältö"
},
"reset_avatar": "Nollaa profiilikuva",
"reset_profile_banner": "Nollaa profiilin tausta",
"reset_avatar_confirm": "Haluatko todella nollata profiilikuvan?",
"reset_banner_confirm": "Haluatko todella nollata profiilin taustan?"
}, },
"time": { "time": {
"day": "{0} päivä", "day": "{0} päivä",
@ -536,7 +561,8 @@
"show_new": "Näytä uudet", "show_new": "Näytä uudet",
"up_to_date": "Ajantasalla", "up_to_date": "Ajantasalla",
"no_more_statuses": "Ei enempää viestejä", "no_more_statuses": "Ei enempää viestejä",
"no_statuses": "Ei viestejä" "no_statuses": "Ei viestejä",
"reload": "Päivitä"
}, },
"status": { "status": {
"favorites": "Tykkäykset", "favorites": "Tykkäykset",
@ -551,7 +577,15 @@
"mute_conversation": "Mykistä keskustelu", "mute_conversation": "Mykistä keskustelu",
"unmute_conversation": "Poista mykistys", "unmute_conversation": "Poista mykistys",
"status_unavailable": "Viesti ei saatavissa", "status_unavailable": "Viesti ei saatavissa",
"copy_link": "Kopioi linkki" "copy_link": "Kopioi linkki",
"bookmark": "Lisää kirjanmerkkeihin",
"unbookmark": "Poista kirjanmerkeistä",
"thread_muted": "Keskustelu mykistetty",
"thread_muted_and_words": ", sisältää sanat:",
"show_full_subject": "Näytä koko otsikko",
"hide_full_subject": "Piilota koko otsikko",
"show_content": "Näytä sisältö",
"hide_content": "Piilota sisältö"
}, },
"user_card": { "user_card": {
"approve": "Hyväksy", "approve": "Hyväksy",
@ -561,7 +595,7 @@
"follow": "Seuraa", "follow": "Seuraa",
"follow_sent": "Pyyntö lähetetty!", "follow_sent": "Pyyntö lähetetty!",
"follow_progress": "Pyydetään…", "follow_progress": "Pyydetään…",
"follow_again": "Lähetä pyyntö uudestaan", "follow_again": "Lähetä pyyntö uudestaan?",
"follow_unfollow": "Älä seuraa", "follow_unfollow": "Älä seuraa",
"followees": "Seuraa", "followees": "Seuraa",
"followers": "Seuraajat", "followers": "Seuraajat",
@ -575,7 +609,7 @@
"statuses": "Viestit", "statuses": "Viestit",
"hidden": "Piilotettu", "hidden": "Piilotettu",
"media": "Media", "media": "Media",
"block_progress": "Estetään...", "block_progress": "Estetään",
"admin_menu": { "admin_menu": {
"grant_admin": "Anna Ylläpitöoikeudet", "grant_admin": "Anna Ylläpitöoikeudet",
"force_nsfw": "Merkitse kaikki viestit NSFW:nä", "force_nsfw": "Merkitse kaikki viestit NSFW:nä",
@ -601,10 +635,10 @@
"subscribe": "Tilaa", "subscribe": "Tilaa",
"unsubscribe": "Poista tilaus", "unsubscribe": "Poista tilaus",
"unblock": "Poista esto", "unblock": "Poista esto",
"unblock_progress": "Postetaan estoa...", "unblock_progress": "Poistetaan estoa…",
"unmute": "Poista mykistys", "unmute": "Poista mykistys",
"unmute_progress": "Poistetaan mykistystä...", "unmute_progress": "Poistetaan mykistystä",
"mute_progress": "Mykistetään...", "mute_progress": "Mykistetään",
"hide_repeats": "Piilota toistot", "hide_repeats": "Piilota toistot",
"show_repeats": "Näytä toistot" "show_repeats": "Näytä toistot"
}, },
@ -625,7 +659,8 @@
"user_settings": "Käyttäjäasetukset", "user_settings": "Käyttäjäasetukset",
"add_reaction": "Lisää Reaktio", "add_reaction": "Lisää Reaktio",
"accept_follow_request": "Hyväksy seurauspyyntö", "accept_follow_request": "Hyväksy seurauspyyntö",
"reject_follow_request": "Hylkää seurauspyyntö" "reject_follow_request": "Hylkää seurauspyyntö",
"bookmark": "Kirjanmerkki"
}, },
"upload": { "upload": {
"error": { "error": {
@ -674,8 +709,8 @@
"domain_mute_card": { "domain_mute_card": {
"mute": "Mykistä", "mute": "Mykistä",
"unmute": "Poista mykistys", "unmute": "Poista mykistys",
"mute_progress": "Mykistetään...", "mute_progress": "Mykistetään",
"unmute_progress": "Poistetaan mykistyst..." "unmute_progress": "Poistetaan mykistystä…"
}, },
"exporter": { "exporter": {
"export": "Vie", "export": "Vie",
@ -743,5 +778,8 @@
"people_talking": "{0} käyttäjää puhuvat", "people_talking": "{0} käyttäjää puhuvat",
"person_talking": "{0} käyttäjä puhuu", "person_talking": "{0} käyttäjä puhuu",
"no_results": "Ei tuloksia" "no_results": "Ei tuloksia"
},
"errors": {
"storage_unavailable": "Pleroma ei voinut käyttää selaimen muistia. Kirjautumisesi ja paikalliset asetukset eivät tallennu ja saatat kohdata odottamattomia ongelmia. Yritä sallia evästeet."
} }
} }

View file

@ -34,7 +34,9 @@
"user_search": "Ricerca utenti", "user_search": "Ricerca utenti",
"search": "Ricerca", "search": "Ricerca",
"who_to_follow": "Chi seguire", "who_to_follow": "Chi seguire",
"preferences": "Preferenze" "preferences": "Preferenze",
"bookmarks": "Segnalibri",
"chats": "Conversazioni"
}, },
"notifications": { "notifications": {
"followed_you": "ti segue", "followed_you": "ti segue",
@ -84,7 +86,7 @@
"change_password": "Cambia password", "change_password": "Cambia password",
"change_password_error": "C'è stato un problema durante il cambiamento della password.", "change_password_error": "C'è stato un problema durante il cambiamento della password.",
"changed_password": "Password cambiata correttamente!", "changed_password": "Password cambiata correttamente!",
"collapse_subject": "Ripiega messaggi con Oggetto", "collapse_subject": "Ripiega messaggi con oggetto",
"confirm_new_password": "Conferma la nuova password", "confirm_new_password": "Conferma la nuova password",
"current_password": "La tua password attuale", "current_password": "La tua password attuale",
"data_import_export_tab": "Importa o esporta dati", "data_import_export_tab": "Importa o esporta dati",
@ -255,7 +257,13 @@
"top_bar": "Barra superiore", "top_bar": "Barra superiore",
"panel_header": "Titolo pannello", "panel_header": "Titolo pannello",
"badge_notification": "Notifica", "badge_notification": "Notifica",
"popover": "Suggerimenti, menù, sbalzi" "popover": "Suggerimenti, menù, sbalzi",
"toggled": "Scambiato",
"chat": {
"border": "Bordo",
"outgoing": "Inviati",
"incoming": "Ricevuti"
}
}, },
"common_colors": { "common_colors": {
"rgbo": "Icone, accenti, medaglie", "rgbo": "Icone, accenti, medaglie",
@ -270,10 +278,59 @@
"shadow_id": "Ombra numero {value}", "shadow_id": "Ombra numero {value}",
"override": "Sostituisci", "override": "Sostituisci",
"component": "Componente", "component": "Componente",
"_tab_label": "Luci ed ombre" "_tab_label": "Luci ed ombre",
"components": {
"avatarStatus": "Icona utente (vista messaggio)",
"avatar": "Icona utente (vista profilo)",
"topBar": "Barra superiore",
"panelHeader": "Intestazione pannello",
"panel": "Pannello",
"input": "Campo d'immissione",
"buttonPressedHover": "Pulsante (puntato e premuto)",
"buttonPressed": "Pulsante (premuto)",
"buttonHover": "Pulsante (puntato)",
"button": "Pulsante",
"popup": "Sbalzi e suggerimenti"
},
"filter_hint": {
"inset_classic": "Le ombre incluse usano {0}",
"spread_zero": "Lo spandimento maggiore di zero si azzera sulle ombre",
"avatar_inset": "Tieni presente che combinare ombre (sia incluse che non) sulle icone utente potrebbe dare risultati strani con quelle trasparenti.",
"drop_shadow_syntax": "{0} non supporta il parametro {1} né la keyword {2}.",
"always_drop_shadow": "Attenzione: quest'ombra usa sempre {0} se il tuo browser lo supporta."
},
"hintV3": "Per le ombre puoi anche usare la sintassi {0} per sfruttare il secondo colore."
}, },
"radii": { "radii": {
"_tab_label": "Raggio" "_tab_label": "Raggio"
},
"fonts": {
"_tab_label": "Font",
"custom": "Personalizzato",
"weight": "Peso (grassettatura)",
"size": "Dimensione (in pixel)",
"family": "Nome font",
"components": {
"postCode": "Font a spaziatura fissa incluso in un messaggio",
"post": "Testo del messaggio",
"input": "Campi d'immissione",
"interface": "Interfaccia"
},
"help": "Seleziona il font da usare per gli elementi dell'interfaccia. Se scegli \"personalizzato\" devi inserire il suo nome di sistema."
},
"preview": {
"link": "un bel collegamentino",
"checkbox": "Ho dato uno sguardo a termini e condizioni",
"header_faint": "Tutto bene",
"fine_print": "Leggi il nostro {0} per imparare un bel niente!",
"faint_link": "utilissimo manuale",
"input": "Sono appena atterrato a Fiumicino.",
"mono": "contenuto",
"text": "Altro {0} e {1}",
"content": "Contenuto",
"button": "Pulsante",
"error": "Errore d'esempio",
"header": "Anteprima"
} }
}, },
"enable_web_push_notifications": "Abilita notifiche web push", "enable_web_push_notifications": "Abilita notifiche web push",
@ -335,7 +392,26 @@
"emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze", "emoji_reactions_on_timeline": "Mostra emoji di reazione sulle sequenze",
"pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore", "pad_emoji": "Affianca spazi agli emoji inseriti tramite selettore",
"notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.", "notification_blocks": "Bloccando un utente non riceverai più le sue notifiche né lo seguirai più.",
"mutes_and_blocks": "Zittiti e bloccati" "mutes_and_blocks": "Zittiti e bloccati",
"profile_fields": {
"value": "Contenuto",
"name": "Etichetta",
"add_field": "Aggiungi campo",
"label": "Metadati profilo"
},
"bot": "Questo profilo è di un robot",
"version": {
"frontend_version": "Versione interfaccia",
"backend_version": "Versione backend",
"title": "Versione"
},
"reset_avatar": "Azzera icona",
"reset_profile_background": "Azzera sfondo profilo",
"reset_profile_banner": "Azzera stendardo profilo",
"reset_avatar_confirm": "Vuoi veramente azzerare l'icona?",
"reset_banner_confirm": "Vuoi veramente azzerare lo stendardo?",
"reset_background_confirm": "Vuoi veramente azzerare lo sfondo?",
"chatMessageRadius": "Messaggi istantanei"
}, },
"timeline": { "timeline": {
"error_fetching": "Errore nell'aggiornamento", "error_fetching": "Errore nell'aggiornamento",
@ -345,7 +421,10 @@
"collapse": "Riduci", "collapse": "Riduci",
"conversation": "Conversazione", "conversation": "Conversazione",
"no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso", "no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso",
"repeated": "condiviso" "repeated": "condiviso",
"no_statuses": "Nessun messaggio",
"no_more_statuses": "Fine dei messaggi",
"reload": "Ricarica"
}, },
"user_card": { "user_card": {
"follow": "Segui", "follow": "Segui",
@ -361,7 +440,47 @@
"block": "Blocca", "block": "Blocca",
"blocked": "Bloccato!", "blocked": "Bloccato!",
"deny": "Nega", "deny": "Nega",
"remote_follow": "Segui da remoto" "remote_follow": "Segui da remoto",
"admin_menu": {
"delete_user_confirmation": "Ne sei completamente sicuro? Quest'azione non può essere annullata.",
"delete_user": "Elimina utente",
"quarantine": "I messaggi non arriveranno alle altre stanze",
"disable_any_subscription": "Rendi utente non seguibile",
"disable_remote_subscription": "Blocca i tentativi di seguirlo da altre stanze",
"sandbox": "Rendi tutti i messaggi solo per seguaci",
"force_unlisted": "Rendi tutti i messaggi invisibili",
"strip_media": "Rimuovi ogni allegato ai messaggi",
"force_nsfw": "Oscura tutti i messaggi",
"delete_account": "Elimina profilo",
"deactivate_account": "Disattiva profilo",
"activate_account": "Attiva profilo",
"revoke_moderator": "Divesti Moderatore",
"grant_moderator": "Crea Moderatore",
"revoke_admin": "Divesti Amministratore",
"grant_admin": "Crea Amministratore",
"moderation": "Moderazione"
},
"show_repeats": "Mostra condivisioni",
"hide_repeats": "Nascondi condivisioni",
"mute_progress": "Zittisco…",
"unmute_progress": "Riabilito…",
"unmute": "Riabilita",
"block_progress": "Blocco…",
"unblock_progress": "Sblocco…",
"unblock": "Sblocca",
"unsubscribe": "Disdici",
"subscribe": "Abbònati",
"report": "Segnala",
"mention": "Menzioni",
"media": "Media",
"its_you": "Sei tu!",
"hidden": "Nascosto",
"follow_unfollow": "Disconosci",
"follow_again": "Reinvio richiesta?",
"follow_progress": "Richiedo…",
"follow_sent": "Richiesta inviata!",
"favorites": "Preferiti",
"message": "Contatta"
}, },
"chat": { "chat": {
"title": "Chat" "title": "Chat"
@ -373,7 +492,8 @@
"scope_options": "Opzioni visibilità", "scope_options": "Opzioni visibilità",
"text_limit": "Lunghezza massima", "text_limit": "Lunghezza massima",
"title": "Caratteristiche", "title": "Caratteristiche",
"who_to_follow": "Chi seguire" "who_to_follow": "Chi seguire",
"pleroma_chat_messages": "Chiacchiere"
}, },
"finder": { "finder": {
"error_fetching_user": "Errore nel recupero dell'utente", "error_fetching_user": "Errore nel recupero dell'utente",
@ -424,7 +544,12 @@
}, },
"direct_warning_to_first_only": "Questo messaggio sarà visibile solo agli utenti menzionati all'inizio.", "direct_warning_to_first_only": "Questo messaggio sarà visibile solo agli utenti menzionati all'inizio.",
"direct_warning_to_all": "Questo messaggio sarà visibile a tutti i menzionati.", "direct_warning_to_all": "Questo messaggio sarà visibile a tutti i menzionati.",
"new_status": "Nuovo messaggio" "new_status": "Nuovo messaggio",
"empty_status_error": "Non puoi pubblicare messaggi vuoti senza allegati",
"preview_empty": "Vuoto",
"preview": "Anteprima",
"media_description_error": "Allegati non caricati, riprova",
"media_description": "Descrizione allegati"
}, },
"registration": { "registration": {
"bio": "Introduzione", "bio": "Introduzione",
@ -448,7 +573,9 @@
"captcha": "CAPTCHA" "captcha": "CAPTCHA"
}, },
"user_profile": { "user_profile": {
"timeline_title": "Sequenza dell'Utente" "timeline_title": "Sequenza dell'Utente",
"profile_loading_error": "Spiacente, c'è stato un errore nel caricamento del profilo.",
"profile_does_not_exist": "Spiacente, questo profilo non esiste."
}, },
"who_to_follow": { "who_to_follow": {
"more": "Altro", "more": "Altro",
@ -547,5 +674,140 @@
"error": "Non trovato.", "error": "Non trovato.",
"searching_for": "Cerco", "searching_for": "Cerco",
"remote_user_resolver": "Cerca utenti remoti" "remote_user_resolver": "Cerca utenti remoti"
},
"errors": {
"storage_unavailable": "Pleroma non ha potuto accedere ai dati del tuo browser. Le tue credenziali o le tue impostazioni locali non potranno essere salvate e potresti incontrare strani errori. Prova ad abilitare i cookie."
},
"status": {
"pinned": "Intestato",
"unpin": "De-intesta",
"pin": "Intesta al profilo",
"delete": "Elimina messaggio",
"repeats": "Condivisi",
"favorites": "Preferiti",
"hide_content": "Nascondi contenuti",
"show_content": "Mostra contenuti",
"hide_full_subject": "Nascondi intero oggetto",
"show_full_subject": "Mostra intero oggetto",
"thread_muted_and_words": ", contiene:",
"thread_muted": "Discussione zittita",
"copy_link": "Copia collegamento",
"status_unavailable": "Messaggio non disponibile",
"unmute_conversation": "Riabilita conversazione",
"mute_conversation": "Zittisci conversazione",
"replies_list": "Risposte:",
"reply_to": "Rispondi a",
"delete_confirm": "Vuoi veramente eliminare questo messaggio?",
"unbookmark": "Rimuovi segnalibro",
"bookmark": "Aggiungi segnalibro"
},
"time": {
"years_short": "{0}a",
"year_short": "{0}a",
"years": "{0} anni",
"year": "{0} anno",
"weeks_short": "{0}set",
"week_short": "{0}set",
"seconds_short": "{0}sec",
"second_short": "{0}sec",
"weeks": "{0} settimane",
"week": "{0} settimana",
"seconds": "{0} secondi",
"second": "{0} secondo",
"now_short": "ora",
"now": "adesso",
"months_short": "{0}me",
"month_short": "{0}me",
"months": "{0} mesi",
"month": "{0} mese",
"minutes_short": "{0}min",
"minute_short": "{0}min",
"minutes": "{0} minuti",
"minute": "{0} minuto",
"in_past": "{0} fa",
"in_future": "fra {0}",
"hours_short": "{0}h",
"days_short": "{0}g",
"hour_short": "{0}h",
"hours": "{0} ore",
"hour": "{0} ora",
"day_short": "{0}g",
"days": "{0} giorni",
"day": "{0} giorno"
},
"user_reporting": {
"title": "Segnalo {0}",
"additional_comments": "Osservazioni accessorie",
"generic_error": "C'è stato un errore nell'elaborazione della tua richiesta.",
"submit": "Invia",
"forward_to": "Inoltra a {0}",
"forward_description": "Il profilo appartiene ad un'altra stanza. Inviare la segnalazione anche a quella?",
"add_comment_description": "La segnalazione sarà inviata ai moderatori della tua stanza. Puoi motivarla qui sotto:"
},
"password_reset": {
"password_reset_required_but_mailer_is_disabled": "Devi reimpostare la tua password, ma non puoi farlo. Contatta il tuo amministratore.",
"password_reset_required": "Devi reimpostare la tua password per poter continuare.",
"password_reset_disabled": "Non puoi azzerare la tua password. Contatta il tuo amministratore.",
"too_many_requests": "Hai raggiunto il numero massimo di tentativi, riprova più tardi.",
"not_found": "Non ho trovato questa email o nome utente.",
"return_home": "Torna alla pagina principale",
"check_email": "Controlla la tua posta elettronica.",
"placeholder": "La tua email o nome utente",
"instruction": "Inserisci il tuo indirizzo email o il tuo nome utente. Ti invieremo un collegamento per reimpostare la tua password.",
"password_reset": "Azzera password",
"forgot_password": "Password dimenticata?"
},
"search": {
"no_results": "Nessun risultato",
"people_talking": "{count} partecipanti",
"person_talking": "{count} partecipante",
"hashtags": "Etichette",
"people": "Utenti"
},
"upload": {
"file_size_units": {
"TiB": "TiB",
"GiB": "GiB",
"MiB": "MiB",
"KiB": "KiB",
"B": "B"
},
"error": {
"default": "Riprova in seguito",
"file_too_big": "File troppo pesante [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"base": "Caricamento fallito."
}
},
"tool_tip": {
"bookmark": "Aggiungi segnalibro",
"reject_follow_request": "Rifiuta seguace",
"accept_follow_request": "Accetta seguace",
"user_settings": "Impostazioni utente",
"add_reaction": "Reagisci",
"favorite": "Gradisci",
"reply": "Rispondi",
"repeat": "Ripeti",
"media_upload": "Carica allegati"
},
"display_date": {
"today": "Oggi"
},
"file_type": {
"file": "File",
"image": "Immagine",
"video": "Video",
"audio": "Audio"
},
"chats": {
"empty_chat_list_placeholder": "Non hai conversazioni. Contatta qualcuno!",
"error_sending_message": "Errore. Il messaggio non è stato inviato.",
"error_loading_chat": "Errore. La conversazione non è stata caricata.",
"delete_confirm": "Vuoi veramente eliminare questo messaggio?",
"more": "Altro",
"empty_message_error": "Non puoi inviare messaggi vuoti",
"new": "Nuova conversazione",
"chats": "Conversazioni",
"delete": "Elimina",
"message_user": "Contatta {nickname}"
} }
} }

View file

@ -28,7 +28,12 @@
"enable": "Inschakelen", "enable": "Inschakelen",
"confirm": "Bevestigen", "confirm": "Bevestigen",
"verify": "Verifiëren", "verify": "Verifiëren",
"generic_error": "Er is een fout opgetreden" "generic_error": "Er is een fout opgetreden",
"peek": "Spiek",
"close": "Sluiten",
"retry": "Opnieuw proberen",
"error_retry": "Probeer het opnieuw",
"loading": "Laden…"
}, },
"login": { "login": {
"login": "Log in", "login": "Log in",
@ -90,7 +95,7 @@
"text/bbcode": "BBCode" "text/bbcode": "BBCode"
}, },
"content_warning": "Onderwerp (optioneel)", "content_warning": "Onderwerp (optioneel)",
"default": "Zojuist geland in L.A.", "default": "Tijd voor anime!",
"direct_warning": "Deze post zal enkel zichtbaar zijn voor de personen die genoemd zijn.", "direct_warning": "Deze post zal enkel zichtbaar zijn voor de personen die genoemd zijn.",
"posting": "Plaatsen", "posting": "Plaatsen",
"scope": { "scope": {
@ -377,7 +382,7 @@
"button": "Knop", "button": "Knop",
"text": "Nog een boel andere {0} en {1}", "text": "Nog een boel andere {0} en {1}",
"mono": "inhoud", "mono": "inhoud",
"input": "Zojuist geland in L.A.", "input": "Tijd voor anime!",
"faint_link": "handige gebruikershandleiding", "faint_link": "handige gebruikershandleiding",
"fine_print": "Lees onze {0} om niets nuttig te leren!", "fine_print": "Lees onze {0} om niets nuttig te leren!",
"header_faint": "Alles komt goed", "header_faint": "Alles komt goed",
@ -451,7 +456,7 @@
"user_mutes": "Gebruikers", "user_mutes": "Gebruikers",
"useStreamingApi": "Berichten en meldingen in real-time ontvangen", "useStreamingApi": "Berichten en meldingen in real-time ontvangen",
"useStreamingApiWarning": "(Afgeraden, experimenteel, kan berichten overslaan)", "useStreamingApiWarning": "(Afgeraden, experimenteel, kan berichten overslaan)",
"type_domains_to_mute": "Voer domeinen in om te negeren", "type_domains_to_mute": "Zoek domeinen om te negeren",
"upload_a_photo": "Upload een foto", "upload_a_photo": "Upload een foto",
"fun": "Plezier", "fun": "Plezier",
"greentext": "Meme pijlen", "greentext": "Meme pijlen",
@ -470,7 +475,15 @@
"frontend_version": "Frontend Versie", "frontend_version": "Frontend Versie",
"backend_version": "Backend Versie", "backend_version": "Backend Versie",
"title": "Versie" "title": "Versie"
} },
"mutes_and_blocks": "Negeringen en Blokkades",
"profile_fields": {
"value": "Inhoud",
"name": "Label",
"add_field": "Veld Toevoegen",
"label": "Profiel metadata"
},
"bot": "Dit is een bot account"
}, },
"timeline": { "timeline": {
"collapse": "Inklappen", "collapse": "Inklappen",
@ -708,7 +721,9 @@
"unpin": "Van profiel losmaken", "unpin": "Van profiel losmaken",
"delete": "Status verwijderen", "delete": "Status verwijderen",
"repeats": "Herhalingen", "repeats": "Herhalingen",
"favorites": "Favorieten" "favorites": "Favorieten",
"thread_muted_and_words": ", heeft woorden:",
"thread_muted": "Thread genegeerd"
}, },
"time": { "time": {
"years_short": "{0}j", "years_short": "{0}j",

View file

@ -45,7 +45,8 @@
"timeline": "Лента", "timeline": "Лента",
"twkn": "Федеративная лента", "twkn": "Федеративная лента",
"search": "Поиск", "search": "Поиск",
"friend_requests": "Запросы на чтение" "friend_requests": "Запросы на чтение",
"bookmarks": "Закладки"
}, },
"notifications": { "notifications": {
"broken_favorite": "Неизвестный статус, ищем...", "broken_favorite": "Неизвестный статус, ищем...",
@ -366,6 +367,10 @@
"show_new": "Показать новые", "show_new": "Показать новые",
"up_to_date": "Обновлено" "up_to_date": "Обновлено"
}, },
"status": {
"bookmark": "В закладки",
"unbookmark": "Удалить из закладок"
},
"user_card": { "user_card": {
"block": "Заблокировать", "block": "Заблокировать",
"blocked": "Заблокирован", "blocked": "Заблокирован",

View file

@ -85,7 +85,7 @@
"administration": "管理员" "administration": "管理员"
}, },
"notifications": { "notifications": {
"broken_favorite": "未知的状态,正在搜索中...", "broken_favorite": "未知的状态,正在搜索中",
"favorited_you": "收藏了你的状态", "favorited_you": "收藏了你的状态",
"followed_you": "关注了你", "followed_you": "关注了你",
"load_older": "加载更早的通知", "load_older": "加载更早的通知",
@ -185,7 +185,7 @@
"generate_new_recovery_codes": "生成新的恢复码", "generate_new_recovery_codes": "生成新的恢复码",
"warning_of_generate_new_codes": "当你生成新的恢复码时,你的旧恢复码就失效了。", "warning_of_generate_new_codes": "当你生成新的恢复码时,你的旧恢复码就失效了。",
"recovery_codes": "恢复码。", "recovery_codes": "恢复码。",
"waiting_a_recovery_codes": "正在接收备份码…", "waiting_a_recovery_codes": "正在接收备份码…",
"recovery_codes_warning": "抄写这些号码,或者保存在安全的地方。这些号码不会再次显示。如果你无法访问你的 2FA app也丢失了你的恢复码你的账号就再也无法登录了。", "recovery_codes_warning": "抄写这些号码,或者保存在安全的地方。这些号码不会再次显示。如果你无法访问你的 2FA app也丢失了你的恢复码你的账号就再也无法登录了。",
"authentication_methods": "身份验证方法", "authentication_methods": "身份验证方法",
"scan": { "scan": {
@ -564,11 +564,11 @@
"subscribe": "订阅", "subscribe": "订阅",
"unsubscribe": "退订", "unsubscribe": "退订",
"unblock": "取消拉黑", "unblock": "取消拉黑",
"unblock_progress": "取消拉黑中...", "unblock_progress": "取消拉黑中",
"block_progress": "拉黑中...", "block_progress": "拉黑中",
"unmute": "取消隐藏", "unmute": "取消隐藏",
"unmute_progress": "取消隐藏中...", "unmute_progress": "取消隐藏中",
"mute_progress": "隐藏中...", "mute_progress": "隐藏中",
"admin_menu": { "admin_menu": {
"moderation": "权限", "moderation": "权限",
"grant_admin": "赋予管理权限", "grant_admin": "赋予管理权限",
@ -690,9 +690,9 @@
} }
}, },
"domain_mute_card": { "domain_mute_card": {
"unmute_progress": "正在取消隐藏…", "unmute_progress": "正在取消隐藏…",
"unmute": "取消隐藏", "unmute": "取消隐藏",
"mute_progress": "隐藏中…", "mute_progress": "隐藏中…",
"mute": "隐藏" "mute": "隐藏"
} }
} }

View file

@ -19,6 +19,7 @@ import oauthTokensModule from './modules/oauth_tokens.js'
import reportsModule from './modules/reports.js' import reportsModule from './modules/reports.js'
import pollsModule from './modules/polls.js' import pollsModule from './modules/polls.js'
import postStatusModule from './modules/postStatus.js' import postStatusModule from './modules/postStatus.js'
import chatsModule from './modules/chats.js'
import VueI18n from 'vue-i18n' import VueI18n from 'vue-i18n'
@ -62,7 +63,15 @@ const persistedStateOptions = {
}; };
(async () => { (async () => {
const persistedState = await createPersistedState(persistedStateOptions) let storageError = false
const plugins = [pushNotifications]
try {
const persistedState = await createPersistedState(persistedStateOptions)
plugins.push(persistedState)
} catch (e) {
console.error(e)
storageError = true
}
const store = new Vuex.Store({ const store = new Vuex.Store({
modules: { modules: {
i18n: { i18n: {
@ -83,13 +92,16 @@ const persistedStateOptions = {
oauthTokens: oauthTokensModule, oauthTokens: oauthTokensModule,
reports: reportsModule, reports: reportsModule,
polls: pollsModule, polls: pollsModule,
postStatus: postStatusModule postStatus: postStatusModule,
chats: chatsModule
}, },
plugins: [persistedState, pushNotifications], plugins,
strict: false // Socket modifies itself, let's ignore this for now. strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production' // strict: process.env.NODE_ENV !== 'production'
}) })
if (storageError) {
store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' })
}
afterStoreSetup({ store, i18n }) afterStoreSetup({ store, i18n })
})() })()

View file

@ -1,4 +1,5 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { WSConnectionStatus } from '../services/api/api.service.js'
import { Socket } from 'phoenix' import { Socket } from 'phoenix'
const api = { const api = {
@ -7,6 +8,7 @@ const api = {
fetchers: {}, fetchers: {},
socket: null, socket: null,
mastoUserSocket: null, mastoUserSocket: null,
mastoUserSocketStatus: null,
followRequests: [] followRequests: []
}, },
mutations: { mutations: {
@ -28,6 +30,9 @@ const api = {
}, },
setFollowRequests (state, value) { setFollowRequests (state, value) {
state.followRequests = value state.followRequests = value
},
setMastoUserSocketStatus (state, value) {
state.mastoUserSocketStatus = value
} }
}, },
actions: { actions: {
@ -47,7 +52,7 @@ const api = {
startMastoUserSocket (store) { startMastoUserSocket (store) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const { state, dispatch, rootState } = store const { state, commit, dispatch, rootState } = store
const timelineData = rootState.statuses.timelines.friends const timelineData = rootState.statuses.timelines.friends
state.mastoUserSocket = state.backendInteractor.startUserSocket({ store }) state.mastoUserSocket = state.backendInteractor.startUserSocket({ store })
state.mastoUserSocket.addEventListener( state.mastoUserSocket.addEventListener(
@ -66,11 +71,22 @@ const api = {
showImmediately: timelineData.visibleStatuses.length === 0, showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends' timeline: 'friends'
}) })
} else if (message.event === 'pleroma:chat_update') {
dispatch('addChatMessages', {
chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage]
})
dispatch('updateChat', { chat: message.chatUpdate })
} }
} }
) )
state.mastoUserSocket.addEventListener('open', () => {
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
})
state.mastoUserSocket.addEventListener('error', ({ detail: error }) => { state.mastoUserSocket.addEventListener('error', ({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error) console.error('Error in MastoAPI websocket:', error)
commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
dispatch('clearOpenedChats')
}) })
state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => { state.mastoUserSocket.addEventListener('close', ({ detail: closeEvent }) => {
const ignoreCodes = new Set([ const ignoreCodes = new Set([
@ -84,8 +100,11 @@ const api = {
console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`) console.warn(`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`)
dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications') dispatch('startFetchingNotifications')
dispatch('startFetchingChats')
dispatch('restartMastoUserSocket') dispatch('restartMastoUserSocket')
} }
commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
dispatch('clearOpenedChats')
}) })
resolve() resolve()
} catch (e) { } catch (e) {
@ -99,12 +118,13 @@ const api = {
return dispatch('startMastoUserSocket').then(() => { return dispatch('startMastoUserSocket').then(() => {
dispatch('stopFetchingTimeline', { timeline: 'friends' }) dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications') dispatch('stopFetchingNotifications')
dispatch('stopFetchingChats')
}) })
}, },
stopMastoUserSocket ({ state, dispatch }) { stopMastoUserSocket ({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' }) dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications') dispatch('startFetchingNotifications')
console.log(state.mastoUserSocket) dispatch('startFetchingChats')
state.mastoUserSocket.close() state.mastoUserSocket.close()
}, },
@ -138,9 +158,6 @@ const api = {
if (!fetcher) return if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher }) store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
}, },
fetchAndUpdateNotifications (store) {
store.state.backendInteractor.fetchAndUpdateNotifications({ store })
},
// Follow requests // Follow requests
startFetchingFollowRequests (store) { startFetchingFollowRequests (store) {

225
src/modules/chats.js Normal file
View file

@ -0,0 +1,225 @@
import Vue from 'vue'
import { find, omitBy, orderBy, sumBy } from 'lodash'
import chatService from '../services/chat_service/chat_service.js'
import { parseChat, parseChatMessage } from '../services/entity_normalizer/entity_normalizer.service.js'
const emptyChatList = () => ({
data: [],
idStore: {}
})
const defaultState = {
chatList: emptyChatList(),
chatListFetcher: null,
openedChats: {},
openedChatMessageServices: {},
fetcher: undefined,
currentChatId: null
}
const getChatById = (state, id) => {
return find(state.chatList.data, { id })
}
const sortedChatList = (state) => {
return orderBy(state.chatList.data, ['updated_at'], ['desc'])
}
const unreadChatCount = (state) => {
return sumBy(state.chatList.data, 'unread')
}
const chats = {
state: { ...defaultState },
getters: {
currentChat: state => state.openedChats[state.currentChatId],
currentChatMessageService: state => state.openedChatMessageServices[state.currentChatId],
findOpenedChatByRecipientId: state => recipientId => find(state.openedChats, c => c.account.id === recipientId),
sortedChatList,
unreadChatCount
},
actions: {
// Chat list
startFetchingChats ({ dispatch, commit }) {
const fetcher = () => {
dispatch('fetchChats', { latest: true })
}
fetcher()
commit('setChatListFetcher', {
fetcher: () => setInterval(() => { fetcher() }, 5000)
})
},
stopFetchingChats ({ commit }) {
commit('setChatListFetcher', { fetcher: undefined })
},
fetchChats ({ dispatch, rootState, commit }, params = {}) {
return rootState.api.backendInteractor.chats()
.then(({ chats }) => {
dispatch('addNewChats', { chats })
return chats
})
},
addNewChats ({ rootState, commit, dispatch, rootGetters }, { chats }) {
commit('addNewChats', { dispatch, chats, rootGetters })
},
updateChat ({ commit }, { chat }) {
commit('updateChat', { chat })
},
// Opened Chats
startFetchingCurrentChat ({ commit, dispatch }, { fetcher }) {
dispatch('setCurrentChatFetcher', { fetcher })
},
setCurrentChatFetcher ({ rootState, commit }, { fetcher }) {
commit('setCurrentChatFetcher', { fetcher })
},
addOpenedChat ({ rootState, commit, dispatch }, { chat }) {
commit('addOpenedChat', { dispatch, chat: parseChat(chat) })
dispatch('addNewUsers', [chat.account])
},
addChatMessages ({ commit }, value) {
commit('addChatMessages', { commit, ...value })
},
resetChatNewMessageCount ({ commit }, value) {
commit('resetChatNewMessageCount', value)
},
clearCurrentChat ({ rootState, commit, dispatch }, value) {
commit('setCurrentChatId', { chatId: undefined })
commit('setCurrentChatFetcher', { fetcher: undefined })
},
readChat ({ rootState, commit, dispatch }, { id, lastReadId }) {
dispatch('resetChatNewMessageCount')
commit('readChat', { id })
rootState.api.backendInteractor.readChat({ id, lastReadId })
},
deleteChatMessage ({ rootState, commit }, value) {
rootState.api.backendInteractor.deleteChatMessage(value)
commit('deleteChatMessage', { commit, ...value })
},
resetChats ({ commit, dispatch }) {
dispatch('clearCurrentChat')
commit('resetChats', { commit })
},
clearOpenedChats ({ rootState, commit, dispatch, rootGetters }) {
commit('clearOpenedChats', { commit })
}
},
mutations: {
setChatListFetcher (state, { commit, fetcher }) {
const prevFetcher = state.chatListFetcher
if (prevFetcher) {
clearInterval(prevFetcher)
}
state.chatListFetcher = fetcher && fetcher()
},
setCurrentChatFetcher (state, { fetcher }) {
const prevFetcher = state.fetcher
if (prevFetcher) {
clearInterval(prevFetcher)
}
state.fetcher = fetcher && fetcher()
},
addOpenedChat (state, { _dispatch, chat }) {
state.currentChatId = chat.id
Vue.set(state.openedChats, chat.id, chat)
if (!state.openedChatMessageServices[chat.id]) {
Vue.set(state.openedChatMessageServices, chat.id, chatService.empty(chat.id))
}
},
setCurrentChatId (state, { chatId }) {
state.currentChatId = chatId
},
addNewChats (state, { _dispatch, chats, _rootGetters }) {
chats.forEach((updatedChat) => {
const chat = getChatById(state, updatedChat.id)
if (chat) {
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
} else {
state.chatList.data.push(updatedChat)
Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
}
})
},
updateChat (state, { _dispatch, chat: updatedChat, _rootGetters }) {
const chat = getChatById(state, updatedChat.id)
if (chat) {
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
}
if (!chat) { state.chatList.data.unshift(updatedChat) }
Vue.set(state.chatList.idStore, updatedChat.id, updatedChat)
},
deleteChat (state, { _dispatch, id, _rootGetters }) {
state.chats.data = state.chats.data.filter(conversation =>
conversation.last_status.id !== id
)
state.chats.idStore = omitBy(state.chats.idStore, conversation => conversation.last_status.id === id)
},
resetChats (state, { commit }) {
state.chatList = emptyChatList()
state.currentChatId = null
commit('setChatListFetcher', { fetcher: undefined })
for (const chatId in state.openedChats) {
chatService.clear(state.openedChatMessageServices[chatId])
Vue.delete(state.openedChats, chatId)
Vue.delete(state.openedChatMessageServices, chatId)
}
},
setChatsLoading (state, { value }) {
state.chats.loading = value
},
addChatMessages (state, { commit, chatId, messages }) {
const chatMessageService = state.openedChatMessageServices[chatId]
if (chatMessageService) {
chatService.add(chatMessageService, { messages: messages.map(parseChatMessage) })
commit('refreshLastMessage', { chatId })
}
},
refreshLastMessage (state, { chatId }) {
const chatMessageService = state.openedChatMessageServices[chatId]
if (chatMessageService) {
const chat = getChatById(state, chatId)
if (chat) {
chat.lastMessage = chatMessageService.lastMessage
if (chatMessageService.lastMessage) {
chat.updated_at = chatMessageService.lastMessage.created_at
}
}
}
},
deleteChatMessage (state, { commit, chatId, messageId }) {
const chatMessageService = state.openedChatMessageServices[chatId]
if (chatMessageService) {
chatService.deleteMessage(chatMessageService, messageId)
commit('refreshLastMessage', { chatId })
}
},
resetChatNewMessageCount (state, _value) {
const chatMessageService = state.openedChatMessageServices[state.currentChatId]
chatService.resetNewMessageCount(chatMessageService)
},
// Used when a connection loss occurs
clearOpenedChats (state) {
const currentChatId = state.currentChatId
for (const chatId in state.openedChats) {
if (currentChatId !== chatId) {
chatService.clear(state.openedChatMessageServices[chatId])
Vue.delete(state.openedChats, chatId)
Vue.delete(state.openedChatMessageServices, chatId)
}
}
},
readChat (state, { id }) {
const chat = getChatById(state, id)
if (chat) {
chat.unread = 0
}
}
}
}
export default chats

View file

@ -46,7 +46,8 @@ export const defaultState = {
repeats: true, repeats: true,
moves: true, moves: true,
emojiReactions: false, emojiReactions: false,
followRequest: true followRequest: true,
chatMention: true
}, },
webPushNotifications: false, webPushNotifications: false,
muteWords: [], muteWords: [],

View file

@ -15,6 +15,8 @@ const defaultState = {
// Stuff from static/config.json // Stuff from static/config.json
alwaysShowSubjectInput: true, alwaysShowSubjectInput: true,
defaultAvatar: '/images/avi.png',
defaultBanner: '/images/banner.png',
background: '/static/aurora_borealis.jpg', background: '/static/aurora_borealis.jpg',
collapseMessageWithSubject: false, collapseMessageWithSubject: false,
disableChat: false, disableChat: false,
@ -53,6 +55,7 @@ const defaultState = {
// Feature-set, apparently, not everything here is reported... // Feature-set, apparently, not everything here is reported...
chatAvailable: false, chatAvailable: false,
pleromaChatMessagesAvailable: false,
gopherAvailable: false, gopherAvailable: false,
mediaProxyAvailable: false, mediaProxyAvailable: false,
suggestionsEnabled: false, suggestionsEnabled: false,

View file

@ -14,7 +14,9 @@ const defaultState = {
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)') window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
) )
}, },
mobileLayout: false mobileLayout: false,
globalNotices: [],
layoutHeight: 0
} }
const interfaceMod = { const interfaceMod = {
@ -58,6 +60,15 @@ const interfaceMod = {
if (!state.settingsModalLoaded) { if (!state.settingsModalLoaded) {
state.settingsModalLoaded = true state.settingsModalLoaded = true
} }
},
pushGlobalNotice (state, notice) {
state.globalNotices.push(notice)
},
removeGlobalNotice (state, notice) {
state.globalNotices = state.globalNotices.filter(n => n !== notice)
},
setLayoutHeight (state, value) {
state.layoutHeight = value
} }
}, },
actions: { actions: {
@ -81,6 +92,31 @@ const interfaceMod = {
}, },
togglePeekSettingsModal ({ commit }) { togglePeekSettingsModal ({ commit }) {
commit('togglePeekSettingsModal') commit('togglePeekSettingsModal')
},
pushGlobalNotice (
{ commit, dispatch },
{
messageKey,
messageArgs = {},
level = 'error',
timeout = 0
}) {
const notice = {
messageKey,
messageArgs,
level
}
if (timeout) {
setTimeout(() => dispatch('removeGlobalNotice', notice), timeout)
}
commit('pushGlobalNotice', notice)
return notice
},
removeGlobalNotice ({ commit }, notice) {
commit('removeGlobalNotice', notice)
},
setLayoutHeight ({ commit }, value) {
commit('setLayoutHeight', value)
} }
} }
} }

View file

@ -22,7 +22,7 @@ const mediaViewer = {
setMedia ({ commit }, attachments) { setMedia ({ commit }, attachments) {
const media = attachments.filter(attachment => { const media = attachments.filter(attachment => {
const type = fileTypeService.fileType(attachment.mimetype) const type = fileTypeService.fileType(attachment.mimetype)
return type === 'image' || type === 'video' return type === 'image' || type === 'video' || type === 'audio'
}) })
commit('setMedia', media) commit('setMedia', media)
}, },

View file

@ -62,7 +62,8 @@ export const defaultState = () => ({
publicAndExternal: emptyTl(), publicAndExternal: emptyTl(),
friends: emptyTl(), friends: emptyTl(),
tag: emptyTl(), tag: emptyTl(),
dms: emptyTl() dms: emptyTl(),
bookmarks: emptyTl()
} }
}) })
@ -163,8 +164,7 @@ const removeStatusFromGlobalStorage = (state, status) => {
} }
} }
const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => {
noIdUpdate = false, userId }) => {
// Sanity check // Sanity check
if (!isArray(statuses)) { if (!isArray(statuses)) {
return false return false
@ -173,8 +173,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
const allStatuses = state.allStatuses const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline] const timelineObject = state.timelines[timeline]
const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0 // Mismatch between API pagination and our internal minId/maxId tracking systems:
const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0 // pagination.maxId is the oldest of the returned statuses when fetching older,
// and pagination.minId is the newest when fetching newer. The names come directly
// from the arguments they're supposed to be passed as for the next fetch.
const minNew = pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0)
const maxNew = pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0)
const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0 const newer = timeline && (maxNew > timelineObject.maxId || timelineObject.maxId === 0) && statuses.length > 0
const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0 const older = timeline && (minNew < timelineObject.minId || timelineObject.minId === 0) && statuses.length > 0
@ -315,7 +320,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
}) })
// Keep the visible statuses sorted // Keep the visible statuses sorted
if (timeline) { if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject) sortTimeline(timelineObject)
} }
} }
@ -463,9 +468,17 @@ export const mutations = {
newStatus.rebloggedBy.push(user) newStatus.rebloggedBy.push(user)
} }
}, },
setBookmarked (state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = value
},
setBookmarkedConfirm (state, { status }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = status.bookmarked
},
setDeleted (state, { status }) { setDeleted (state, { status }) {
const newStatus = state.allStatusesObject[status.id] const newStatus = state.allStatusesObject[status.id]
newStatus.deleted = true if (newStatus) newStatus.deleted = true
}, },
setManyDeleted (state, condition) { setManyDeleted (state, condition) {
Object.values(state.allStatusesObject).forEach(status => { Object.values(state.allStatusesObject).forEach(status => {
@ -508,6 +521,9 @@ export const mutations = {
dismissNotification (state, { id }) { dismissNotification (state, { id }) {
state.notifications.data = state.notifications.data.filter(n => n.id !== id) state.notifications.data = state.notifications.data.filter(n => n.id !== id)
}, },
dismissNotifications (state, { finder }) {
state.notifications.data = state.notifications.data.filter(n => finder)
},
updateNotification (state, { id, updater }) { updateNotification (state, { id, updater }) {
const notification = find(state.notifications.data, n => n.id === id) const notification = find(state.notifications.data, n => n.id === id)
notification && updater(notification) notification && updater(notification)
@ -515,6 +531,11 @@ export const mutations = {
queueFlush (state, { timeline, id }) { queueFlush (state, { timeline, id }) {
state.timelines[timeline].flushMarker = id state.timelines[timeline].flushMarker = id
}, },
queueFlushAll (state) {
Object.keys(state.timelines).forEach((timeline) => {
state.timelines[timeline].flushMarker = state.timelines[timeline].maxId
})
},
addRepeats (state, { id, rebloggedByUsers, currentUser }) { addRepeats (state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id] const newStatus = state.allStatusesObject[id]
newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _) newStatus.rebloggedBy = rebloggedByUsers.filter(_ => _)
@ -585,8 +606,8 @@ export const mutations = {
const statuses = { const statuses = {
state: defaultState(), state: defaultState(),
actions: { actions: {
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) { addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) {
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId }) commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination })
}, },
addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) { addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) {
commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters }) commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters })
@ -661,9 +682,26 @@ const statuses = {
rootState.api.backendInteractor.unretweet({ id: status.id }) rootState.api.backendInteractor.unretweet({ id: status.id })
.then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser })) .then(status => commit('setRetweetedConfirm', { status, user: rootState.users.currentUser }))
}, },
bookmark ({ rootState, commit }, status) {
commit('setBookmarked', { status, value: true })
rootState.api.backendInteractor.bookmarkStatus({ id: status.id })
.then(status => {
commit('setBookmarkedConfirm', { status })
})
},
unbookmark ({ rootState, commit }, status) {
commit('setBookmarked', { status, value: false })
rootState.api.backendInteractor.unbookmarkStatus({ id: status.id })
.then(status => {
commit('setBookmarkedConfirm', { status })
})
},
queueFlush ({ rootState, commit }, { timeline, id }) { queueFlush ({ rootState, commit }, { timeline, id }) {
commit('queueFlush', { timeline, id }) commit('queueFlush', { timeline, id })
}, },
queueFlushAll ({ rootState, commit }) {
commit('queueFlushAll')
},
markNotificationsAsSeen ({ rootState, commit }) { markNotificationsAsSeen ({ rootState, commit }) {
commit('markNotificationsAsSeen') commit('markNotificationsAsSeen')
apiService.markNotificationsAsSeen({ apiService.markNotificationsAsSeen({

View file

@ -1,6 +1,6 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import oauthApi from '../services/new_api/oauth.js' import oauthApi from '../services/new_api/oauth.js'
import { compact, map, each, merge, last, concat, uniq } from 'lodash' import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
import { set } from 'vue' import { set } from 'vue'
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
@ -10,7 +10,7 @@ export const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id] const oldItem = obj[item.id]
if (oldItem) { if (oldItem) {
// We already have this, so only merge the new info. // We already have this, so only merge the new info.
merge(oldItem, item) mergeWith(oldItem, item, mergeArrayLength)
return { item: oldItem, new: false } return { item: oldItem, new: false }
} else { } else {
// This is a new item, prepare it // This is a new item, prepare it
@ -23,6 +23,13 @@ export const mergeOrAdd = (arr, obj, item) => {
} }
} }
const mergeArrayLength = (oldValue, newValue) => {
if (isArray(oldValue) && isArray(newValue)) {
oldValue.length = newValue.length
return mergeWith(oldValue, newValue, mergeArrayLength)
}
}
const getNotificationPermission = () => { const getNotificationPermission = () => {
const Notification = window.Notification const Notification = window.Notification
@ -116,7 +123,7 @@ export const mutations = {
}, },
setCurrentUser (state, user) { setCurrentUser (state, user) {
state.lastLoginName = user.screen_name state.lastLoginName = user.screen_name
state.currentUser = merge(state.currentUser || {}, user) state.currentUser = mergeWith(state.currentUser || {}, user, mergeArrayLength)
}, },
clearCurrentUser (state) { clearCurrentUser (state) {
state.currentUser = false state.currentUser = false
@ -259,6 +266,11 @@ const users = {
mutations, mutations,
getters, getters,
actions: { actions: {
fetchUserIfMissing (store, id) {
if (!store.getters.findUser(id)) {
store.dispatch('fetchUser', id)
}
},
fetchUser (store, id) { fetchUser (store, id) {
return store.rootState.api.backendInteractor.fetchUser({ id }) return store.rootState.api.backendInteractor.fetchUser({ id })
.then((user) => { .then((user) => {
@ -486,6 +498,7 @@ const users = {
store.dispatch('stopFetchingFollowRequests') store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications') store.commit('clearNotifications')
store.commit('resetStatuses') store.commit('resetStatuses')
store.dispatch('resetChats')
}) })
}, },
loginUser (store, accessToken) { loginUser (store, accessToken) {
@ -525,6 +538,9 @@ const users = {
// Start fetching notifications // Start fetching notifications
store.dispatch('startFetchingNotifications') store.dispatch('startFetchingNotifications')
// Start fetching chats
store.dispatch('startFetchingChats')
} }
if (store.getters.mergedConfig.useStreamingApi) { if (store.getters.mergedConfig.useStreamingApi) {
@ -532,6 +548,7 @@ const users = {
console.error('Failed initializing MastoAPI Streaming socket', error) console.error('Failed initializing MastoAPI Streaming socket', error)
startPolling() startPolling()
}).then(() => { }).then(() => {
store.dispatch('fetchChats', { latest: true })
setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000) setTimeout(() => store.dispatch('setNotificationsSilence', false), 10000)
}) })
} else { } else {

View file

@ -1,5 +1,5 @@
import { each, map, concat, last, get } from 'lodash' import { each, map, concat, last, get } from 'lodash'
import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js' import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors' import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */ /* eslint-env browser */
@ -50,6 +50,7 @@ const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships' const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses` const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}` const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/' const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/' const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block` const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
@ -58,6 +59,8 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute` const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe` const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe` const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark`
const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses' const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media' const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes` const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
@ -78,6 +81,11 @@ const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions` const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}` const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats`
const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
const oldfetch = window.fetch const oldfetch = window.fetch
@ -138,20 +146,11 @@ const updateNotificationSettings = ({ credentials, settings }) => {
}).then((data) => data.json()) }).then((data) => data.json())
} }
const updateAvatar = ({ credentials, avatar }) => { const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => {
const form = new FormData() const form = new FormData()
form.append('avatar', avatar) if (avatar !== null) form.append('avatar', avatar)
return fetch(MASTODON_PROFILE_UPDATE_URL, { if (banner !== null) form.append('header', banner)
headers: authHeaders(credentials), if (background !== null) form.append('pleroma_background_image', background)
method: 'PATCH',
body: form
}).then((data) => data.json())
.then((data) => parseUser(data))
}
const updateBg = ({ credentials, background }) => {
const form = new FormData()
form.append('pleroma_background_image', background)
return fetch(MASTODON_PROFILE_UPDATE_URL, { return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'PATCH', method: 'PATCH',
@ -161,17 +160,6 @@ const updateBg = ({ credentials, background }) => {
.then((data) => parseUser(data)) .then((data) => parseUser(data))
} }
const updateBanner = ({ credentials, banner }) => {
const form = new FormData()
form.append('header', banner)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
body: form
}).then((data) => data.json())
.then((data) => parseUser(data))
}
const updateProfile = ({ credentials, params }) => { const updateProfile = ({ credentials, params }) => {
return promisedRequest({ return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL, url: MASTODON_PROFILE_UPDATE_URL,
@ -498,7 +486,8 @@ const fetchTimeline = ({
until = false, until = false,
userId = false, userId = false,
tag = false, tag = false,
withMuted = false withMuted = false,
replyVisibility = 'all'
}) => { }) => {
const timelineUrls = { const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE, public: MASTODON_PUBLIC_TIMELINE,
@ -509,7 +498,8 @@ const fetchTimeline = ({
user: MASTODON_USER_TIMELINE_URL, user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL, media: MASTODON_USER_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL, favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
tag: MASTODON_TAG_TIMELINE_URL tag: MASTODON_TAG_TIMELINE_URL,
bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
} }
const isNotifications = timeline === 'notifications' const isNotifications = timeline === 'notifications'
const params = [] const params = []
@ -538,9 +528,12 @@ const fetchTimeline = ({
if (timeline === 'public' || timeline === 'publicAndExternal') { if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false]) params.push(['only_media', false])
} }
if (timeline !== 'favorites') { if (timeline !== 'favorites' && timeline !== 'bookmarks') {
params.push(['with_muted', withMuted]) params.push(['with_muted', withMuted])
} }
if (replyVisibility !== 'all') {
params.push(['reply_visibility', replyVisibility])
}
params.push(['limit', 20]) params.push(['limit', 20])
@ -548,16 +541,20 @@ const fetchTimeline = ({
url += `?${queryString}` url += `?${queryString}`
let status = '' let status = ''
let statusText = '' let statusText = ''
let pagination = {}
return fetch(url, { headers: authHeaders(credentials) }) return fetch(url, { headers: authHeaders(credentials) })
.then((data) => { .then((data) => {
status = data.status status = data.status
statusText = data.statusText statusText = data.statusText
pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
flakeId: timeline !== 'bookmarks' && timeline !== 'notifications'
})
return data return data
}) })
.then((data) => data.json()) .then((data) => data.json())
.then((data) => { .then((data) => {
if (!data.error) { if (!data.error) {
return data.map(isNotifications ? parseNotification : parseStatus) return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination }
} else { } else {
data.status = status data.status = status
data.statusText = statusText data.statusText = statusText
@ -608,6 +605,22 @@ const unretweet = ({ id, credentials }) => {
.then((data) => parseStatus(data)) .then((data) => parseStatus(data))
} }
const bookmarkStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_BOOKMARK_STATUS_URL(id),
headers: authHeaders(credentials),
method: 'POST'
})
}
const unbookmarkStatus = ({ id, credentials }) => {
return promisedRequest({
url: MASTODON_UNBOOKMARK_STATUS_URL(id),
headers: authHeaders(credentials),
method: 'POST'
})
}
const postStatus = ({ const postStatus = ({
credentials, credentials,
status, status,
@ -617,7 +630,8 @@ const postStatus = ({
poll, poll,
mediaIds = [], mediaIds = [],
inReplyToStatusId, inReplyToStatusId,
contentType contentType,
preview
}) => { }) => {
const form = new FormData() const form = new FormData()
const pollOptions = poll.options || [] const pollOptions = poll.options || []
@ -647,6 +661,9 @@ const postStatus = ({
if (inReplyToStatusId) { if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId) form.append('in_reply_to_id', inReplyToStatusId)
} }
if (preview) {
form.append('preview', 'true')
}
return fetch(MASTODON_POST_STATUS_URL, { return fetch(MASTODON_POST_STATUS_URL, {
body: form, body: form,
@ -654,13 +671,7 @@ const postStatus = ({
headers: authHeaders(credentials) headers: authHeaders(credentials)
}) })
.then((response) => { .then((response) => {
if (response.ok) { return response.json()
return response.json()
} else {
return {
error: response
}
}
}) })
.then((data) => data.error ? data : parseStatus(data)) .then((data) => data.error ? data : parseStatus(data))
} }
@ -682,6 +693,17 @@ const uploadMedia = ({ formData, credentials }) => {
.then((data) => parseAttachment(data)) .then((data) => parseAttachment(data))
} }
const setMediaDescription = ({ id, description, credentials }) => {
return promisedRequest({
url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
method: 'PUT',
headers: authHeaders(credentials),
payload: {
description
}
}).then((data) => parseAttachment(data))
}
const importBlocks = ({ file, credentials }) => { const importBlocks = ({ file, credentials }) => {
const formData = new FormData() const formData = new FormData()
formData.append('list', file) formData.append('list', file)
@ -1050,6 +1072,10 @@ const MASTODON_STREAMING_EVENTS = new Set([
'filters_changed' 'filters_changed'
]) ])
const PLEROMA_STREAMING_EVENTS = new Set([
'pleroma:chat_update'
])
// A thin wrapper around WebSocket API that allows adding a pre-processor to it // A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events // Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({ export const ProcessedWS = ({
@ -1106,7 +1132,7 @@ export const handleMastoWS = (wsEvent) => {
if (!data) return if (!data) return
const parsedEvent = JSON.parse(data) const parsedEvent = JSON.parse(data)
const { event, payload } = parsedEvent const { event, payload } = parsedEvent
if (MASTODON_STREAMING_EVENTS.has(event)) { if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) {
// MastoBE and PleromaBE both send payload for delete as a PLAIN string // MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') { if (event === 'delete') {
return { event, id: payload } return { event, id: payload }
@ -1116,6 +1142,8 @@ export const handleMastoWS = (wsEvent) => {
return { event, status: parseStatus(data) } return { event, status: parseStatus(data) }
} else if (event === 'notification') { } else if (event === 'notification') {
return { event, notification: parseNotification(data) } return { event, notification: parseNotification(data) }
} else if (event === 'pleroma:chat_update') {
return { event, chatUpdate: parseChat(data) }
} }
} else { } else {
console.warn('Unknown event', wsEvent) console.warn('Unknown event', wsEvent)
@ -1123,6 +1151,81 @@ export const handleMastoWS = (wsEvent) => {
} }
} }
export const WSConnectionStatus = Object.freeze({
'JOINED': 1,
'CLOSED': 2,
'ERROR': 3
})
const chats = ({ credentials }) => {
return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) })
.then((data) => data.json())
.then((data) => {
return { chats: data.map(parseChat).filter(c => c) }
})
}
const getOrCreateChat = ({ accountId, credentials }) => {
return promisedRequest({
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials
})
}
const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
let url = PLEROMA_CHAT_MESSAGES_URL(id)
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
limit && `limit=${limit}`
].filter(_ => _).join('&')
url = url + (args ? '?' + args : '')
return promisedRequest({
url,
method: 'GET',
credentials
})
}
const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
const payload = {
'content': content
}
if (mediaId) {
payload['media_id'] = mediaId
}
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST',
payload: payload,
credentials
})
}
const readChat = ({ id, lastReadId, credentials }) => {
return promisedRequest({
url: PLEROMA_CHAT_READ_URL(id),
method: 'POST',
payload: {
'last_read_id': lastReadId
},
credentials
})
}
const deleteChatMessage = ({ chatId, messageId, credentials }) => {
return promisedRequest({
url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
method: 'DELETE',
credentials
})
}
const apiService = { const apiService = {
verifyCredentials, verifyCredentials,
fetchTimeline, fetchTimeline,
@ -1146,9 +1249,12 @@ const apiService = {
unfavorite, unfavorite,
retweet, retweet,
unretweet, unretweet,
bookmarkStatus,
unbookmarkStatus,
postStatus, postStatus,
deleteStatus, deleteStatus,
uploadMedia, uploadMedia,
setMediaDescription,
fetchMutes, fetchMutes,
muteUser, muteUser,
unmuteUser, unmuteUser,
@ -1166,10 +1272,8 @@ const apiService = {
deactivateUser, deactivateUser,
register, register,
getCaptcha, getCaptcha,
updateAvatar, updateProfileImages,
updateBg,
updateProfile, updateProfile,
updateBanner,
importBlocks, importBlocks,
importFollows, importFollows,
deleteAccount, deleteAccount,
@ -1200,7 +1304,13 @@ const apiService = {
fetchKnownDomains, fetchKnownDomains,
fetchDomainMutes, fetchDomainMutes,
muteDomain, muteDomain,
unmuteDomain unmuteDomain,
chats,
getOrCreateChat,
chatMessages,
sendChatMessage,
readChat,
deleteChatMessage
} }
export default apiService export default apiService

View file

@ -12,10 +12,6 @@ const backendInteractorService = credentials => ({
return notificationsFetcher.startFetching({ store, credentials }) return notificationsFetcher.startFetching({ store, credentials })
}, },
fetchAndUpdateNotifications ({ store }) {
return notificationsFetcher.fetchAndUpdate({ store, credentials })
},
startFetchingFollowRequests ({ store }) { startFetchingFollowRequests ({ store }) {
return followRequestFetcher.startFetching({ store, credentials }) return followRequestFetcher.startFetching({ store, credentials })
}, },

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