Merge pull request '2022.10 stable' (#177) from develop into stable
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Reviewed-on: AkkomaGang/pleroma-fe#177
This commit is contained in:
floatingghost 2022-10-08 11:13:01 +00:00
commit c8c8d40827
48 changed files with 2973 additions and 1755 deletions

View file

@ -1,47 +0,0 @@
# This file is a template, and might need editing before it works on your project.
# Official framework image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/node/tags/
image: node:12
stages:
- lint
- build
- test
- deploy
lint:
stage: lint
script:
- yarn
- npm run lint
- npm run stylelint
test:
stage: test
variables:
APT_CACHE_DIR: apt-cache
script:
- mkdir -pv $APT_CACHE_DIR && apt-get -qq update
- apt install firefox-esr -y --no-install-recommends
- firefox --version
- yarn
- yarn unit
build:
stage: build
script:
- yarn
- npm run build
artifacts:
paths:
- dist/
docs-deploy:
stage: deploy
image: alpine:latest
only:
- develop@pleroma/pleroma-fe
before_script:
- apk add curl
script:
- curl -X POST -F"token=$DOCS_PIPELINE_TRIGGER" -F'ref=master' https://git.pleroma.social/api/v4/projects/673/trigger/pipeline

View file

@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## Unreleased
### Added
- Implemented remote interaction with statuses
## 2022.09 - 2022-09-10
### Added
- Automatic post translations. Must be configured on the backend in order to work.
- Post editing, including a log of previous edits.
### Changed
- Top bar now has navigation shortcuts. Can be enabled or disabled by admins or users.
- Optional replacement of timeline drop-down with navigation buttons. Also configurable.
- Posts and posts with replies are now separated on user profiles.
- Custom emoji from remote instances on a post can now also be used.
## 2022.08 - 2022-08-12
### Added
- Ability to quote public and unlisted posts
- Bubble timeline
### Changed
- Emoji in emoji picker is separated by packs
### Removed
- Chats, they were half-baked. Just use PMs.
## 2022.07 - 2022-07-16
### Fixed
- AdminFE button no longer scrolls page to top when clicked
- Pinned statuses no longer appear at bottom of user timeline (still appear as part of the timeline when fetched deep enough)
@ -16,6 +43,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Attachments are ALWAYS in same order as user uploaded, no more "videos first"
- Attachment description is prefilled with backend-provided default when uploading
- Proper visual feedback that next image is loading when browsing
- Misskey-Flavoured Markdown support
- Custom emoji reactions
### Changed
- (You)s are optional (opt-in) now, bolding your nickname is also optional (opt-out)

View file

@ -6,6 +6,7 @@ You have several timelines to browse trough
- **Bookmarks** all the posts you've bookmarked. You can bookmark a post by clicking the three dots on the bottom right of the post and choose Bookmark.
- **Direct Messages** all posts with `direct` scope addressed to you or mentioning you.
- **Public Timelines** all public posts made by users on the instance you're on
- **Bubble Timeline** all public posts from instances recommended by your admin(s) in the instance settings. This won't appear if they haven't set anything up for it.
- **The Whole Known Network** also known as **TWKN** or **Federated Timeline** - all public posts known by your instance. Due to nature of the network your instance may not know *all* the posts on the network, so only posts known by your instance are shown there.
Note that by default you will see all posts made by other users on your Home Timeline, this contrast behavior of Twitter and Mastodon, which shows you only non-reply posts and replies to people you follow. You can change said behavior in the [settings](settings.md#filtering).

View file

@ -1,7 +1,7 @@
{
"name": "pleroma_fe",
"version": "1.0.0",
"description": "A Qvitter-style frontend for certain GS servers.",
"version": "3.2.0",
"description": "A frontend for Akkoma instances",
"author": "Roger Braun <roger@rogerbraun.net>",
"private": true,
"scripts": {
@ -20,7 +20,7 @@
"@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-regular-svg-icons": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0",
"@vuelidate/core": "2.0.0-alpha.42",
@ -41,7 +41,7 @@
"qrcode": "1",
"ruffle-mirror": "2021.12.31",
"vue": "^3.2.31",
"vue-i18n": "^9.2.0-beta.39",
"vue-i18n": "^9.2.2",
"vue-router": "4.0.14",
"vue-template-compiler": "2.6.11",
"vuex": "4.0.2"

View file

@ -61,7 +61,6 @@
<EditStatusModal v-if="editingAvailable" />
<StatusHistoryModal v-if="editingAvailable" />
<SettingsModal />
<UpdateNotification />
<GlobalNoticeList />
</div>
</template>

View file

@ -398,7 +398,6 @@ const afterStoreSetup = async ({ store, i18n }) => {
store.dispatch('startFetchingAnnouncements')
getTOS({ store })
getStickers({ store })
store.dispatch('getSupportedTranslationlanguages')
const router = createRouter({
history: createWebHistory(),

View file

@ -20,6 +20,9 @@ const About = {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
},
showLocalBubblePanel () {
return this.$store.state.instance.localBubbleInstances.length > 0
}
}
}

View file

@ -3,7 +3,7 @@
<instance-specific-panel v-if="showInstanceSpecificPanel" />
<staff-panel />
<terms-of-service-panel />
<LocalBubblePanel />
<LocalBubblePanel v-if="showLocalBubblePanel" />
<MRFTransparencyPanel />
<features-panel v-if="showFeaturesPanel" />
</div>

View file

@ -52,6 +52,9 @@ const AccountActions = {
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
removeUserFromFollowers () {
this.$store.dispatch('removeUserFromFollowers', this.user.id)
},
reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
}

View file

@ -28,6 +28,13 @@
class="dropdown-divider"
/>
</template>
<button
v-if="relationship.followed_by"
class="btn button-default btn-block dropdown-item"
@click="removeUserFromFollowers"
>
{{ $t('user_card.remove_follower') }}
</button>
<button
v-if="relationship.blocking"
class="btn button-default btn-block dropdown-item"

View file

@ -8,7 +8,8 @@ import {
faThumbtack,
faShareAlt,
faExternalLinkAlt,
faHistory
faHistory,
faFilePen
} from '@fortawesome/free-solid-svg-icons'
import {
faBookmark as faBookmarkReg,
@ -24,7 +25,8 @@ library.add(
faShareAlt,
faExternalLinkAlt,
faFlag,
faHistory
faHistory,
faFilePen
)
const ExtraButtons = {
@ -36,7 +38,8 @@ const ExtraButtons = {
data () {
return {
expanded: false,
showingDeleteDialog: false
showingDeleteDialog: false,
showingRedraftDialog: false
}
},
methods: {
@ -122,6 +125,34 @@ const ExtraButtons = {
const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
stripFieldsList.forEach(p => delete originalStatus[p])
this.$store.dispatch('openStatusHistoryModal', originalStatus)
},
redraftStatus () {
if (this.shouldConfirmDelete) {
this.showRedraftStatusConfirmDialog()
} else {
this.doRedraftStatus()
}
},
doRedraftStatus () {
this.$store.dispatch('fetchStatusSource', { id: this.status.id })
.then(data => this.$store.dispatch('openPostStatusModal', {
isRedraft: true,
statusId: this.status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: this.status.nsfw,
statusPoll: this.status.poll,
statusFiles: [...this.status.attachments],
statusScope: this.status.visibility,
statusContentType: data.content_type
}))
this.doDeleteStatus()
},
showRedraftStatusConfirmDialog () {
this.showingRedraftDialog = true
},
hideRedraftStatusConfirmDialog () {
this.showingRedraftDialog = false
}
},
computed: {

View file

@ -95,6 +95,17 @@
icon="history"
/><span>{{ $t("status.edit_history") }}</span>
</button>
<button
v-if="ownStatus"
class="button-default dropdown-item dropdown-item-icon"
@click.prevent="redraftStatus"
@click="close"
>
<FAIcon
fixed-width
icon="file-pen"
/><span>{{ $t("status.redraft") }}</span>
</button>
<button
v-if="canDelete"
class="button-default dropdown-item dropdown-item-icon"
@ -179,6 +190,16 @@
>
{{ $t('status.delete_confirm') }}
</ConfirmModal>
<ConfirmModal
v-if="showingRedraftDialog"
:title="$t('status.redraft_confirm_title')"
:cancel-text="$t('status.redraft_confirm_cancel_button')"
:confirm-text="$t('status.redraft_confirm_accept_button')"
@cancelled="hideRedraftStatusConfirmDialog"
@accepted="doRedraftStatus"
>
{{ $t('status.redraft_confirm') }}
</ConfirmModal>
</teleport>
</template>
</Popover>

View file

@ -31,7 +31,10 @@ const FavoriteButton = {
}
},
computed: {
...mapGetters(['mergedConfig'])
...mapGetters(['mergedConfig']),
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}

View file

@ -13,13 +13,19 @@
:spin="animated"
/>
</button>
<span v-else>
<a
v-else
class="button-unstyled interactive"
target="_blank"
role="button"
:href="remoteInteractionLink"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.favorite')"
:icon="['far', 'star']"
/>
</span>
</a>
<span
v-if="!mergedConfig.hidePostStats && status.fave_num > 0"
class="action-counter"

View file

@ -1,6 +1,7 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import RemoteFollow from '../remote_follow/remote_follow.vue'
import FollowButton from '../follow_button/follow_button.vue'
import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue'
const FollowCard = {
props: [
@ -10,7 +11,8 @@ const FollowCard = {
components: {
BasicUserCard,
RemoteFollow,
FollowButton
FollowButton,
RemoveFollowerButton
},
computed: {
isMe () {

View file

@ -22,6 +22,11 @@
class="follow-card-follow-button"
:user="user"
/>
<RemoveFollowerButton
v-if="noFollowsYou && relationship.followed_by"
:relationship="relationship"
class="follow-card-button"
/>
</template>
</div>
</basic-user-card>
@ -40,6 +45,12 @@
line-height: 1.5em;
}
&-button {
margin-top: 0.5em;
padding: 0 1.5em;
margin-left: 1em;
}
&-follow-button {
margin-top: 0.5em;
margin-left: auto;

View file

@ -86,7 +86,8 @@ const PostStatusForm = {
'fileLimit',
'submitOnEnter',
'emojiPickerPlacement',
'optimisticPosting'
'optimisticPosting',
'isRedraft'
],
emits: [
'posted',
@ -141,7 +142,7 @@ const PostStatusForm = {
contentType
}
if (this.statusId) {
if (this.statusId || this.isRedraft) {
const statusContentType = this.statusContentType || contentType
statusParams = {
spoilerText: this.subject || '',
@ -259,7 +260,7 @@ const PostStatusForm = {
return this.newStatus.files.length >= this.fileLimit
},
isEdit () {
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
return typeof this.statusId !== 'undefined' && this.statusId.trim() !== '' && !this.isRedraft
},
...mapGetters(['mergedConfig']),
...mapState({

View file

@ -28,7 +28,8 @@ const PostStatusModal = {
},
watch: {
params (newVal, oldVal) {
if (get(newVal, 'repliedUser.id') !== get(oldVal, 'repliedUser.id')) {
if (get(newVal, 'repliedUser.id') !== get(oldVal, 'repliedUser.id') ||
get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
this.resettingForm = true
this.$nextTick(() => {
this.resettingForm = false

View file

@ -0,0 +1,25 @@
export default {
props: ['relationship'],
data () {
return {
inProgress: false
}
},
computed: {
label () {
if (this.inProgress) {
return this.$t('user_card.follow_progress')
} else {
return this.$t('user_card.remove_follower')
}
}
},
methods: {
onClick () {
this.inProgress = true
this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => {
this.inProgress = false
})
}
}
}

View file

@ -0,0 +1,13 @@
<template>
<button
class="btn button-default follow-button"
:class="{ toggled: inProgress }"
:disabled="inProgress"
:title="$t('user_card.remove_follower')"
@click="onClick"
>
{{ label }}
</button>
</template>
<script src="./remove_follower_button.js"></script>

View file

@ -9,6 +9,9 @@ const ReplyButton = {
computed: {
loggedIn () {
return !!this.$store.state.users.currentUser
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}

View file

@ -12,13 +12,19 @@
icon="reply"
/>
</button>
<span v-else>
<a
v-else
class="button-unstyled interactive"
target="_blank"
role="button"
:href="remoteInteractionLink"
>
<FAIcon
icon="reply"
class="fa-scale-110 fa-old-padding"
:title="$t('tool_tip.reply')"
/>
</span>
</a>
<span
v-if="status.replies_count > 0"
class="action-counter"

View file

@ -51,6 +51,9 @@ const RetweetButton = {
},
shouldConfirmRepeat () {
return this.mergedConfig.modalOnRepeat
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}

View file

@ -20,13 +20,19 @@
:title="$t('timeline.no_retweet_hint')"
/>
</span>
<span v-else>
<a
v-else
class="button-unstyled interactive"
target="_blank"
role="button"
:href="remoteInteractionLink"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="retweet"
:title="$t('tool_tip.repeat')"
/>
</span>
</a>
<span
v-if="!mergedConfig.hidePostStats && status.repeat_num > 0"
class="no-event"

View file

@ -12,7 +12,8 @@ export default {
'path',
'disabled',
'options',
'expert'
'expert',
'hideDefaultLabel'
],
computed: {
pathDefault () {

View file

@ -16,7 +16,11 @@
:value="option.value"
>
{{ option.label }}
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
<template
v-if="hideDefaultLabel !== true"
>
{{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
</template>
</option>
</Select>
<ModifiedIndicator :changed="isChanged" />

View file

@ -19,7 +19,7 @@ const SharedComputedObject = () => ({
.map(key => [key, {
get () { return this.$store.getters.mergedConfig[key] },
set (value) {
this.$store.dispatch('setOption', { name: key, value })
this.$store.dispatch('setOption', { name: key, value, manual: true })
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
@ -27,7 +27,7 @@ const SharedComputedObject = () => ({
.map(key => ['serverSide_' + key, {
get () { return this.$store.state.serverSideConfig[key] },
set (value) {
this.$store.dispatch('setServerSideOption', { name: key, value })
this.$store.dispatch('setServerSideOption', { name: key, value, manual: true })
}
}])
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),

View file

@ -8,11 +8,12 @@ import SharedComputedObject from '../helpers/shared_computed_object.js'
import ServerSideIndicator from '../helpers/server_side_indicator.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGlobe
faGlobe, faSync
} from '@fortawesome/free-solid-svg-icons'
library.add(
faGlobe
faGlobe,
faSync
)
const GeneralTab = {
@ -48,6 +49,8 @@ const GeneralTab = {
value: tab,
label: this.$t(`user_card.${tab}`)
})),
profilesExpanded: false,
newProfileName: '',
loopSilentAvailable:
// Firefox
Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'mozHasAudio') ||
@ -88,8 +91,22 @@ const GeneralTab = {
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
}
},
settingsProfiles () {
return (this.$store.state.instance.settingsProfiles || [])
},
settingsProfile: {
get: function () { return this.$store.getters.mergedConfig.profile },
set: function (val) {
this.$store.dispatch('setOption', { name: 'profile', value: val })
this.$store.dispatch('getSettingsProfile')
}
},
settingsVersion () {
return this.$store.getters.mergedConfig.profileVersion
},
translationLanguages () {
return (this.$store.getters.mergedConfig.supportedTranslationLanguages.target || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))
const langs = this.$store.state.instance.translationLanguages || []
return (langs || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))
},
translationLanguage: {
get: function () { return this.$store.getters.mergedConfig.translationLanguage },
@ -105,6 +122,30 @@ const GeneralTab = {
},
setTranslationLanguage (value) {
this.$store.dispatch('setOption', { name: 'translationLanguage', value })
},
toggleExpandedSettings () {
this.profilesExpanded = !this.profilesExpanded
},
loadSettingsProfile (name) {
this.$store.commit('setOption', { name: 'profile', value: name })
this.$store.dispatch('getSettingsProfile', true)
},
createSettingsProfile () {
this.$store.dispatch('setOption', { name: 'profile', value: this.newProfileName })
this.$store.dispatch('setOption', { name: 'profileVersion', value: 1 })
this.$store.dispatch('syncSettings')
this.newProfileName = ''
},
forceSync () {
this.$store.dispatch('getSettingsProfile')
},
refreshProfiles () {
this.$store.dispatch('listSettingsProfiles')
},
deleteSettingsProfile (name) {
if (confirm(this.$t('settings.settings_profile_delete_confirm'))) {
this.$store.dispatch('deleteSettingsProfile', name)
}
}
}
}

View file

@ -1,7 +1,6 @@
<template>
<div :label="$t('settings.general')">
<div class="setting-item">
<h2>{{ $t('settings.interface') }}</h2>
<ul class="setting-list">
<li>
<interface-language-switcher
@ -10,6 +9,94 @@
:set-language="val => language = val"
/>
</li>
<li
v-if="user && (settingsProfiles.length > 0)"
>
<h2>{{ $t('settings.settings_profile') }}</h2>
<p>
{{ $t('settings.settings_profile_currently', { name: settingsProfile, version: settingsVersion }) }}
<button
class="btn button-default"
@click="forceSync()"
>
{{ $t('settings.settings_profile_force_sync') }}
</button>
</p>
<div
@click="toggleExpandedSettings"
>
<template
v-if="profilesExpanded"
>
<button class="btn button-default">
{{ $t('settings.settings_profiles_unshow') }}
</button>
</template>
<template
v-else
>
<button class="btn button-default">
{{ $t('settings.settings_profiles_show') }}
</button>
</template>
</div>
<br>
<template
v-if="profilesExpanded"
>
<div
v-for="profile in settingsProfiles"
:key="profile.id"
class="settings-profile"
>
<h4>{{ profile.name }} ({{ profile.version }})</h4>
<template
v-if="settingsProfile === profile.name"
>
{{ $t('settings.settings_profile_in_use') }}
</template>
<template
v-else
>
<button
class="btn button-default"
@click="loadSettingsProfile(profile.name)"
>
{{ $t('settings.settings_profile_use') }}
</button>
<button
class="btn button-default"
@click="deleteSettingsProfile(profile.name)"
>
{{ $t('settings.settings_profile_delete') }}
</button>
</template>
</div>
<button class="btn button-default" @click="refreshProfiles()">
{{ $t('settings.settings_profiles_refresh') }}
<FAIcon icon="sync" @click="refreshProfiles()" />
</button>
<h3>{{ $t('settings.settings_profile_creation') }}</h3>
<label for="settings-profile-new-name">
{{ $t('settings.settings_profile_creation_new_name_label') }}
</label>
<input v-model="newProfileName" id="settings-profile-new-name">
<button
class="btn button-default"
@click="createSettingsProfile"
>
{{ $t('settings.settings_profile_creation_submit') }}
</button>
</template>
</li>
</ul>
</div>
<div class="setting-item">
<h2>{{ $t('settings.interface') }}</h2>
<ul class="setting-list">
<li v-if="instanceSpecificPanelPresent">
<BooleanSetting path="hideISP">
{{ $t('settings.hide_isp') }}
@ -463,7 +550,6 @@
</BooleanSetting>
</li>
<li>
<!-- <BooleanSetting path="serverSide_defaultNSFW"> -->
<BooleanSetting path="sensitiveByDefault">
{{ $t('settings.sensitive_by_default') }}
</BooleanSetting>
@ -546,3 +632,13 @@
</template>
<script src="./general_tab.js"></script>
<style lang="scss">
.settings-profile {
margin-bottom: 1em;
}
#settings-profile-new-name {
margin-left: 1em;
margin-right: 1em;
}
</style>

View file

@ -83,7 +83,7 @@ const StatusContent = {
return this.status.attachments.map(file => fileType.fileType(file.mimetype))
},
translationLanguages () {
return (this.$store.getters.mergedConfig.supportedTranslationLanguages.source || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))
return (this.$store.state.instance.supportedTranslationLanguages.source || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))
},
...mapGetters(['mergedConfig'])
},

View file

@ -60,7 +60,7 @@
v-if="status.translation"
class="translation"
>
<h4>{{ $t('status.translated_from', { language: status.translation.detected_language }) }}</h4>
<h4>{{ $t(`languages.translated_from.${status.translation.detected_language.toLowerCase()}`) }}</h4>
<RichContent
:class="{ '-single-line': singleLine }"
class="text media-body"
@ -85,7 +85,7 @@
:key="language.key"
:value="language.value"
>
{{ language.label }}
{{ $t(`languages.${language.value.toLowerCase()}`) }}
</option>
</Select>
{{ ' ' }}

View file

@ -11,12 +11,13 @@ const StillImage = {
],
data () {
return {
stopGifs: this.$store.getters.mergedConfig.stopGifs
stopGifs: this.$store.getters.mergedConfig.stopGifs,
isAnimated: false
}
},
computed: {
animated () {
return this.stopGifs && (this.mimetype === 'image/gif' || this.src.endsWith('.gif'))
return this.stopGifs && this.isAnimated
},
style () {
const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str
@ -31,17 +32,89 @@ const StillImage = {
const image = this.$refs.src
if (!image) return
this.imageLoadHandler && this.imageLoadHandler(image)
this.detectAnimation(image)
this.drawThumbnail()
},
onError () {
this.imageLoadError && this.imageLoadError()
},
detectAnimation (image) {
if (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) {
this.isAnimated = true
return
}
// harmless CORS errors without-- clean console with
if (!this.$store.state.instance.mediaProxyAvailable) return
// Animated JPEGs?
if (!(this.src.endsWith('.webp') || this.src.endsWith('.png'))) return
// Browser Cache should ensure image doesn't get loaded twice if cache exists
fetch(image.src, {
referrerPolicy: 'same-origin'
})
.then(data => {
// We don't need to read the whole file so only call it once
data.body.getReader().read()
.then(reader => {
if (this.src.endsWith('.webp') && this.isAnimatedWEBP(reader.value)) {
this.isAnimated = true
return
}
if (this.src.endsWith('.png') && this.isAnimatedPNG(reader.value)) {
this.isAnimated = true
}
})
})
.catch(() => {
// this.imageLoadError && this.imageLoadError()
})
},
isAnimatedWEBP (data) {
/**
* WEBP HEADER CHUNK
* === START HEADER ===
* 82 73 70 70 ("RIFF")
* xx xx xx xx (SIZE)
* 87 69 66 80 ("WEBP")
* === END OF HEADER ===
* 86 80 56 88 ("VP8X") Extended VP8X
* xx xx xx xx (VP8X)
* [++] RSVILEX(A)R (1 byte)
* A Animated bit
*/
// Relevant bytes
const segment = data.slice(4 * 3, (4 * 5) + 1)
// Check for VP8X string
if (segment.join('').includes(['86805688'])) {
// Check for Animation bit
return !!((segment[8] >> 1) & 1)
}
// No VP8X = Not Animated (X is for Extended)
return false
},
isAnimatedPNG (data) {
// Find acTL before IDAT in PNG; if found it is animated
const segment = []
for (let i = 0; i < data.length; i++) {
segment.push(String.fromCharCode(data[i]))
}
const str = segment.join('')
const idatPos = str.indexOf('IDAT')
return (str.substring(0, idatPos > 0 ? idatPos : 0).indexOf('acTL') > 0)
},
drawThumbnail () {
const canvas = this.$refs.canvas
if (!canvas) return
if (!this.$refs.canvas) return
const image = this.$refs.src
const width = image.naturalWidth
const height = image.naturalHeight
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(image, 0, 0, width, height)
},
onError () {
this.imageLoadError && this.imageLoadError()
}
},
updated () {
// On computed animated change
this.drawThumbnail()
}
}

View file

@ -135,7 +135,6 @@
a {
display: block;
padding: 0.6em 0.65em;
padding-bottom: 0;
&:hover {
background-color: $fallback--lightBg;

View file

@ -23,7 +23,8 @@ const TimelineMenuContent = {
...mapState({
currentUser: state => state.users.currentUser,
privateMode: state => state.instance.private,
federating: state => state.instance.federating
federating: state => state.instance.federating,
showBubbleTimeline: state => (state.instance.localBubbleInstances.length > 0)
})
}
}

View file

@ -16,7 +16,7 @@
>{{ $t("nav.home_timeline") }}</span>
</router-link>
</li>
<li v-if="currentUser">
<li v-if="currentUser && showBubbleTimeline">
<router-link
class="menu-item"
:to="{ name: 'bubble-timeline' }"

View file

@ -5,7 +5,7 @@
"keyword": {
"ftl_removal": "Von der Zeitleiste \"Das gesamte bekannte Netzwerk\" entfernen",
"is_replaced_by": "→",
"keyword_policies": "Keyword Richtlinien",
"keyword_policies": "Richtlinien für Schlüsselwörter",
"reject": "Ablehnen",
"replace": "Ersetzen"
},
@ -16,12 +16,15 @@
"accept_desc": "Diese Instanz akzeptiert nur Nachrichten von den folgenden Instanzen:",
"ftl_removal": "Von der Zeitleiste \"Das bekannte Netzwerk\" entfernen",
"ftl_removal_desc": "Dieser Instanz entfernt folgende Instanzen von der \"Das bekannte Netzwerk\" Zeitleiste:",
"instance": "Instanz",
"media_nsfw": "Erzwingen Medien als heikel zu makieren",
"media_nsfw_desc": "Diese Instanz makiert die Medien in Beiträgen der folgenden Instanzen als heikel:",
"media_removal": "Medienentfernung",
"media_removal_desc": "Diese Instanz entfernt Medien von den Beiträgen der folgenden Instanzen:",
"not_applicable": "entfällt",
"quarantine": "Quarantäne",
"quarantine_desc": "Diese Instanz sendet nur öffentliche Beiträge zu den folgenden Instanzen:",
"quarantine_desc": "Diese Instanz sendet keine Beiträge zu den folgenden Instanzen:",
"reason": "Grund",
"reject": "Ablehnen",
"reject_desc": "Diese Instanz akzeptiert keine Nachrichten der folgenden Instanzen:",
"simple_policies": "Instanzspezifische Richtlinien"
@ -29,6 +32,27 @@
},
"staff": "Mitarbeiter"
},
"announcements": {
"all_day_prompt": "Dies ist ein Ganztagsereignis",
"cancel_edit_action": "Abbrechen",
"close_error": "Schließen",
"delete_action": "Löschen",
"edit_action": "Bearbeiten",
"end_time_display": "Endet um {time}",
"end_time_prompt": "Ende: ",
"inactive_message": "Diese Ankündigung ist inaktiv",
"mark_as_read_action": "Als gelesen markieren",
"page_header": "Ankündigungen",
"post_action": "Veröffentlichen",
"post_error": "Fehler: {error}",
"post_form_header": "Ankündigung veröffentlichen",
"post_placeholder": "Inhalt der Ankündigung",
"published_time_display": "Veröffentlicht um {time}",
"start_time_display": "Startet um {time}",
"start_time_prompt": "Start: ",
"submit_edit_action": "Absenden",
"title": "Ankündigung"
},
"chats": {
"chats": "Chats",
"delete": "Löschen",
@ -109,6 +133,13 @@
"admin": "Admin",
"moderator": "Moderator"
},
"scope_in_timeline": {
"direct": "Direkt",
"local": "Lokal - nur deine eigene Instanz kann diesen Beitrag sehen",
"private": "Nur an Folgende",
"public": "Öffentlich",
"unlisted": "Nicht gelistet"
},
"show_less": "Zeige weniger",
"show_more": "Zeige mehr",
"submit": "Absenden",
@ -131,6 +162,84 @@
"load_older": "Lade ältere Interaktionen",
"moves": "Benutzer migriert zu"
},
"languages": {
"ar": "Arabisch",
"az": "Aserbaidschanisch",
"bg": "Bulgarisch",
"cs": "Tschechisch",
"da": "Dänisch",
"de": "Deutsch",
"el": "Griechisch",
"en": "Englisch",
"eo": "Esperanto",
"es": "Spanisch",
"fa": "Persisch",
"fi": "Finnisch",
"fr": "Französisch",
"ga": "Irisch",
"he": "Hebräisch",
"hi": "Hindi",
"hu": "Ungarisch",
"id": "Indonesisch",
"it": "Italienisch",
"ja": "Japanisch",
"ko": "Koreanisch",
"lt": "Litauisch",
"lv": "Lettisch",
"nl": "Niederländisch",
"pl": "Polnisch",
"pt": "Portugiesisch",
"ru": "Russisch",
"sk": "Slowakisch",
"sv": "Schwedisch",
"tr": "Türkisch",
"translated_from": {
"ar": "Übersetzt aus dem Arabischen",
"az": "Übersetzt aus dem Aserbaidschanischen",
"bg": "Übersetzt aus dem Bulgarischen",
"cs": "Übersetzt aus dem Tschechischen",
"da": "Übersetzt aus dem Dänischen",
"de": "Übersetzt aus dem Deutschen",
"el": "Übersetzt aus dem Griechischen",
"en": "Übersetzt aus dem Englischen",
"eo": "Übersetzt von @:languages.eo",
"es": "Übersetzt aus dem Spanischen",
"fa": "Übersetzt aus dem Persischen",
"fi": "Übersetzt aus dem Finnischen",
"fr": "Übersetzt aus dem Französischen",
"ga": "Übersetzt aus dem Irischen",
"he": "Übersetzt aus dem Hebräischen",
"hi": "Übersetzt von @:languages.hi",
"hu": "Übersetzt aus dem Ungarischen",
"id": "Übersetzt aus dem Indonesischen",
"it": "Übersetzt aus dem Italienischen",
"ja": "Übersetzt aus dem Japanischen",
"ko": "Übersetzt aus dem Koreanischen",
"lt": "Übersetzt aus dem Litauischen",
"lv": "Übersetzt aus dem Lettischen",
"nl": "Übersetzt aus dem Niederländischen",
"pl": "Übersetzt aus dem Polnischen",
"pt": "Übersetzt aus dem Portugiesischen",
"ru": "Übersetzt aus dem Russischen",
"sk": "Übersetzt aus dem Slowakischen",
"sv": "Übersetzt aus dem Schwedischen",
"tr": "Übersetzt aus dem Türkischen",
"uk": "Übersetzt aus dem Ukrainischen",
"zh": "Übersetzt aus dem Chinesischen"
},
"uk": "Ukrainisch",
"zh": "Chinesisch"
},
"lists": {
"create": "Erstellen",
"delete": "Liste löschen",
"following_only": "Auf Folgende begrenzen",
"lists": "Listen",
"new": "Neue Liste",
"save": "Änderungen speichern",
"search": "Benutzer suchen",
"title": "Listen-Titel"
},
"login": {
"authentication_code": "Authentifizierungscode",
"description": "Mit OAuth anmelden",
@ -144,38 +253,45 @@
"login": "Anmelden",
"logout": "Abmelden",
"password": "Passwort",
"placeholder": "z.B. lain",
"placeholder": "meinbenutzername",
"recovery_code": "Wiederherstellungscode",
"register": "Registrieren",
"username": "Benutzername"
},
"media_modal": {
"counter": "{current} / {total}",
"hide": "Medienansicht schließen",
"next": "Weiter",
"previous": "Zurück"
},
"nav": {
"about": "Über",
"administration": "Administration",
"announcements": "Ankündigungen",
"back": "Zurück",
"bookmarks": "Lesezeichen",
"chats": "Chats",
"dms": "Direktnachrichten",
"friend_requests": "Followanfragen",
"home_timeline": "Heim Zeitlinie",
"home_timeline": "Heimzeitleiste",
"home_timeline_description": "Beiträge von Leuten, denen du folgst",
"interactions": "Interaktionen",
"lists": "Listen",
"mentions": "Erwähnungen",
"preferences": "Voreinstellungen",
"public_timeline_description": "Öffentliche Beiträge von dieser Instanz",
"public_tl": "Öffentliche Zeitleiste",
"search": "Suche",
"timeline": "Zeitleiste",
"timelines": "Zeitlinie",
"twkn": "Bekannte Netzwerk",
"twkn": "Bekanntes Netzwerk",
"twkn_timeline_description": "Beiträge aus dem gesamten bekannten Netzwerk",
"user_search": "Benutzersuche",
"who_to_follow": "Wem folgen"
},
"notifications": {
"broken_favorite": "Unbekannte Nachricht, suche danach…",
"error": "Error beim laden von Neuigkeiten",
"error": "Fehler beim Laden neuer Benachrichtigungen: {0}",
"favorited_you": "favorisierte deine Nachricht",
"follow_request": "möchte dir folgen",
"followed_you": "folgt dir",
@ -183,6 +299,7 @@
"migrated_to": "migrierte zu",
"no_more_notifications": "Keine Benachrichtigungen mehr",
"notifications": "Benachrichtigungen",
"poll_ended": "Umfrage wurde beendet",
"reacted_with": "reagierte mit {0}",
"read": "Gelesen!",
"repeated_you": "wiederholte deine Nachricht"
@ -223,27 +340,34 @@
"text/bbcode": "BBCode",
"text/html": "HTML",
"text/markdown": "Markdown",
"text/plain": "Nur Text"
"text/plain": "Nur Text",
"text/x.misskeymarkdown": "MFM"
},
"content_warning": "Betreff (optional)",
"default": "Sitze gerade im Hofbräuhaus.",
"content_warning": "Inhaltswarnung (optional)",
"default": "Sitze gerade im Hofbräuhaus",
"direct_warning_to_all": "Dieser Beitrag wird für alle erwähnten Benutzer sichtbar sein.",
"direct_warning_to_first_only": "Dieser Beitrag wird für alle Benutzer, die am Anfang der Nachricht erwähnt wurden, sichtbar sein.",
"empty_status_error": "Eine leere Nachricht ohne Anhänge kann nicht gesendet werden",
"edit_remote_warning": "Änderungen könnten auf manchen Instanzen nicht sichtbar sein!",
"edit_status": "Beitrag ändern",
"edit_unsupported_warning": "Umfragen und Erwähnungen werden durch die Bearbeitung nicht geändert.",
"empty_status_error": "Eine Nachricht ohne Text und ohne Anhänge kann nicht gesendet werden",
"media_description": "Medienbeschreibung",
"media_description_error": "Medien konnten nicht neu geladen werden, versuche es erneut",
"new_status": "Neuen Status veröffentlichen",
"media_not_sensitive_warning": "Es wurde eine Inhaltswarnung eingestellt, aber die Anhänge sind nicht als heikel gekennzeichnet!",
"new_status": "Neuer Post",
"post": "Post",
"posting": "Veröffentlichen",
"preview": "Vorschau",
"preview_empty": "Leer",
"scope": {
"direct": "Direkt - Beitrag nur an erwähnte Profile",
"local": "Lokal - diesen Beitrag nicht föderieren",
"private": "Nur Follower - Beitrag nur für Follower sichtbar",
"public": "Öffentlich - Beitrag an öffentliche Zeitleisten",
"unlisted": "Nicht gelistet - Nicht in öffentlichen Zeitleisten anzeigen"
},
"scope_notice": {
"local": "Dieser Bericht ist auf anderen Instanzen nicht sichbar",
"private": "Dieser Beitrag wird nur für deine Follower sichtbar sein",
"public": "Dieser Beitrag wird für alle sichtbar sein",
"unlisted": "Dieser Beitrag wird weder in der öffentlichen Zeitleiste noch im gesamten bekannten Netzwerk sichtbar sein"
@ -251,11 +375,