Merge remote-tracking branch 'origin/develop' into shigusegubu

* origin/develop: (41 commits)
  Apply suggestion to src/components/notifications/notifications.js
  fix height for emoji panel of settings modal
  Add bookmarks
  FE part of BE issue 1586 provide index md
  change alert popup alpha
  add follow request users to store
  make the addNotice dispatch return the notice
  change storage error one-off into a global notice system
  remove accidental log
  revert accidental change in instance.js
  update the message and changelog
  catch localforage error and let the application work, add an alert for user to dismiss
  document the 'mark-as-read-detection' system
  allow overscrolling enough to not have FAB block interactables
  add initial fetching back in a more streamlined way
  remove unnecessary fetchAndUpdate, change notifications fetcher to not double fetch
  change Show New text to Reload when flushing
  Make use of backend reply filtering
  update changelog for reply-to strikethrough
  add no-statusId support for status popover
  ...
This commit is contained in:
Henry Jameson 2020-07-05 15:46:34 +03:00
commit 7d81d11338
52 changed files with 824 additions and 193 deletions

View file

@ -17,6 +17,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Added private notifications option for push notifications - Added private notifications option for push notifications
- '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
- Added profile meta data fields that can be set in profile settings
- 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
@ -35,6 +38,8 @@ 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
- 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
@ -96,6 +101,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,6 +13,7 @@ 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 GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { windowWidth } from './services/window_utils/window_utils' import { windowWidth } from './services/window_utils/window_utils'
export default { export default {
@ -32,7 +33,8 @@ export default {
MobileNav, MobileNav,
SettingsModal, SettingsModal,
UserReportingModal, UserReportingModal,
PostStatusModal PostStatusModal,
GlobalNoticeList
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline', mobileActivePanel: 'timeline',

View file

@ -858,6 +858,10 @@ nav {
display: block; display: block;
margin-right: 0.8em; margin-right: 0.8em;
} }
.main {
margin-bottom: 7em;
}
} }
.select-multiple { .select-multiple {

View file

@ -128,6 +128,7 @@
<PostStatusModal /> <PostStatusModal />
<SettingsModal /> <SettingsModal />
<portal-target name="modal" /> <portal-target name="modal" />
<GlobalNoticeList />
</div> </div>
</template> </template>

View file

@ -8,38 +8,64 @@ 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 preloadFetch = async (request) => {
const data = parsedInitialResults()
if (!data || !data[request]) {
return window.fetch(request)
}
const requestData = atob(data[request])
return {
ok: true,
json: () => JSON.parse(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 +158,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 })
@ -195,18 +221,28 @@ const resolveStaffAccounts = ({ store, accounts }) => {
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: '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 +293,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 +316,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 +340,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,6 +2,7 @@ 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'
@ -40,6 +41,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([^/@]+)',

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>

View file

@ -431,6 +431,7 @@ const EmojiInput = {
const offsetBottom = offsetTop + offsetHeight const offsetBottom = offsetTop + offsetHeight
panel.style.top = offsetBottom + 'px' panel.style.top = offsetBottom + 'px'
if (!picker) return
picker.$el.style.top = offsetBottom + 'px' picker.$el.style.top = offsetBottom + 'px'
picker.$el.style.bottom = 'auto' picker.$el.style.bottom = 'auto'
} }

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

@ -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

@ -17,6 +17,11 @@
<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 && 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

@ -27,6 +27,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'
@ -56,11 +61,6 @@ const Notifications = {
components: { components: {
Notification Notification
}, },
created () {
const { dispatch } = this.$store
dispatch('fetchAndUpdateNotifications')
},
watch: { watch: {
unseenCount (count) { unseenCount (count) {
if (count > 0) { if (count > 0) {

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,
@ -23,6 +25,7 @@ const ProfileTab = {
showRole: this.$store.state.users.currentUser.show_role, showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role, role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable, discoverable: this.$store.state.users.currentUser.discoverable,
bot: this.$store.state.users.currentUser.bot,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move, allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true, pickAvatarBtnVisible: true,
bannerUploading: false, bannerUploading: false,
@ -62,6 +65,18 @@ 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
} }
}, },
methods: { methods: {
@ -74,17 +89,21 @@ 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,
hide_followers: this.hideFollowers, hide_followers: this.hideFollowers,
discoverable: this.discoverable, discoverable: this.discoverable,
bot: this.bot,
allow_following_move: this.allowFollowingMove, allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount, hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount, hide_followers_count: this.hideFollowersCount,
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)
}) })
@ -92,6 +111,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 }

View file

@ -79,4 +79,21 @@
.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;
}
&>.icon-container {
width: 20px;
&>.icon-cancel {
vertical-align: sub;
}
}
}
} }

