1
0
mirror of https://git.pleroma.social/sjw/pleroma-fe.git synced 2024-12-22 23:49:30 +01:00

fix conflicts, use file icon instead of link, add support for audio and file icon in cw'd posts

This commit is contained in:
Shpuld Shpuldson 2020-07-06 14:01:03 +03:00
commit 87b5f828d7
40 changed files with 455 additions and 63 deletions

View File

@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 'Bot' settings option and badge
- Added profile meta data fields that can be set in profile settings
- Descriptions can be set on uploaded files before posting
- When a post is a reply to an unavailable post, the 'Reply to'-text has a strike-through style
### Changed
- Registration page no longer requires email if the server is configured not to require it
@ -44,6 +45,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- 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
### Fixed
@ -105,6 +107,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Ability to change user's email
- About page
- Added remote user redirect
- Bookmarks
### 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
### Fixed

View File

@ -8,8 +8,6 @@
>
> --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.
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",
"diff": "^3.0.1",
"escape-html": "^1.0.3",
"parse-link-header": "^1.0.1",
"localforage": "^1.5.0",
"phoenix": "^1.3.0",
"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 UserReportingModal from './components/user_reporting_modal/user_reporting_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'
export default {
@ -32,7 +33,8 @@ export default {
MobileNav,
SettingsModal,
UserReportingModal,
PostStatusModal
PostStatusModal,
GlobalNoticeList
},
data: () => ({
mobileActivePanel: 'timeline',

View File

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

View File

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

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 FriendsTimeline from 'components/friends_timeline/friends_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 Interactions from 'components/interactions/interactions.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: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ 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: 'remote-user-profile-acct',
path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',

View File

@ -42,7 +42,7 @@ const Attachment = {
if (this.type === 'image') return 'icon-picture'
if (this.type === 'video') return 'icon-video'
if (this.type === 'audio') return 'icon-music'
return 'icon-link'
return 'icon-doc'
},
referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'

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

@ -34,6 +34,16 @@ const ExtraButtons = {
navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess'))
.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: {

View File

@ -40,6 +40,22 @@
>
<i class="icon-pin" /><span>{{ $t("status.unpin") }}</span>
</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
v-if="canDelete"
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") }}
</router-link>
</li>
<li v-if="currentUser">
<router-link :to="{ name: 'bookmarks'}">
<i class="button-icon icon-bookmark" /> {{ $t("nav.bookmarks") }}
</router-link>
</li>
<li v-if="currentUser && currentUser.locked">
<router-link :to="{ name: '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
}
},
created () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
notificationsFetcher.fetchAndUpdate({ store, credentials })
},
computed: {
mainClass () {
return this.minimalMode ? '' : 'panel panel-default'
@ -56,11 +61,6 @@ const Notifications = {
components: {
Notification
},
created () {
const { dispatch } = this.$store
dispatch('fetchAndUpdateNotifications')
},
watch: {
unseenCount (count) {
if (count > 0) {

View File

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

View File

@ -65,6 +65,14 @@
<i class="button-icon icon-home-2" /> {{ $t("nav.timeline") }}
</router-link>
</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
v-if="currentUser && currentUser.locked"
@click="toggleDrawer"

View File

@ -197,7 +197,7 @@
>
<StatusPopover
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"
style="min-width: 0"
>
@ -208,7 +208,12 @@
@click.prevent="gotoOriginal(status.in_reply_to_status_id)"
>
<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>
</StatusPopover>
<span
@ -526,6 +531,10 @@ $status-margin: 0.75em;
margin: 0 0.4em 0 0.2em;
}
.strikethrough {
text-decoration: line-through;
}
.replies-separator {
margin-left: 0.4em;
}

View File

@ -99,15 +99,9 @@ const StatusContent = {
file => !fileType.fileMatchesSomeType(this.galleryTypes, file)
)
},
hasImageAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'image'
)
},
hasVideoAttachments () {
return this.status.attachments.some(
file => fileType.fileType(file.mimetype) === 'video'
)
attachmentTypes () {
console.log(this.status.attachments)
return this.status.attachments.map(file => fileType.fileType(file.mimetype))
},
maxThumbnails () {
return this.mergedConfig.maxThumbnails

View File

@ -55,13 +55,21 @@
>
{{ $t("status.show_content") }}
<span
v-if="hasImageAttachments"
v-if="attachmentTypes.includes('image')"
class="icon-picture"
/>
<span
v-if="hasVideoAttachments"
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"

View File

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

View File

@ -137,7 +137,7 @@ const Timeline = {
showImmediately: true,
userId: this.userId,
tag: this.tag
}).then(statuses => {
}).then(({ statuses }) => {
store.commit('setLoading', { timeline: this.timelineName, value: false })
if (statuses && statuses.length === 0) {
this.bottomedOut = true

View File

@ -120,6 +120,7 @@
"public_tl": "Public Timeline",
"timeline": "Timeline",
"twkn": "The Whole Known Network",
"bookmarks": "Bookmarks",
"user_search": "User Search",
"search": "Search",
"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": "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": {
"favs_repeats": "Repeats and Favorites",
"follows": "New follows",
@ -630,6 +634,8 @@
"pin": "Pin on profile",
"unpin": "Unpin from profile",
"pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?",
"reply_to": "Reply to",
"replies_list": "Replies:",
@ -725,7 +731,8 @@
"add_reaction": "Add Reaction",
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request"
"reject_follow_request": "Reject follow request",
"bookmark": "Bookmark"
},
"upload": {
"error": {

View File

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

View File

@ -62,7 +62,15 @@ const persistedStateOptions = {
};
(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({
modules: {
i18n: {
@ -85,11 +93,13 @@ const persistedStateOptions = {
polls: pollsModule,
postStatus: postStatusModule
},
plugins: [persistedState, pushNotifications],
plugins,
strict: false // Socket modifies itself, let's ignore this for now.
// strict: process.env.NODE_ENV !== 'production'
})
if (storageError) {
store.dispatch('pushGlobalNotice', { messageKey: 'errors.storage_unavailable', level: 'error' })
}
afterStoreSetup({ store, i18n })
})()

View File

@ -138,9 +138,6 @@ const api = {
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
},
fetchAndUpdateNotifications (store) {
store.state.backendInteractor.fetchAndUpdateNotifications({ store })
},
// Follow requests
startFetchingFollowRequests (store) {

View File

@ -14,7 +14,8 @@ const defaultState = {
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')
)
},
mobileLayout: false
mobileLayout: false,
globalNotices: []
}
const interfaceMod = {
@ -58,6 +59,12 @@ const interfaceMod = {
if (!state.settingsModalLoaded) {
state.settingsModalLoaded = true
}
},
pushGlobalNotice (state, notice) {
state.globalNotices.push(notice)
},
removeGlobalNotice (state, notice) {
state.globalNotices = state.globalNotices.filter(n => n !== notice)
}
},
actions: {
@ -81,6 +88,28 @@ const interfaceMod = {
},
togglePeekSettingsModal ({ commit }) {
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(),
friends: 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 = {},
noIdUpdate = false, userId }) => {
const addNewStatuses = (state, { statuses, showImmediately = false, timeline, user = {}, noIdUpdate = false, userId, pagination = {} }) => {
// Sanity check
if (!isArray(statuses)) {
return false
@ -173,8 +173,13 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline]
const maxNew = statuses.length > 0 ? maxBy(statuses, 'id').id : 0
const minNew = statuses.length > 0 ? minBy(statuses, 'id').id : 0
// Mismatch between API pagination and our internal minId/maxId tracking systems:
// 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 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
if (timeline) {
if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject)
}
}
@ -463,6 +468,14 @@ export const mutations = {
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 }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.deleted = true
@ -590,8 +603,8 @@ export const mutations = {
const statuses = {
state: defaultState(),
actions: {
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId }) {
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId })
addNewStatuses ({ rootState, commit }, { statuses, showImmediately = false, timeline = false, noIdUpdate = false, userId, pagination }) {
commit('addNewStatuses', { statuses, showImmediately, timeline, noIdUpdate, user: rootState.users.currentUser, userId, pagination })
},
addNewNotifications ({ rootState, commit, dispatch, rootGetters }, { notifications, older }) {
commit('addNewNotifications', { visibleNotificationTypes: visibleNotificationTypes(rootState), dispatch, notifications, older, rootGetters })
@ -666,6 +679,20 @@ const statuses = {
rootState.api.backendInteractor.unretweet({ id: status.id })
.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 }) {
commit('queueFlush', { timeline, id })
},

View File

@ -1,5 +1,5 @@
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'
/* 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_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
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_MUTES_URL = '/api/v1/mutes/'
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_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
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_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
@ -510,7 +513,8 @@ const fetchTimeline = ({
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_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 params = []
@ -539,7 +543,7 @@ const fetchTimeline = ({
if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false])
}
if (timeline !== 'favorites') {
if (timeline !== 'favorites' && timeline !== 'bookmarks') {
params.push(['with_muted', withMuted])
}
if (replyVisibility !== 'all') {
@ -552,16 +556,20 @@ const fetchTimeline = ({
url += `?${queryString}`
let status = ''
let statusText = ''
let pagination = {}
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
status = data.status
statusText = data.statusText
pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
flakeId: timeline !== 'bookmarks' && timeline !== 'notifications'
})
return data
})
.then((data) => data.json())
.then((data) => {
if (!data.error) {
return data.map(isNotifications ? parseNotification : parseStatus)
return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination }
} else {
data.status = status
data.statusText = statusText
@ -612,6 +620,22 @@ const unretweet = ({ id, credentials }) => {
.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 = ({
credentials,
status,
@ -1152,6 +1176,8 @@ const apiService = {
unfavorite,
retweet,
unretweet,
bookmarkStatus,
unbookmarkStatus,
postStatus,
deleteStatus,
uploadMedia,

View File

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

View File

@ -1,4 +1,5 @@
import escape from 'escape-html'
import parseLinkHeader from 'parse-link-header'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
const qvitterStatusType = (status) => {
@ -232,6 +233,8 @@ export const parseStatus = (data) => {
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
output.type = data.reblog ? 'retweet' : 'status'
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.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
} else {
output.text = data.content
output.summary = data.spoiler_text
@ -381,3 +385,16 @@ const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
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 })
.then((requests) => {
store.commit('setFollowRequests', requests)
store.commit('addNewUsers', requests)
}, () => {})
.catch(() => {})
}

View File

@ -27,21 +27,25 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => {
}
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 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)
fetchNotifications({ store, args, older })
}
return result
}
}
const fetchNotifications = ({ store, args, older }) => {
return apiService.fetchTimeline(args)
.then((notifications) => {
.then(({ data: notifications }) => {
update({ store, notifications, older })
return notifications
}, () => store.dispatch('setNotificationsError', { value: true }))

View File

@ -34,7 +34,8 @@ export const DEFAULT_OPACITY = {
alert: 0.5,
input: 0.5,
faint: 0.5,
underlay: 0.15
underlay: 0.15,
alertPopup: 0.95
}
/** SUBJECT TO CHANGE IN THE FUTURE, this is all beta
@ -627,6 +628,39 @@ export const SLOT_INHERITANCE = {
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',
badgeNotificationText: {
depends: ['text', 'badgeNotification'],

View File

@ -2,7 +2,7 @@ import { camelCase } from 'lodash'
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)
store.dispatch('setError', { value: false })
@ -12,7 +12,8 @@ const update = ({ store, statuses, timeline, showImmediately, userId }) => {
timeline: ccTimeline,
userId,
statuses,
showImmediately
showImmediately,
pagination
})
}
@ -47,16 +48,18 @@ const fetchAndUpdate = ({
const numStatusesBeforeFetch = timelineData.statuses.length
return apiService.fetchTimeline(args)
.then((statuses) => {
if (statuses.error) {
store.dispatch('setErrorData', { value: statuses })
.then(response => {
if (response.error) {
store.dispatch('setErrorData', { value: response })
return
}
const { data: statuses, pagination } = response
if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) {
store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId })
}
update({ store, statuses, timeline, showImmediately, userId })
return statuses
update({ store, statuses, timeline, showImmediately, userId, pagination })
return { statuses, pagination }
}, () => store.dispatch('setError', { value: true }))
}

20
static/fontello.json Executable file → Normal file
View File

@ -376,10 +376,28 @@
"code": 59429,
"src": "fontawesome"
},
{
"uid": "f04a5d24e9e659145b966739c4fde82a",
"css": "bookmark",
"code": 59430,
"src": "fontawesome"
},
{
"uid": "2f5ef6f6b7aaebc56458ab4e865beff5",
"css": "bookmark-empty",
"code": 61591,
"src": "fontawesome"
},
{
"uid": "9ea0a737ccc45d6c510dcbae56058849",
"css": "music",
"code": 59430,
"code": 59432,
"src": "fontawesome"
},
{
"uid": "1b5a5d7b7e3c71437f5a26befdd045ed",
"css": "doc",
"code": 59433,
"src": "fontawesome"
}
]

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 qvitterapidata from '../../../../fixtures/statuses.json'
@ -383,4 +383,24 @@ describe('API Entities normalizer', () => {
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"
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:
version "0.0.5"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"