View file

@ -95,6 +95,59 @@
{{ $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>
<Checkbox v-model="bot">
{{ $t('settings.bot') }}
</Checkbox>
</p>
<button <button
:disabled="newName && newName.length === 0" :disabled="newName && newName.length === 0"
class="btn btn-default" class="btn btn-default"

View file

@ -65,6 +65,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"

View file

@ -142,7 +142,7 @@ const Status = {
return this.mergedConfig.hideFilteredStatuses return this.mergedConfig.hideFilteredStatuses
}, },
hideStatus () { hideStatus () {
return (this.hideReply || this.deleted) || (this.muted && this.hideFilteredStatuses) || this.virtualHidden return this.deleted || (this.muted && this.hideFilteredStatuses)
}, },
isFocused () { isFocused () {
// retweet or root of an expanded conversation // retweet or root of an expanded conversation
@ -165,37 +165,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
@ -526,6 +531,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

@ -44,14 +44,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
@ -142,12 +142,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,18 +3,57 @@
<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("status.show_full_subject") }}
</a>
</div>
<div
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
<a
v-if="hideTallStatus"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': focused }"
href="#"
@click.prevent="toggleShowMore"
> >
{{ $t("general.show_more") }} {{ $t("general.show_more") }}
</a>
<div
v-if="!hideSubjectStatus"
class="status-content media-body"
@click.prevent="linkClicked"
v-html="postBodyHtml"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
>
{{ $t("status.show_content") }}
<span <span
v-if="hasImageAttachments" v-if="hasImageAttachments"
class="icon-picture" class="icon-picture"
@ -28,54 +67,14 @@
class="icon-link" 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
v-else
:class="{'tall-status': hideTallStatus}"
class="status-content-wrapper"
>
<a
v-if="hideTallStatus"
class="tall-status-hider"
:class="{ 'tall-status-hider_focused': focused }"
href="#"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<div
v-if="!hideSubjectStatus"
class="status-content media-body"
@click.prevent="linkClicked"
v-html="contentHtml"
/>
<div
v-else
class="status-content media-body"
@click.prevent="linkClicked"
v-html="status.summary_html"
/>
<a
v-if="hideSubjectStatus"
href="#"
class="cw-status-hider"
@click.prevent="toggleShowMore"
>{{ $t("general.show_more") }}</a>
<a <a
v-if="showingMore" v-if="showingMore"
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 +128,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 +141,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,6 +181,38 @@ $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;

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

@ -46,11 +46,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 () {
@ -123,8 +127,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 })
@ -146,7 +148,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

@ -70,10 +70,20 @@
> >
@{{ user.screen_name }} @{{ user.screen_name }}
</router-link> </router-link>
<span <template v-if="!hideBio">
v-if="!hideBio && !!visibleRole" <span
class="alert staff" v-if="!!visibleRole"
>{{ visibleRole }}</span> class="alert user-role"
>
{{ visibleRole }}
</span>
<span
v-if="user.bot"
class="alert user-role"
>
bot
</span>
</template>
<span v-if="user.locked"><i class="icon icon-lock" /></span> <span v-if="user.locked"><i class="icon icon-lock" /></span>
<span <span
v-if="!mergedConfig.hideUserStats && !hideBio" v-if="!mergedConfig.hideUserStats && !hideBio"
@ -458,7 +468,7 @@
color: var(--text, $fallback--text); color: var(--text, $fallback--text);
} }
.staff { .user-role {
flex: none; flex: none;
text-transform: capitalize; text-transform: capitalize;
color: $fallback--text; color: $fallback--text;

View file

@ -120,6 +120,7 @@
"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",
@ -163,6 +164,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",
@ -266,6 +270,7 @@
"block_import_error": "Error importing blocks", "block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.", "blocks_imported": "Blocks imported! Processing them will take a while.",
"blocks_tab": "Blocks", "blocks_tab": "Blocks",
"bot": "This is a bot account",
"btnRadius": "Buttons", "btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)", "cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)", "cGreen": "Green (Retweet)",
@ -333,6 +338,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",
@ -611,6 +622,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"
@ -622,6 +634,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:",
@ -630,7 +644,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",
@ -713,7 +731,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": {

View file

@ -255,7 +255,8 @@
"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"
}, },
"common_colors": { "common_colors": {
"rgbo": "Icone, accenti, medaglie", "rgbo": "Icone, accenti, medaglie",

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": "Неизвестный статус, ищем...",
@ -130,6 +131,7 @@
"background": "Фон", "background": "Фон",
"bio": "Описание", "bio": "Описание",
"btnRadius": "Кнопки", "btnRadius": "Кнопки",
"bot": "Это аккаунт бота",
"cBlue": "Ответить, читать", "cBlue": "Ответить, читать",
"cGreen": "Повторить", "cGreen": "Повторить",
"cOrange": "Нравится", "cOrange": "Нравится",
@ -365,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

@ -62,7 +62,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: {
@ -85,11 +93,13 @@ const persistedStateOptions = {
polls: pollsModule, polls: pollsModule,
postStatus: postStatusModule postStatus: postStatusModule
}, },
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

@ -138,9 +138,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) {

View file

@ -14,7 +14,8 @@ 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: []
} }
const interfaceMod = { const interfaceMod = {
@ -58,6 +59,12 @@ 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)
} }
}, },
actions: { actions: {
@ -81,6 +88,28 @@ 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)
} }
} }
} }

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,6 +468,14 @@ 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 newStatus.deleted = true
@ -515,6 +528,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 +603,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 +679,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

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, 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`
@ -498,7 +501,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 +513,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 +543,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 +556,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 +620,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,
@ -1146,6 +1174,8 @@ const apiService = {
unfavorite, unfavorite,
retweet, retweet,
unretweet, unretweet,
bookmarkStatus,
unbookmarkStatus,
postStatus, postStatus,
deleteStatus, deleteStatus,
uploadMedia, uploadMedia,

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 })
}, },

View file

@ -1,4 +1,5 @@
import escape from 'escape-html' import escape from 'escape-html'
import parseLinkHeader from 'parse-link-header'
import { isStatusNotification } from '../notification_utils/notification_utils.js' import { isStatusNotification } from '../notification_utils/notification_utils.js'
const qvitterStatusType = (status) => { const qvitterStatusType = (status) => {
@ -232,6 +233,8 @@ export const parseStatus = (data) => {
output.repeated = data.reblogged output.repeated = data.reblogged
output.repeat_num = data.reblogs_count output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
output.type = data.reblog ? 'retweet' : 'status' output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive output.nsfw = data.sensitive
@ -248,6 +251,7 @@ export const parseStatus = (data) => {
output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct output.in_reply_to_screen_name = data.pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
} else { } else {
output.text = data.content output.text = data.content
output.summary = data.spoiler_text output.summary = data.spoiler_text
@ -381,3 +385,16 @@ const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i const nsfwRegex = /#nsfw/i
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex) return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
} }
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
const flakeId = opts.flakeId
const parsedLinkHeader = parseLinkHeader(linkHeader)
if (!parsedLinkHeader) return
const maxId = parsedLinkHeader.next.max_id
const minId = parsedLinkHeader.prev.min_id
return {
maxId: flakeId ? maxId : parseInt(maxId, 10),
minId: flakeId ? minId : parseInt(minId, 10)
}
}

View file

@ -4,6 +4,7 @@ const fetchAndUpdate = ({ store, credentials }) => {
return apiService.fetchFollowRequests({ credentials }) return apiService.fetchFollowRequests({ credentials })
.then((requests) => { .then((requests) => {
store.commit('setFollowRequests', requests) store.commit('setFollowRequests', requests)
store.commit('addNewUsers', requests)
}, () => {}) }, () => {})
.catch(() => {}) .catch(() => {})
} }

View file

@ -27,21 +27,25 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => {
} }
const result = fetchNotifications({ store, args, older }) const result = fetchNotifications({ store, args, older })
// load unread notifications repeatedly to provide consistency between browser tabs // If there's any unread notifications, try fetch notifications since
// the newest read notification to check if any of the unread notifs
// have changed their 'seen' state (marked as read in another session), so
// we can update the state in this session to mark them as read as well.
// The normal maxId-check does not tell if older notifications have changed
const notifications = timelineData.data const notifications = timelineData.data
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id) const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id)
if (readNotifsIds.length) { const numUnseenNotifs = notifications.length - readNotifsIds.length
if (numUnseenNotifs > 0) {
args['since'] = Math.max(...readNotifsIds) args['since'] = Math.max(...readNotifsIds)
fetchNotifications({ store, args, older }) fetchNotifications({ store, args, older })
} }
return result return result
} }
} }
const fetchNotifications = ({ store, args, older }) => { const fetchNotifications = ({ store, args, older }) => {
return apiService.fetchTimeline(args) return apiService.fetchTimeline(args)
.then((notifications) => { .then(({ data: notifications }) => {
update({ store, notifications, older }) update({ store, notifications, older })
return notifications return notifications
}, () => store.dispatch('setNotificationsError', { value: true })) }, () => store.dispatch('setNotificationsError', { value: true }))

View file

@ -34,7 +34,8 @@ export const DEFAULT_OPACITY = {
alert: 0.5, alert: 0.5,
input: 0.5, input: 0.5,
faint: 0.5, faint: 0.5,
underlay: 0.15 underlay: 0.15,
alertPopup: 0.95
} }
/** SUBJECT TO CHANGE IN THE FUTURE, this is all beta /** SUBJECT TO CHANGE IN THE FUTURE, this is all beta
@ -627,6 +628,39 @@ export const SLOT_INHERITANCE = {
textColor: true textColor: true
}, },
alertPopupError: {
depends: ['alertError'],
opacity: 'alertPopup'
},
alertPopupErrorText: {
depends: ['alertErrorText'],
layer: 'popover',
variant: 'alertPopupError',
textColor: true
},
alertPopupWarning: {
depends: ['alertWarning'],
opacity: 'alertPopup'
},
alertPopupWarningText: {
depends: ['alertWarningText'],
layer: 'popover',
variant: 'alertPopupWarning',
textColor: true
},
alertPopupNeutral: {
depends: ['alertNeutral'],
opacity: 'alertPopup'
},
alertPopupNeutralText: {
depends: ['alertNeutralText'],
layer: 'popover',
variant: 'alertPopupNeutral',
textColor: true
},
badgeNotification: '--cRed', badgeNotification: '--cRed',
badgeNotificationText: { badgeNotificationText: {
depends: ['text', 'badgeNotification'], depends: ['text', 'badgeNotification'],

View file

@ -2,7 +2,7 @@ import { camelCase } from 'lodash'
import apiService from '../api/api.service.js' import apiService from '../api/api.service.js'
const update = ({ store, statuses, timeline, showImmediately, userId }) => { const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => {
const ccTimeline = camelCase(timeline) const ccTimeline = camelCase(timeline)
store.dispatch('setError', { value: false }) store.dispatch('setError', { value: false })
@ -12,7 +12,8 @@ const update = ({ store, statuses, timeline, showImmediately, userId }) => {
timeline: ccTimeline, timeline: ccTimeline,
userId, userId,
statuses, statuses,
showImmediately showImmediately,
pagination
}) })
} }
@ -30,7 +31,8 @@ const fetchAndUpdate = ({
const rootState = store.rootState || store.state const rootState = store.rootState || store.state
const { getters } = store const { getters } = store
const timelineData = rootState.statuses.timelines[camelCase(timeline)] const timelineData = rootState.statuses.timelines[camelCase(timeline)]
const hideMutedPosts = getters.mergedConfig.hideMutedPosts const { hideMutedPosts, replyVisibility } = getters.mergedConfig
const loggedIn = !!rootState.users.currentUser
if (older) { if (older) {
args['until'] = until || timelineData.minId args['until'] = until || timelineData.minId
@ -41,20 +43,23 @@ const fetchAndUpdate = ({
args['userId'] = userId args['userId'] = userId
args['tag'] = tag args['tag'] = tag
args['withMuted'] = !hideMutedPosts args['withMuted'] = !hideMutedPosts
if (loggedIn) args['replyVisibility'] = replyVisibility
const numStatusesBeforeFetch = timelineData.statuses.length const numStatusesBeforeFetch = timelineData.statuses.length
return apiService.fetchTimeline(args) return apiService.fetchTimeline(args)
.then((statuses) => { .then(response => {
if (statuses.error) { if (response.error) {
store.dispatch('setErrorData', { value: statuses }) store.dispatch('setErrorData', { value: response })
return return
} }
const { data: statuses, pagination } = response
if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) { if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) {
store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId }) store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId })
} }
update({ store, statuses, timeline, showImmediately, userId }) update({ store, statuses, timeline, showImmediately, userId, pagination })
return statuses return { statuses, pagination }
}, () => store.dispatch('setError', { value: true })) }, () => store.dispatch('setError', { value: true }))
} }

12
static/fontello.json Executable file → Normal file
View file

@ -375,6 +375,18 @@
"css": "download", "css": "download",
"code": 59429, "code": 59429,
"src": "fontawesome" "src": "fontawesome"
},
{
"uid": "f04a5d24e9e659145b966739c4fde82a",
"css": "bookmark",
"code": 59430,
"src": "fontawesome"
},
{
"uid": "2f5ef6f6b7aaebc56458ab4e865beff5",
"css": "bookmark-empty",
"code": 61591,
"src": "fontawesome"
} }
] ]
} }

View file

@ -18,6 +18,42 @@ describe('The users module', () => {
expect(state.users).to.eql([user]) expect(state.users).to.eql([user])
expect(state.users[0].name).to.eql('Dude') expect(state.users[0].name).to.eql('Dude')
}) })
it('merging array field in new information for old users', () => {
const state = cloneDeep(defaultState)
const user = {
id: '1',
fields: [
{ name: 'Label 1', value: 'Content 1' }
]
}
const firstModUser = {
id: '1',
fields: [
{ name: 'Label 2', value: 'Content 2' },
{ name: 'Label 3', value: 'Content 3' }
]
}
const secondModUser = {
id: '1',
fields: [
{ name: 'Label 4', value: 'Content 4' }
]
}
mutations.addNewUsers(state, [user])
expect(state.users[0].fields).to.have.length(1)
expect(state.users[0].fields[0].name).to.eql('Label 1')
mutations.addNewUsers(state, [firstModUser])
expect(state.users[0].fields).to.have.length(2)
expect(state.users[0].fields[0].name).to.eql('Label 2')
expect(state.users[0].fields[1].name).to.eql('Label 3')
mutations.addNewUsers(state, [secondModUser])
expect(state.users[0].fields).to.have.length(1)
expect(state.users[0].fields[0].name).to.eql('Label 4')
})
}) })
describe('findUser', () => { describe('findUser', () => {

View file

@ -1,4 +1,4 @@
import { parseStatus, parseUser, parseNotification, addEmojis } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js' import { parseStatus, parseUser, parseNotification, addEmojis, parseLinkHeaderPagination } from '../../../../../src/services/entity_normalizer/entity_normalizer.service.js'
import mastoapidata from '../../../../fixtures/mastoapi.json' import mastoapidata from '../../../../fixtures/mastoapi.json'
import qvitterapidata from '../../../../fixtures/statuses.json' import qvitterapidata from '../../../../fixtures/statuses.json'
@ -383,4 +383,24 @@ describe('API Entities normalizer', () => {
expect(result).to.include('title=\':[a-z] {|}*:\'') expect(result).to.include('title=\':[a-z] {|}*:\'')
}) })
}) })
describe('Link header pagination', () => {
it('Parses min and max ids as integers', () => {
const linkHeader = '<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader)
expect(result).to.eql({
'maxId': 861676,
'minId': 861741
})
})
it('Parses min and max ids as flakes', () => {
const linkHeader = '<http://example.com/api/v1/timelines/home?max_id=9waQx5IIS48qVue2Ai>; rel="next", <http://example.com/api/v1/timelines/home?min_id=9wi61nIPnfn674xgie>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader, { flakeId: true })
expect(result).to.eql({
'maxId': '9waQx5IIS48qVue2Ai',
'minId': '9wi61nIPnfn674xgie'
})
})
})
}) })

View file

@ -5751,6 +5751,13 @@ parse-json@^4.0.0:
error-ex "^1.3.1" error-ex "^1.3.1"
json-parse-better-errors "^1.0.1" json-parse-better-errors "^1.0.1"
parse-link-header@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parse-link-header/-/parse-link-header-1.0.1.tgz#bedfe0d2118aeb84be75e7b025419ec8a61140a7"
integrity sha1-vt/g0hGK64S+deewJUGeyKYRQKc=
dependencies:
xtend "~4.0.1"
parseqs@0.0.5: parseqs@0.0.5:
version "0.0.5" version "0.0.5"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"