Compare commits

..

2 commits

Author SHA1 Message Date
ef6f8586c2 Move regex creation to filtering tab logic
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
2024-12-18 22:45:05 -05:00
105154a42b Add regex filter support
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
This makes any filter that starts and ends in forward slashes act as a
regex filter instead of a simple substring filter.

Currently doesn't support trailing flags unlike actual JS regexes, so
modifiers should be used instead for that functionality:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Modifier
2024-12-17 23:43:59 -05:00
135 changed files with 1970 additions and 3814 deletions

View file

@ -1,5 +1,5 @@
labels:
platform: linux/arm64
platform: linux/amd64
steps:
lint:
@ -42,17 +42,14 @@ steps:
- develop
- stable
image: node:20
environment:
SCW_ACCESS_KEY:
from_secret: SCW_ACCESS_KEY
SCW_SECRET_KEY:
from_secret: SCW_SECRET_KEY
SCW_DEFAULT_ORGANIZATION_ID:
from_secret: SCW_DEFAULT_ORGANIZATION_ID
secrets:
- SCW_ACCESS_KEY
- SCW_SECRET_KEY
- SCW_DEFAULT_ORGANIZATION_ID
commands:
- apt-get update && apt-get install -y rclone wget zip
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_arm64
- mv scaleway-cli_2.30.0_linux_arm64 scaleway-cli
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_amd64
- mv scaleway-cli_2.30.0_linux_amd64 scaleway-cli
- chmod +x scaleway-cli
- ./scaleway-cli object config install type=rclone
- zip akkoma-fe.zip -r dist
@ -67,17 +64,15 @@ steps:
- stable
environment:
CI: "true"
SCW_ACCESS_KEY:
from_secret: SCW_ACCESS_KEY
SCW_SECRET_KEY:
from_secret: SCW_SECRET_KEY
SCW_DEFAULT_ORGANIZATION_ID:
from_secret: SCW_DEFAULT_ORGANIZATION_ID
image: python:3.10-slim
secrets:
- SCW_ACCESS_KEY
- SCW_SECRET_KEY
- SCW_DEFAULT_ORGANIZATION_ID
commands:
- apt-get update && apt-get install -y rclone wget git zip
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_arm64
- mv scaleway-cli_2.30.0_linux_arm64 scaleway-cli
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_amd64
- mv scaleway-cli_2.30.0_linux_amd64 scaleway-cli
- chmod +x scaleway-cli
- ./scaleway-cli object config install type=rclone
- cd docs

View file

@ -4,55 +4,11 @@ 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
### Changed
- various minor visual styling enhancements
## 2026.05 (3.19) - 2026-05-04
### Added
- lists UI can now read and set the "exclusive" parameter, allowing members to be removed from the home timeline
- user profiles now have a small gallery for profile media
- alt text of user profile media is now exposed and can be edited in profile settings
- if known, polls now show what promise wrt to vote anonymity was made
### Fixed
- fix error on list creation preventing initial accounts from being actually added
- fix notifications on mobile
- fix attachment display for remotes not federating any MIME type indicators if they still indicate a generic type.
This applies to e.g. bridgy
- fixed some spacing issues after mentions
- MFM statuses now use the same emoji base size as *keys for better compatability
### Changed
- reworked rich content (anything with custom emoji or not pure plaintext) parsing;
there _should_ be no visible changes except fixing obviously broken edgecases
and perhaps more reliable green- and cyantext styling
- the frontend now also applies its own HTML sanitisation instead of relying solely on the backends sanitisation;
this shouldn't cause any visible changes but further hardens against potential future bugs
- various minor visual styling enhancements
## 2026.03 (3.18.0) - 2026-03-14
### REMOVED
- dropped obsolete and buggy dm timeline
### Added
- UI for conversations API, replacing the DM timeline.
Here each thread (conversation) has its own timeline and read markers instead of mixing everything together.
- Boosts now show when and with which visibility they were boosted
- bookmarks are now accessible via the narrow/mobile UI
### Fixed
- fixed saving fallback cop yof settings to local browser storage
- improve image animation detection further
- fix status content parsing for mention and hashtag detection; this could lock the UI until reload
- fix display of nsfw attachment overlays on webkit
## Between 2022.09 (3.2.0) and 2025.12 (3.17.0)
A whole lot of stuff, but we forgot to update the changelog besides the one entry below, oopsi
- Implemented remote interaction with statuses
## 2022.09 (3.2.0) - 2022-09-10
## 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.

View file

@ -24,7 +24,7 @@ Make sure you have [Node.js](https://nodejs.org/) installed. You can check `/.wo
``` bash
# install dependencies
npm install -g corepack
corepack enable
yarn
# serve with hot reload at localhost:8080
@ -55,4 +55,3 @@ Edit config.json for configuration.
### Login methods
```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations.

View file

@ -15,13 +15,12 @@ put a file that looks like this
```json
{
"myPack": "/static/stickers/myPack/"
"myPack": "/static/stickers/myPack"
}
```
This file is a mapping from name to pack directory location. It says "we have a pack called myPack, look for
it inside `/static/stickers/myPack`". You can add as many packs as you like in this manner.
Note that a single leading and a trailing slash are **required** to work correctly!
it at `/static/stickers/myPack`". You can add as many packs as you like in this manner.
## Creating the pack

View file

@ -6,6 +6,7 @@
<title>Akkoma</title>
<link rel="stylesheet" href="/static/font/tiresias.css">
<link rel="stylesheet" href="/static/font/css/lato.css">
<link rel="stylesheet" href="/static/mfm.css">
<link rel="stylesheet" href="/static/custom.css">
<link rel="stylesheet" href="/static/theme-holder.css" id="theme-holder">
<!--server-generated-meta-->

View file

@ -1,6 +1,6 @@
{
"name": "pleroma_fe",
"version": "3.19.0",
"version": "3.10.0",
"description": "A frontend for Akkoma instances",
"author": "Roger Braun <roger@rogerbraun.net>",
"private": true,
@ -31,7 +31,6 @@
"click-outside-vue3": "^4.0.1",
"cropperjs": "^1.6.2",
"diff": "^5.2.0",
"dompurify": "^3.3.3",
"escape-html": "^1.0.3",
"iso-639-1": "^2.1.15",
"js-cookie": "^3.0.1",
@ -129,6 +128,5 @@
"engines": {
"node": ">= 16.0.0",
"npm": ">= 3.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}

View file

@ -59,8 +59,7 @@ export default {
{
'-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown,
'-wide-timeline': this.widenTimeline
'-has-new-post-button': this.newPostButtonShown
},
'-' + this.layoutType
]
@ -94,9 +93,6 @@ export default {
newPostButtonShown () {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
widenTimeline () {
return this.$store.getters.mergedConfig.widenTimeline
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
editingAvailable () { return this.$store.state.instance.editingAvailable },
layoutType () { return this.$store.state.interface.layoutType },

View file

@ -172,10 +172,6 @@ nav {
background-color: rgba(0, 0, 0, 0.15);
background-color: var(--underlay, rgba(0, 0, 0, 0.15));
z-index: -1000;
.-wide-timeline & {
margin:0 calc(var(--columnGap) / -2);
}
}
.app-layout {
@ -191,17 +187,12 @@ nav {
grid-template-rows: 1fr;
box-sizing: border-box;
margin: 0 auto;
padding: 0 calc(var(--columnGap) / 2);
align-content: flex-start;
flex-wrap: wrap;
justify-content: center;
min-height: 100vh;
overflow-x: clip;
&.-wide-timeline {
--maxiColumn: minmax(var(--miniColumn), 1fr);
}
.column {
--___columnMargin: var(--columnGap);

View file

@ -12,6 +12,7 @@ import routes from './routes'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js'
@ -182,12 +183,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('renderMisskeyMarkdown')
copyInstanceOption('sidebarRight')
if (config.backendCommitUrl)
copyInstanceOption('backendCommitUrl')
if (config.frontendCommitUrl)
copyInstanceOption('frontendCommitUrl')
return store.dispatch('setTheme', config['theme'])
}
@ -252,6 +247,17 @@ const getStickers = async ({ store }) => {
}
}
const getAppSecret = async ({ store }) => {
const { state, commit } = store
const { oauth, instance } = state
return getOrCreateApp({ ...oauth, instance: instance.server, commit })
.then((app) => getClientToken({ ...app, instance: instance.server }))
.then((token) => {
commit('setAppToken', token.access_token)
commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
})
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop())
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
@ -339,7 +345,7 @@ const setConfig = async ({ store }) => {
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig })
await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store }))
}
const checkOAuthToken = async ({ store }) => {

View file

@ -1,14 +1,12 @@
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 DMConvTimeline from 'components/dm_conv_timeline/dm_conv_timeline.vue'
import DMConvList from 'components/dm_conv_list/dm_conv_list.vue'
import DMConvRecipients from 'components/dm_conv_recipients/dm_conv_recipients.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue'
import BubbleTimeline from 'components/bubble_timeline/bubble_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'
import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
import Registration from 'components/registration/registration.vue'
@ -49,9 +47,6 @@ export default (store) => {
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'bubble-timeline', path: '/main/bubble', component: BubbleTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/main/conversations', component: DMConvList, beforeEnter: validateAuthenticatedRoute },
{ name: 'dm_conversation', path: '/main/conversations/:id', component: DMConvTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'dm-conversation-recipients', path: '/main/conversations/:id/recipients', component: DMConvRecipients },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
@ -67,6 +62,7 @@ export default (store) => {
},
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile, meta: { dontScroll: true } },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'registration-request-sent', path: '/registration-request-sent', component: RegistrationRequestSent },
{ name: 'awaiting-email-confirmation', path: '/awaiting-email-confirmation', component: AwaitingEmailConfirmation },

View file

@ -54,7 +54,7 @@ const Attachment = {
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false,
img: fileTypeService.fileType(this.attachment) === 'image' && document.createElement('img'),
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
@ -105,7 +105,7 @@ const Attachment = {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
},
type () {
return fileTypeService.fileType(this.attachment)
return fileTypeService.fileType(this.attachment.mimetype)
},
hidden () {
return this.nsfw && this.hideNsfwLocal && !this.showHidden

View file

@ -19,17 +19,6 @@
height: 200px;
position: relative;
overflow: hidden;
align-content: center;
.status-popover & {
height: 200px;
}
}
&.-nsfw-placeholder {
.attachment-wrapper {
align-content: unset;
}
}
.description-container {
@ -126,24 +115,6 @@
align-items: center;
justify-content: center;
padding-top: 0.5em;
p {
line-height: 1.5;
padding: 0 0.5em;
white-space: pre-line;
text-align: center;
max-height: 200px;
overflow-y: auto;
scrollbar-color: var(--border) #0000;
width: 100%;
box-sizing: border-box;
.status-popover & {
text-overflow: ellipsis;
overflow: hidden;
height: 1lh;
}
}
}

View file

@ -267,11 +267,11 @@ const conversation = {
},
replies () {
let i = 1
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
const irid = in_reply_to_status_id
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
@ -414,14 +414,6 @@ const conversation = {
},
toggleExpanded () {
this.expanded = !this.expanded
const navHeight = document.getElementById("nav").offsetHeight
const headingHeight = document.getElementsByClassName("timeline-heading")[0].offsetHeight
document.documentElement.style.setProperty("--timeline-scroll-margin-top", `${navHeight + headingHeight}px`)
this.$nextTick(() => {
if (!this.expanded) {
this.$el.scrollIntoView({ block: 'nearest' })
}
})
},
getConversationId (statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]

View file

@ -278,7 +278,5 @@
&.-expanded.status-fadein {
margin: calc(var(--status-margin, $status-margin) / 2);
}
scroll-margin-block-start: var(--timeline-scroll-margin-top);
}
</style>

View file

@ -105,18 +105,6 @@
v-if="(currentUser || !privateMode) && showNavShortcuts"
class="nav-items right"
>
<router-link
v-if="currentUser"
class="nav-icon"
:to="{ name: 'dms' }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="envelope"
:title="$t('nav.dm_conversations')"
/>
</router-link>
<router-link
v-if="currentUser"
class="nav-icon"

View file

@ -1,80 +0,0 @@
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
const DMConvCard = {
components: {
ConfirmModal,
Status,
UserAvatar
},
props: {
conversation: {
type: Object,
required: true
},
compact: {
type: Boolean,
default: true
},
showLastStatus: {
type: Boolean,
default: true
},
showFullControls: {
type: Boolean,
default: false
}
},
emits: ['deleted'],
data () {
return {
showingDeleteConfirmDialogue: false
}
},
computed: {
shouldConfirmDelete() {
return this.$store.getters.mergedConfig.modalOnDeleteDMConversation;
},
membersTruncated() {
// XXX: this should ideally adapt to panel width
const maxLen = 11
const full = this.conversation.accounts
const truncated = full.length > maxLen
const truncList = truncated ? full.slice(0, maxLen) : full
return {
truncated: truncated,
users: truncList
}
},
last_status_text() {
return this.conversation.last_status?.content
}
},
methods: {
markRead() {
this.$store.dispatch('markDMConversationAsRead', { id: this.conversation.id })
},
showDeleteConfirmModal() {
this.showingDeleteConfirmDialogue = true
},
hideDeleteConfirmModal() {
this.showingDeleteConfirmDialogue = false
},
deleteConversation() {
if (this.shouldConfirmDelete) {
this.showDeleteConfirmModal()
} else {
this.doDeleteConversation()
}
},
doDeleteConversation() {
this.$store.dispatch('deleteDMConversation', { id: this.conversation.id })
this.hideDeleteConfirmModal()
this.$emit('deleted')
}
}
}
export default DMConvCard

View file

@ -1,134 +0,0 @@
<template>
<div class="dm-conv-card">
<router-link
:to="{ name: 'dm_conversation', params: {id: conversation.id }}"
>
<div class="heading">
<div class="title-bar">
<div class="title-bar-left">
<div
v-if="conversation.unread"
class="unread"
>
<span
class="badge badge-notification"
role="figure"
:title="$t('dm_conv.unread_msg')"
:alt="$t('dm_conv.unread_msg')"
>
!
</span>
<button
class="button-unstyled"
:title="$t('dm_conv.mark_single_read_tooltip')"
@click.stop.prevent="markRead()"
>
<FAIcon
icon="check"
class="fa-scale-110 fa-old-padding dm-conv-mark-read"
/>
</button>
&nbsp;
</div>
<h4>{{ $t('dm_conv.default_name', {id: conversation.id}) }}</h4>
</div>
<div class="title-bar-right">
<button
class="button-unstyled button-delete"
:title="$t('dm_conv.delete_tooltip')"
@click.stop.prevent="deleteConversation()"
>
<FAIcon
icon="trash-alt"
class="fa-scale-110 fa-old-padding dm-conv-delete"
/>
</button>
</div>
</div>
</div>
<div class="members">
<UserAvatar
v-for="user in membersTruncated.users"
:key="user.id"
:user="user"
:compact="compact"
/>
<div
v-if="membersTruncated.truncated"
class="ellipsis"
>
...
</div>
</div>
</router-link>
<div
v-if="showLastStatus"
class="last-message"
>
<div class="last-message-title">
{{ $t('dm_conv.last_message_title') }}:
</div>
<Status
:statusoid="conversation.last_status"
:compact="true"
:is-preview="true"
/>
</div>
<div
v-if="showFullControls"
class="controls"
>
<button
class="btn button-default"
:title="$t('dm_conv.recipients_edit_mode_button_tooltip')"
@click.once="$router.push({ name: 'dm-conversation-recipients', params: { id: conversation.id }})"
>
{{ $t('dm_conv.recipients_edit_mode_button') }}
</button>
</div>
<teleport to="#modal">
<confirm-modal
v-if="showingDeleteConfirmDialogue"
:title="$t('dm_conv.delete_confirm_title')"
:confirm-text="$t('dm_conv.delete_confirm_accept_button')"
:cancel-text="$t('dm_conv.delete_confirm_cancel_button')"
@accepted="doDeleteConversation"
@cancelled="hideDeleteConfirmModal"
>
{{ $t('dm_conv.delete_confirm', { identifier: conversation.id }) }}
</confirm-modal>
</teleport>
</div>
</template>
<script src="./dm_conv_card.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.dm-conv-card {
.heading, .title-bar, .title-bar-left, .members {
display: flex;
flex-wrap: nowrap;
overflow-x: hidden;
}
.title-bar {
width: 100%;
justify-content: space-between;
}
.controls {
text-align: center;
}
.members {
padding: 6px 0;
}
.last-message-title {
font-style: italic;
color: var(--faint);
}
}
</style>

View file

@ -1,33 +0,0 @@
import DMConvCard from '../dm_conv_card/dm_conv_card.vue'
import List from '../list/list.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
const PaginatedDMConvList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchDMConversationList'),
select: (props, $store) => $store.state.dmConversations.allDMConversations || [],
destroy: (props, $store) => $store.dispatch('clearDMConversations'),
childPropName: 'items',
additionalPropNames: []
})(List)
const DMConvList = {
components: {
PaginatedDMConvList,
DMConvCard
},
data () {
return {}
},
computed: {
conversations() {
return this.$store.state.dmConversations.allDMConversations
}
},
methods: {
markAllRead() {
this.$store.dispatch('markAllDMConversationsAsRead')
}
}
}
export default DMConvList

View file

@ -1,47 +0,0 @@
<template>
<div class="settings panel panel-default dm-conv-panel">
<div class="panel-heading">
<div class="title">
{{ $t('nav.dm_conv_list') }}
</div>
</div>
<div class="panel-controls">
<button
class="btn button-default mark-all-read-button"
@click="markAllRead()"
>
{{ $t('dm_conv.mark_all_read_button') }}
</button>
</div>
<div class="panel-body dm-conv-list">
<PaginatedDMConvList>
<template #item="{item}">
<DMConvCard :conversation="item" />
</template>
</PaginatedDMConvList>
</div>
</div>
</template>
<script src="./dm_conv_list.js"></script>
<style lang="scss">
.dm-conv-panel {
.dm-conv-list {
margin: 0 1em;
.dm-conv-card {
margin: 2.5em 0;
}
}
.panel-controls {
margin-top: 0.5em;
text-align: center;
}
.mark-all-read-button {
display: inline-block;
}
}
</style>

View file

@ -1,57 +0,0 @@
import { mapGetters } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import ListUserSearch from '../list_user_search/list_user_search.vue'
const DMConvRecipients = {
data () {
return {
conversationId: null,
conversation: null,
recipients: [],
suggestions: []
}
},
components: {
BasicUserCard,
ListUserSearch
},
computed: {
conversationTitle () {
return this.$i18n.t('dm_conv.default_name', {id: this.conversationId})
},
...mapGetters(['findUser'])
},
methods: {
toggleUser (user) {
if (this.isRecipient(user)) {
this.recipients.filter((r) => r.id !== user.id)
} else {
this.recipients.push(user)
}
},
isRecipient (user) {
return this.recipients.some((r) => r.id == user.id)
},
onResults (results) {
this.suggestions = results.map((id) => this.findUser(id)).filter(user => user)
},
updateRecipients () {
const recipientIds = this.recipients.map((u) => u.id)
this.$store.dispatch('setDMConversationDetails', {id: this.conversationId, recipients: recipientIds })
.then((updateConv) => {
this.conversation = updateConv
this.recipients = updateConv.accounts
})
}
},
created () {
this.conversationId = this.$route.params.id
this.$store.dispatch('fetchDMConversationDetails', { id: this.conversationId })
.then((data) => {
this.conversation = data
this.recipients = data.accounts
})
},
}
export default DMConvRecipients

View file

@ -1,82 +0,0 @@
<template>
<div class="panel-default panel dm-conv-recipients-edit">
<div
ref="header"
class="panel-heading"
>
<div class="title">
{{ $t('dm_conv.recipients_edit_title', {conversation_name: conversationTitle}) }}
</div>
<button
class="btn button-default"
@click="$router.back"
>
{{ $t('nav.back') }}
</button>
</div>
<h4>
{{ $t('dm_conv.recipients_edit_current_title') }}
</h4>
<div class="member-list current-recipients">
<div
v-for="user in recipients"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
class="selected"
@click.capture.prevent="toggleUser(user)"
/>
</div>
</div>
<h4>
{{ $t('dm_conv.recipients_edit_add_new_title') }}
</h4>
<ListUserSearch @results="onResults" />
<div class="member-list">
<div
v-for="user in suggestions"
:key="user.id"
class="member"
>
<BasicUserCard
:user="user"
:class="isRecipient(user) ? 'selected' : ''"
@click.capture.prevent="toggleUser(user)"
/>
</div>
</div>
<button
class="btn button-default"
@click="updateRecipients"
>
{{ $t('dm_conv.recipients_save') }}
</button>
</div>
</template>
<script src="./dm_conv_recipients.js"></script>
<style lang="scss">
@import '../../_variables.scss';
.dm-conv-recipients-edit {
.member-list {
padding-bottom: 0.7rem;
}
.current-recipients {
margin-bottom: 1.5em;
}
.basic-user-card:hover,
.basic-user-card.selected {
cursor: pointer;
background-color: var(--selectedPost, $fallback--lightBg);
}
}
</style>

View file

@ -1,34 +0,0 @@
import DMConvCard from '../dm_conv_card/dm_conv_card.vue'
import Timeline from '../timeline/timeline.vue'
const DMConvTimeline = {
data () {
return {
conversationId: null
}
},
components: {
DMConvCard,
Timeline
},
computed: {
conversation () { return this.$store.getters.getDMConversationById(this.conversationId) },
timeline () { return this.$store.state.statuses.timelines.dmConv }
},
methods: {
forceLeave () {
this.$router.push('/')
}
},
created () {
this.conversationId = this.$route.params.id
this.$store.dispatch('fetchDMConversationDetails', { id: this.conversationId })
this.$store.dispatch('startFetchingTimeline', { timeline: 'dmConv', conversationId: this.conversationId })
},
unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'dmConv')
this.$store.commit('clearTimeline', { timeline: 'dmConv' })
}
}
export default DMConvTimeline

View file

@ -1,24 +0,0 @@
<template>
<Timeline
title="$t('dm_conv.page_header')"
:timeline="timeline"
:conversation-id="conversationId"
timeline-name="dmConv"
>
<template
#extraHeading
>
<DMConvCard
v-if="conversation"
:conversation="conversation"
:compact="false"
:show-full-controls="true"
:show-last-status="false"
:link-to-timeline="false"
@deleted="forceLeave"
/>
</template>
</Timeline>
</template>
<script src="./dm_conv_timeline.js"></script>

View file

@ -0,0 +1,14 @@
import Timeline from '../timeline/timeline.vue'
const DMs = {
computed: {
timeline () {
return this.$store.state.statuses.timelines.dms
}
},
components: {
Timeline
}
}
export default DMs

View file

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

View file

@ -1,5 +1,3 @@
import StillImage from '../still-image/still-image.vue'
const EMOJI_SIZE = 32 + 8
const GROUP_TITLE_HEIGHT = 24
const BUFFER_SIZE = 3 * EMOJI_SIZE
@ -19,9 +17,6 @@ const EmojiGrid = {
resizeObserver: null
}
},
components: {
StillImage
},
mounted () {
const rect = this.$refs.container.getBoundingClientRect()
this.containerWidth = rect.width

View file

@ -34,11 +34,10 @@
@click.stop.prevent="onEmoji(item.emoji)"
>
<span v-if="!item.emoji.imageUrl">{{ item.emoji.replacement }}</span>
<StillImage
<img
v-else
:src="item.emoji.imageUrl"
no-stop-gifs="true"
/>
>
</span>
</template>
</div>

View file

@ -1,6 +1,5 @@
import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import StillImage from '../still-image/still-image.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
@ -121,8 +120,7 @@ const EmojiInput = {
}
},
components: {
EmojiPicker,
StillImage
EmojiPicker
},
computed: {
padEmoji () {

View file

@ -20,7 +20,6 @@
ref="picker"
show-keep-open
:class="{ hide: !showPicker }"
:visible="showPicker"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
@emoji="insert"
@ -48,11 +47,10 @@
v-if="!suggestion.mfm"
class="image"
>
<StillImage
<img
v-if="suggestion.img"
:src="suggestion.img"
no-stop-gifs="true"
/>
>
<span v-else>{{ suggestion.replacement }}</span>
</span>
<div class="label">

View file

@ -1,4 +1,4 @@
const MFM_TAGS = ['bg', 'blur', 'bounce', 'center', 'fg', 'flip', 'font', 'jelly', 'jump', 'position', 'rainbow', 'rotate', 'scale', 'shake', 'sparkle', 'spin', 'tada', 'twitch', 'x2', 'x3', 'x4']
const MFM_TAGS = ['blur', 'bounce', 'flip', 'font', 'jelly', 'jump', 'rainbow', 'rotate', 'shake', 'sparkle', 'spin', 'tada', 'twitch', 'x2', 'x3', 'x4']
.map(tag => ({ displayText: tag, detailText: '$[' + tag + ' ]', replacement: '$[' + tag + ' ]', mfm: true }))
/**
@ -71,7 +71,7 @@ export const suggestUsers = ({ dispatch, state }) => {
let timeout = null
let cancelUserSearch = null
const userSearch = (query) => dispatch('searchUsers', { query, resolve: false })
const userSearch = (query) => dispatch('searchUsers', { query })
const debounceUserSearch = (query) => {
cancelUserSearch && cancelUserSearch()
return new Promise((resolve, reject) => {
@ -86,20 +86,19 @@ export const suggestUsers = ({ dispatch, state }) => {
}
return async input => {
if (previousQuery === input) return suggestions
const noPrefix = input.toLowerCase().substr(1)
if (previousQuery === noPrefix) return suggestions
suggestions = []
previousQuery = input
// if there are more than two @, its not a valid nick
if (input.match(/@/g)?.length > 2) {
return []
previousQuery = noPrefix
// Fetch more and wait, don't fetch if there's the 2nd @ because
// the backend user search can't deal with it.
// Reference semantics make it so that we get the updated data after
// the await.
if (!noPrefix.includes('@')) {
await debounceUserSearch(noPrefix)
}
// fetch new matching users into our cache
await debounceUserSearch(input)
const noPrefix = input.toLowerCase().substr(1)
const newSuggestions = state.users.users.filter(
user =>
user.screen_name.toLowerCase().startsWith(noPrefix) ||

View file

@ -1,7 +1,6 @@
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
import EmojiGrid from '../emoji_grid/emoji_grid.vue'
import StillImage from '../still-image/still-image.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
@ -27,17 +26,12 @@ const EmojiPicker = {
required: false,
type: Boolean,
default: false
},
visible: {
required: false,
type: Boolean,
default: true
}
},
data () {
return {
keyword: '',
activeGroup: this.getDefaultGroup(),
activeGroup: 'standard',
showingStickers: false,
keepOpen: false
}
@ -45,8 +39,7 @@ const EmojiPicker = {
components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox,
EmojiGrid,
StillImage
EmojiGrid
},
methods: {
debouncedSearch: debounce(function (e) {
@ -89,11 +82,6 @@ const EmojiPicker = {
return list.filter(emoji => {
return (regex.test(emoji.displayText) || (!emoji.imageUrl && emoji.replacement === this.keyword))
})
},
getDefaultGroup () {
if (!this.visible) return null
const recentEmojis = this.$store.getters.recentEmojis
return recentEmojis.length === 0 ? 'standard' : 'recent'
}
},
computed: {
@ -160,13 +148,6 @@ const EmojiPicker = {
stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0 && this.enableStickerPicker
}
},
watch: {
visible (val, oldVal) {
if (val && this.activeGroup === null) {
this.activeGroup = this.getDefaultGroup()
}
}
}
}

View file

@ -18,11 +18,10 @@
@click.prevent="highlight(group.id)"
>
<span v-if="!group.first.imageUrl">{{ group.first.replacement }}</span>
<StillImage
<img
v-else
:src="group.first.imageUrl"
no-stop-gifs="true"
/>
>
</span>
<span
v-if="stickerPickerEnabled"

View file

@ -11,7 +11,7 @@
@click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()"
>
<template
<span
v-if="reaction.url !== null"
>
<StillImage
@ -19,15 +19,16 @@
:title="reaction.name"
:alt="reaction.name"
class="reaction-emoji"
height="2.55em"
/>
{{ reaction.count }}
</template>
<template v-else>
</span>
<span v-else>
<span class="reaction-emoji unicode-emoji">
{{ reaction.name }}
</span>
<span>{{ reaction.count }}</span>
</template>
</span>
</button>
</UserListPopover>
<a
@ -52,26 +53,23 @@
container-type: inline-size;
}
.unicode-emoji {
font-size: 210%;
}
.emoji-reaction {
padding: 2px 0.5em;
padding: 0 0.5em;
margin-right: 0.5em;
margin-top: 0.5em;
display: flex;
align-items: end;
align-items: center;
justify-content: center;
box-sizing: border-box;
.reaction-emoji {
width: auto;
max-width: 96cqw;
height: 2.55em !important;
margin-right: 0.25em;
&.still-image {
height: 2.55em;
}
&.unicode-emoji {
display: inline-block;
font-size: 2.125em; // assuming default line height of 1.2rem and emojis that don't exceed line height
line-height: 2.55rem;
}
}
&:focus {
outline: none;
@ -99,9 +97,9 @@
}
.button-default.picked-reaction {
&, &:hover {
box-shadow: inset 0 0 0 1px var(--accent, $fallback--link);
}
border: 1px solid var(--accent, $fallback--link);
margin-left: -1px; // offset the border, can't use inset shadows either
margin-right: calc(0.5em - 1px);
}
</style>

View file

@ -91,7 +91,7 @@ const ExtraButtons = {
.catch(err => this.$emit('onError', err.error.error))
},
copyLink () {
navigator.clipboard.writeText(this.status.canonical_id)
navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess'))
.catch(err => this.$emit('onError', err.error.error))
},
@ -187,6 +187,13 @@ const ExtraButtons = {
noTranslationTargetSet () {
return this.$store.getters.mergedConfig.translationLanguage === undefined
},
statusLink () {
if (this.status.is_local) {
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
} else {
return this.status.external_url
}
},
shouldConfirmDelete () {
return this.$store.getters.mergedConfig.modalOnDelete
},

View file

@ -88,8 +88,10 @@ const Gallery = {
set(this.sizes, id, { width, height })
},
rowStyle (row) {
if (!row.audio && !row.minimal && !row.grid) {
return { 'aspect-ratio': `1/${(1 / (row.items.length + 0.6))}` }
if (row.audio) {
return { 'padding-bottom': '25%' } // fixed reduced height for audio
} else if (!row.minimal && !row.grid) {
return { 'padding-bottom': `${(100 / (row.items.length + 0.6))}%` }
}
},
itemStyle (id, row) {

View file

@ -96,15 +96,9 @@
.gallery-row {
position: relative;
height: 0;
width: 100%;
flex-grow: 1;
.Status & {
max-height: 30em;
}
&.-audio {
aspect-ratio: 4/1; // this is terrible, but it's how it was before so I'm not changing it >:(
}
&:not(:first-child) {
margin-top: 0.5em;

View file

@ -22,17 +22,13 @@ const ListNew = {
data () {
return {
title: '',
exclusive: false,
userIds: [],
selectedUserIds: []
}
},
created () {
this.$store.dispatch('fetchList', { id: this.id })
.then((list) => {
this.title = list.title
this.exclusive = !!list.exclusive
})
.then(() => { this.title = this.findListTitle(this.id) })
this.$store.dispatch('fetchListAccounts', { id: this.id })
.then(() => {
this.selectedUserIds = this.findListAccounts(this.id)
@ -80,7 +76,7 @@ const ListNew = {
this.userIds = results
},
updateList () {
this.$store.dispatch('setList', { id: this.id, title: this.title, exclusive: this.exclusive })
this.$store.dispatch('setList', { id: this.id, title: this.title })
this.$store.dispatch('setListAccounts', { id: this.id, accountIds: this.selectedUserIds })
this.$router.push({ name: 'list-timeline', params: { id: this.id } })

View file

@ -21,17 +21,6 @@
:placeholder="$t('lists.title')"
>
</div>
<div class="input-wrap">
<input
type="checkbox"
id="list-exclusive-input"
ref="exclusive"
v-model="exclusive"
>
<label for="list-exclusive-input">
{{ $t('lists.exclusive_description') }}
</label>
</div>
<div class="member-list">
<div
v-for="user in selectedUsers"

View file

@ -22,7 +22,6 @@ const ListNew = {
data () {
return {
title: '',
exclusive: false,
userIds: [],
selectedUserIds: []
}
@ -68,7 +67,7 @@ const ListNew = {
createList () {
// the API has two different endpoints for "creating a list with a name"
// and "updating the accounts on the list".
this.$store.dispatch('createList', { title: this.title, exclusive: this.exclusive })
this.$store.dispatch('createList', { title: this.title })
.then((list) => {
this.$store.dispatch('setListAccounts', { id: list.id, accountIds: this.selectedUserIds })
this.$router.push({ name: 'list-timeline', params: { id: list.id } })

View file

@ -21,17 +21,6 @@
:placeholder="$t('lists.title')"
>
</div>
<div class="input-wrap">
<input
type="checkbox"
id="list-exclusive-input"
ref="exclusive"
v-model="exclusive"
>
<label for="list-exclusive-input">
{{ $t('lists.exclusive_description') }}
</label>
</div>
<div class="member-list">
<div

View file

@ -43,7 +43,7 @@ const LoginForm = {
}
oauthApi.getOrCreateApp(data)
.then((app) => { oauthApi.login({ ...data, ...app }) })
.then((app) => { oauthApi.login({ ...app, ...data }) })
},
submitPassword () {
const { clientId } = this.oauth

View file

@ -67,7 +67,7 @@ const MediaModal = {
},
methods: {
getType (media) {
return fileTypeService.fileType(media)
return fileTypeService.fileType(media.mimetype)
},
hide () {
// HACK: Closing immediately via a touch will cause the click

View file

@ -24,15 +24,14 @@
:min-scale="pinchZoomMinScale"
:reset-to-min-scale-limit="pinchZoomScaleResetLimit"
>
<StillImage
<img
:class="{ loading }"
class="modal-image"
:src="currentMedia.url"
:alt="currentMedia.description"
:title="currentMedia.description"
:image-load-handler="onImageLoaded"
no-stop-gifs="true"
/>
@load="onImageLoaded"
>
</PinchZoom>
</SwipeClick>
<VideoAttachment

View file

@ -42,14 +42,8 @@ const mediaUpload = {
.then((fileData) => {
self.$emit('uploaded', fileData)
self.decreaseUploadCount()
}, (error) => {
var msg = typeof error === "string" ? error : error.message
if (msg) {
self.$emit('upload-failed', 'message', [msg])
} else {
self.$emit('upload-failed', 'default')
}
console.warn(`Failed to upload media: ${error}`)
}, (error) => {
self.$emit('upload-failed', 'default')
self.decreaseUploadCount()
})
},

View file

@ -53,9 +53,6 @@ const NavPanel = {
federating: state => state.instance.federating,
}),
...mapGetters(['unreadAnnouncementCount']),
unreadDMConversationsCount () {
return this.$store.state.users.currentUser?.pleroma?.unread_conversation_count || 0
},
followRequestCount () {
return this.$store.state.users.currentUser.follow_requests_count
}

View file

@ -25,24 +25,6 @@
<TimelineMenuContent class="timelines" />
</div>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'dms' }"
>
<FAIcon
fixed-width
class="fa-scale-110"
icon="envelope"
/>{{ $t("nav.dm_conversations") }}
<span
v-if="unreadDMConversationsCount > 0"
class="badge badge-notification"
>
{{ unreadDMConversationsCount }}
</span>
</router-link>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"

View file

@ -1,10 +1,6 @@
import Timeago from 'components/timeago/timeago.vue'
import RichContent from 'components/rich_content/rich_content.jsx'
import { forEach, map } from 'lodash'
import {
faCircleCheck,
faTriangleExclamation
} from '@fortawesome/free-solid-svg-icons'
export default {
name: 'Poll',
@ -54,13 +50,6 @@ export default {
totalVotesCount () {
return this.poll.votes_count
},
totalFractionBase () {
// Due to a backend bug, we might not have any voter count info for remote polls
// in this case, fall back to count of votes even for multiple cjoice polls
// to be able to at least display _something_
const total_base = this.poll.multiple ? this.poll.voters_count : this.poll.votes_count
return total_base > 0 ? total_base : this.poll.votes_count
},
containerClass () {
return {
loading: this.loading
@ -81,11 +70,10 @@ export default {
},
methods: {
percentageForOption (count) {
const total = this.totalFractionBase
return total === 0 ? 0 : Math.round(count / total * 100)
return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100)
},
resultTitle (option) {
return `${option.votes_count}/${this.totalFractionBase} ${this.$t('polls.votes')}`
return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}`
},
fetchPoll () {
this.$store.dispatch('refreshPoll', { id: this.statusId, pollId: this.poll.id })

View file

@ -53,24 +53,6 @@
</label>
</div>
</div>
<div class="poll-hint">
<div
v-if="poll.akkoma?.anonymous === true"
class="alert success"
>
<FAIcon icon="check-circle" />
&nbsp;
{{ $t('polls.indicate_anonymous') }}
</div>
<div
v-else-if="poll.akkoma?.anonymous === false"
class="alert warning"
>
<FAIcon icon="triangle-exclamation" />
&nbsp;
{{ $t('polls.indicate_disclosure') }}
</div>
</div>
<div class="footer faint">
<button
v-if="!showResults"
@ -162,9 +144,6 @@
display: flex;
align-items: center;
}
.poll-hint {
margin: 0.25em 0;
}
&.loading * {
cursor: progress;
}

View file

@ -24,7 +24,6 @@
<button
v-if="options.length > 2"
class="delete-option button-unstyled -hover-highlight"
type="button"
@click="deleteOption(index)"
>
<FAIcon icon="times" />
@ -33,7 +32,6 @@
<button
v-if="options.length < maxOptions"
class="add-option faint button-unstyled -hover-highlight"
type="button"
@click="addOption"
>
<FAIcon

View file

@ -10,7 +10,6 @@ import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { reject, map, uniqBy, debounce } from 'lodash'
import { usePostLanguageOptions } from 'src/lib/post_language'
import scopeUtils from 'src/lib/scope_utils.js'
import suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue'
@ -86,7 +85,6 @@ const PostStatusForm = {
'quoteId',
'repliedUser',
'attentions',
'copyMessageLanguage',
'copyMessageScope',
'subject',
'disableSubject',
@ -150,12 +148,13 @@ const PostStatusForm = {
const preset = this.$route.query.message
let statusText = preset || ''
if (this.replyTo || this.quoteId || this.repliedUser) {
if (this.replyTo || this.quoteId) {
const currentUser = this.$store.state.users.currentUser
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
}
const { postContentType: contentType, sensitiveByDefault, sensitiveIfSubject, alwaysShowSubjectInput } = this.$store.getters.mergedConfig
const { postContentType: contentType, postLanguage: defaultPostLanguage, sensitiveByDefault, sensitiveIfSubject, interfaceLanguage, alwaysShowSubjectInput } = this.$store.getters.mergedConfig
const postLanguage = defaultPostLanguage || interfaceToISOLanguage(interfaceLanguage)
let statusParams = {
spoilerText: this.subject || '',
@ -166,7 +165,7 @@ const PostStatusForm = {
poll: {},
mediaDescriptions: {},
visibility: this.suggestedVisibility(),
language: this.suggestedLanguage(),
language: postLanguage,
contentType
}
@ -181,7 +180,7 @@ const PostStatusForm = {
poll: this.statusPoll || {},
mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || this.suggestedVisibility(),
language: this.statusLanguage || this.suggestedLanguage(),
language: this.statusLanguage || postLanguage,
contentType: statusContentType
}
}
@ -330,7 +329,6 @@ const PostStatusForm = {
watch: {
'newStatus': {
deep: true,
flush: 'sync',
handler () {
this.statusChanged()
}
@ -343,22 +341,17 @@ const PostStatusForm = {
this.saveDraft()
},
clearStatus () {
const config = this.$store.getters.mergedConfig
const newStatus = this.newStatus
this.newStatus = {
status: '',
spoilerText: '',
files: [],
nsfw: !!config.sensitiveByDefault,
visibility: this.suggestedVisibility(),
contentType: config.postContentType,
language: this.suggestedLanguage(),
visibility: newStatus.visibility,
contentType: newStatus.contentType,
language: newStatus.language,
poll: {},
mediaDescriptions: {}
}
const scopeselector = this.$refs.scopeselector
if (scopeselector) {
scopeselector.currentScope = this.newStatus.visibility
}
this.pollFormVisible = false
this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
this.clearPollForm()
@ -518,7 +511,7 @@ const PostStatusForm = {
addMediaFile (fileInfo) {
this.newStatus.files.push(fileInfo)
if (this.$store.getters.mergedConfig.sensitiveIfSubject && this.newStatus.spoilerText !== '' || !!this.$store.getters.mergedConfig.sensitiveByDefault) {
if (this.$store.getters.mergedConfig.sensitiveIfSubject && this.newStatus.spoilerText !== '') {
this.newStatus.nsfw = true
}
this.$emit('resize', { delayed: true })
@ -555,7 +548,7 @@ const PostStatusForm = {
this.uploadingFiles = false
},
type (fileInfo) {
return fileTypeService.fileType(fileInfo)
return fileTypeService.fileType(fileInfo.mimetype)
},
paste (e) {
this.autoPreview()
@ -767,19 +760,16 @@ const PostStatusForm = {
openProfileTab () {
this.$store.dispatch('openSettingsModalTab', 'profile')
},
suggestedLanguage () {
// Make sure the inherited language is actually valid
if (this.postLanguageOptions.find(o => o.value === this.copyMessageLanguage)) {
return this.copyMessageLanguage
}
const { postLanguage: defaultPostLanguage, interfaceLanguage } = this.$store.getters.mergedConfig
const postLanguage = defaultPostLanguage || interfaceToISOLanguage(interfaceLanguage)
return postLanguage
},
suggestedVisibility () {
const maxScope = this.copyMessageScope
const defaultScope = this.$store.state.users.currentUser.default_scope
return scopeUtils.negotiate(defaultScope, maxScope)
if (this.copyMessageScope) {
if (this.copyMessageScope === 'direct') {
return this.copyMessageScope
}
if (this.copyMessageScope !== 'public' && this.$store.state.users.currentUser.default_scope !== 'private') {
return this.copyMessageScope
}
}
return this.$store.state.users.currentUser.default_scope
}
}
}

View file

@ -18,7 +18,6 @@
>
<button
class="button-unstyled -link"
type="button"
@click="openProfileTab"
>
{{ $t('post_status.account_not_locked_warning_link') }}
@ -137,7 +136,6 @@
class="form-post-subject"
@input="onSubjectInput"
@focus="focusSubjectInput()"
@keydown.exact.enter.prevent
>
</EmojiInput>
<i18n-t
@ -196,7 +194,6 @@
>
<scope-selector
v-if="!disableVisibilitySelector"
ref="scopeselector"
:user-default="userDefaultScope"
:original-scope="copyMessageScope"
:initial-scope="newStatus.visibility"
@ -204,11 +201,10 @@
/>
<div
class="format-selector-container"
>
class="format-selector-container">
<div
class="format-selector"
>
>
<Select
id="post-language"
v-model="newStatus.language"
@ -276,7 +272,6 @@
<button
class="emoji-icon button-unstyled"
:title="$t('emoji.add_emoji')"
type="button"
@click="showEmojiPicker"
>
<FAIcon icon="smile-beam" />
@ -286,7 +281,6 @@
class="poll-icon button-unstyled"
:class="{ selected: pollFormVisible }"
:title="$t('polls.add_poll')"
type="button"
@click="togglePollForm"
>
<FAIcon icon="poll-h" />
@ -296,7 +290,6 @@
class="spoiler-icon button-unstyled"
:class="{ selected: subjectVisible }"
:title="$t('post_status.toggle_content_warning')"
type="button"
@click="toggleSubjectVisible"
>
<FAIcon icon="eye-slash" />

View file

@ -7,16 +7,8 @@ const QuoteButton = {
name: 'QuoteButton',
props: ['status', 'quoting', 'visibility'],
computed: {
showButton () {
const currentUserId = this.$store.state.users.currentUser?.id
if (!currentUserId)
return false
if (['public', 'unlisted', 'local'].includes(this.visibility))
return true
return (this.visibility === 'private' && currentUserId == this.status.user.id)
loggedIn () {
return !!this.$store.state.users.currentUser
}
}
}

View file

@ -1,6 +1,6 @@
<template>
<div
v-if="showButton"
v-if="(visibility === 'public' || visibility === 'unlisted') && loggedIn"
class="QuoteButton"
>
<button

View file

@ -1,6 +1,4 @@
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import ScopeSelector from '../scope_selector/scope_selector.vue'
import scopeUtils from 'src/lib/scope_utils.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faRetweet } from '@fortawesome/free-solid-svg-icons'
@ -9,16 +7,12 @@ library.add(faRetweet)
const RetweetButton = {
props: ['status', 'loggedIn', 'visibility'],
components: {
ConfirmModal,
ScopeSelector
ConfirmModal
},
data () {
const maxScope = this.status.visibility
const defaultScope = this.$store.state.users.currentUser.default_scope
return {
animated: false,
showingConfirmDialog: false,
retweetVisibility: scopeUtils.negotiate(defaultScope, maxScope)
showingConfirmDialog: false
}
},
methods: {
@ -31,7 +25,7 @@ const RetweetButton = {
},
doRetweet () {
if (!this.status.repeated) {
this.$store.dispatch('retweet', { id: this.status.id, visibility: this.retweetVisibility })
this.$store.dispatch('retweet', { id: this.status.id })
} else {
this.$store.dispatch('unretweet', { id: this.status.id })
}
@ -46,9 +40,6 @@ const RetweetButton = {
},
hideConfirmDialog () {
this.showingConfirmDialog = false
},
changeVis (visibility) {
this.retweetVisibility = visibility
}
},
computed: {
@ -63,13 +54,7 @@ const RetweetButton = {
},
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
},
userDefaultScope () {
return this.$store.state.users.currentUser.default_scope
},
statusScope () {
return this.status.visibility
},
}
}
}

View file

@ -49,12 +49,6 @@
@cancelled="hideConfirmDialog"
>
{{ $t('status.repeat_confirm') }}
<scope-selector
:user-default="userDefaultScope"
:original-scope="statusScope"
:initial-scope="retweetVisibility"
:on-scope-change="changeVis"
/>
</confirm-modal>
</teleport>
</div>

View file

@ -1,4 +1,7 @@
import { fromString } from 'src/services/html_converter/parser.service.js'
import { unescape, flattenDeep } from 'lodash'
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import StillImage from 'src/components/still-image/still-image.vue'
import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
@ -14,392 +17,15 @@ import './rich_content.scss'
* to a <MentionsLine> containing single <MentionLink>.
* - Replaces emoji shortcodes with <StillImage>'d images.
*
* Note:
* 1. Vue properties MUST NOT be touched during rendering!
* But the parser also needs to keep track of mutable state across functions,
* thus we split the actual rendering out to a one-time use class object.
* There are two problems with this component's architecture:
* 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
* proven to be a massive overcomplication due to amount of things done here.
* 2. We need to output both render and some extra data, which seems to be imp-
* possible in vue. Current solution is to emit 'parseReady' event when parsing
* is done within render() function.
* 3. We are inserting _dynamic_ Vue component elements into the body (e.g. the show more of mentions line).
* For the dynamic part to work, the whole tree must be a JSX Vue/React-style VNode thingy.
* Thus, rather than just doing minor mods to the parsed vanialla HTMLElements + adding in a couple new,
* we must recreate the element tree in JSX template style.
* Child elements can be apssed via an array with text nodes just being simple strings.
* Use <Tag ...>...</Tag> where `Tag` is a variable holding a plain HTML node name
* to wrap vanilla elements into the Vue/React garments.
*
* Apart from that one small hiccup with emit in render this _should_ be vue3-ready
*/
class RichContentParser {
// All mentions that appear in post body
writtenMentions = [];
// All mentions beyond the limiter (see MentionsLine), collapsed from default view
invisibleMentions = [];
// unique index for vue "tag" property
#mentionIndex = 0;
// buffer for just the current sequence of consectuive mentions
#currentMentions = [];
// This is used to recover spacing removed when parsing mentions
#lastSpacing = '';
// Reference to the embedded Vue object to read its settings.
// READ-ONLY: NEVER EVER TOUCH DATA INSIDE RENDER!
vueState;
constructor(vueState) {
this.vueState = vueState
}
parse() {
let hbody = fromString(this.vueState.html)
// Green/cyantexting requires detecting linebreaks and acting on larger blocks
// Its easier to do this first in a separate pass before the regular per-element modifications.
// This does no JSX templating either and just modifies the vanilla DOM in-place.
if (this.vueState.greentext && !this.vueState.mfm) {
this.#styleGreentext(hbody)
}
const jsxChilds = []
for (const n of hbody.childNodes) {
jsxChilds.push(...this.#processNode(n))
}
// DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3?
const result = <span class="RichContent">
{ jsxChilds }
</span>
return result;
}
static #isSimpleShallow(node) {
// avoid meddling with code blocks, pre-styled blockquote elements etc
return (
node.nodeType === Node.TEXT_NODE ||
["BR", "P", "DIV", "SPAN", "A"].includes(node.nodeName)
)
}
static #isSimple(node) {
return (
RichContentParser.#isSimpleShallow(node) &&
[...node.childNodes].every(RichContentParser.#isSimple)
)
}
#maybeStyleLine(parent, lineElements) {
if (lineElements.length == 0) return;
if (!lineElements.every(RichContentParser.#isSimple)) return;
const cleanText = lineElements
.map(e => e.innerText ?? e.textContent ?? '')
.join('')
.replace(/@\w+/gi, '')
.trim()
const styleChar = cleanText.charAt(0)
const style = ({'>': 'greentext', '<': 'cyantext'})[styleChar]
if (!style) return;
const styledLine = document.createElement('span')
styledLine.classList.add(style)
parent.insertBefore(styledLine, lineElements[0])
for (var e of lineElements) {
e = parent.removeChild(e)
styledLine.appendChild(e)
}
}
#styleGreentext(lineParent, allowDescent = true) {
// Greentexting is only aplied on "simple" source types.
// Thus its sufficient to consider only <p> and <br /> for line breaking here.
// The former fortunately cannot be nested anyway and
// we furthermore assume all linebreaks appear either at the top-level
// or within the first level of a top-level <p> element. Linerbeaks nested within
// another tag couldnt be styled without breaking the enclosing tag anyway.
if (lineParent.childNodes.length == 0) return;
// copy array to avoid weirdness when removing elements from top-level later
const nodes = [...lineParent.childNodes]
const currentLine = []
for (const n of nodes) {
if (n.nodeName === 'P' && allowDescent) {
this.#maybeStyleLine(lineParent, currentLine)
this.#styleGreentext(n, false)
currentLine.splice(0)
} else if (n.nodeName === 'BR') {
this.#maybeStyleLine(lineParent, currentLine)
currentLine.splice(0)
} else {
currentLine.push(n)
}
}
this.#maybeStyleLine(lineParent, currentLine)
}
#getLinkData(link, index = 0) {
return {
index,
url: link.attributes['href']?.value,
tag: link.attributes['data-tag']?.value,
content: link.innerHTML,
textContent: link.innerText ?? ''
}
}
#nodeAttributeMap(node) {
// node.attributes is a funny "NamedNodeMap", not a regular object.
// Need to covnert it first, else it ends up with numeric names and object values, confusing JSX
const attrs = { }
for (const a of node.attributes)
attrs[a.name] = a.value
return attrs
}
#renderImage(node) {
return <StillImage
{ ...this.#nodeAttributeMap(node) }
class="img"
/>
}
#renderHashtag(node) {
const { url, tag, content } = this.#getLinkData(node)
return <HashtagLink url={url} tag={tag} content={content}/>
}
#renderMention(node) {
const linkData = this.#getLinkData(node, this.#mentionIndex++)
linkData.notifying = this.vueState.attentions.some(a => a.statusnet_profile_url === linkData.url)
this.writtenMentions.push(linkData)
this.#currentMentions.push(linkData)
if (this.#currentMentions.length > MENTIONS_LIMIT) {
this.invisibleMentions.push(linkData)
}
if (this.#currentMentions.length === 1) {
return <MentionsLine mentions={ this.#currentMentions } />
} else {
return ''
}
}
#renderEmoji(url, shortcode) {
return <StillImage
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
/>
}
#breakMentionChain() {
this.#currentMentions = []
this.#lastSpacing = ''
}
#maybePushTextNode(acc, text, start, end) {
if (end > start && start >= 0) {
acc.push(text.slice(start, end))
}
}
#processTextForEmoji(text) {
const elements = []
let plainStart = 0
let plainEnd = text.indexOf(':')
while (plainEnd >= 0) {
const emojiEnd = text.indexOf(':', plainEnd + 1)
if (emojiEnd <= 0) break;
const emojiName = text.slice(plainEnd + 1, emojiEnd)
const emoji = emojiName && this.vueState.emoji.find(e => e.shortcode == emojiName)
// found another ":", but name didnt match an emoji;
// restart with this next ":" as the opener
if (!emoji) {
plainEnd = emojiEnd
continue
}
// found matching emoji; insert and continue after last ":"
this.#maybePushTextNode(elements, text, plainStart, plainEnd)
elements.push(this.#renderEmoji(emoji.url, emoji.shortcode))
plainStart = emojiEnd + 1
plainEnd = text.indexOf(':', emojiEnd + 1)
}
plainEnd = text.length
if (plainStart == 0) {
return [text]
}
this.#maybePushTextNode(elements, text, plainStart, plainEnd)
return elements
}
#processTextNode(textNode) {
let text = textNode.textContent;
const isEmpty = text.trim() === ''
if (isEmpty) {
// No idea why the old code did that; in-source linebreaks have no special meaning in HTML.
// Maybe a quirk of the cursed old NIH parser, but doesn't make any sense to me now.
//if (text.includes('\n')) this.#breakMentionChain();
// Don't include spaces between consecutive mentions. Since Mentions are inserted into
// the preceeding MentionLine element, well end up with superfluous whitespace otherwise.
if (this.#currentMentions.length > 0) {
this.#lastSpacing = text
textNode.textContent = ''
}
return [textNode.textContent]
}
if (this.#lastSpacing && !text.match(/^\s/)) {
text = this.#lastSpacing + text
}
this.#breakMentionChain()
return this.#processTextForEmoji(text)
}
#applyMfmStyle(node) {
// CSS selectors and styling rules can check whether data-* attributes exist and are true
// but are otherwise unable to extract the precise value. To make MFM parameters available to our style sheet
// we turn e.g. `{'data-mfm-some': '1deg', 'data-mfm-thing': '5s'}` to "--mfm-some: 1deg;--mfm-thing: 5s;"
//
// Since we use ECMAScripts Element.style.setProperty it already ought to be impossible to insert extra style
// properties via malicious MFM values (it already automatically rejects or escapes values as appropriate).
// However, to be extra safe and given all valid parameters should match this anyway,
// we restrict values to only letters, numbers, dot and minus signs.
// There is a special case for the `color` value, since it is provided without `#`,
// but CSS requires this prefix in the `style` property
// no pre-existing style should ever be present anyway, but lets make sure
node.removeAttribute('style')
for (const a of node.attributes) {
const key = a.name
const val = a.value
if (!(key.startsWith('data-mfm-') && val !== true && /^[a-zA-Z0-9.\-]*$/.test(val)))
continue
const cssName = '--mfm-' + key.substr(9)
const cssVal = key === 'data-mfm-color' ? ('#' + val) : val
node.style.setProperty(cssName, cssVal)
}
}
#processSpan(node) {
// mentions can be wrapped in 'h-card' spans, unpack them and process here
// to avoid breaking the mention chain or adding spurious padding
if (this.vueState.handleLinks && node.classList.contains('h-card')) {
return [...node.childNodes].map(n => this.#processNode(n))
}
this.#applyMfmStyle(node)
return null
}
#processLink(node) {
// Mentions
if (node.classList.contains('mention')) {
return this.#renderMention(node)
}
// mention chain no longer consecutive
this.#breakMentionChain()
// Hashtags
if (
(node.classList.contains('hashtag')) || // Pleroma style
(node.attributes['rel']?.value === 'tag') // Mastodon style
) {
return this.#renderHashtag(node)
}
// some other link
node.setAttribute('target', '_blank')
return null
}
#spacingPrefix(node) {
const firstChild = node.childNodes.length > 0 ? node.childNodes[0] : null
const eligibleForSpace =
// Padding is only needed if we just finished parsing mentions
this.#currentMentions.length > 0 &&
// Don't add padding if content is string and has padding already
!(firstChild?.nodeType == Node.TEXT_NODE && firstChild.textContent.match(/^\s/))
return eligibleForSpace ? this.#lastSpacing : ''
}
#processElementNode(node) {
const Tag = node.nodeName
const mentionsLinePadding = this.#spacingPrefix(node)
switch (Tag) {
case 'BR':
this.#breakMentionChain()
return [<br />]
case 'IMG':
this.#breakMentionChain()
return [mentionsLinePadding, this.#renderImage(node)]
case 'A':
if (!this.vueState.handleLinks) break
const styledLink = this.#processLink(node)
if (styledLink !== null) return [styledLink]
break
case 'SPAN':
const styledSpanElements = this.#processSpan(node)
if (styledSpanElements !== null) return styledSpanElements;
break
}
this.#breakMentionChain()
const pchilds = [...node.childNodes].map(n => this.#processNode(n))
const attrs = this.#nodeAttributeMap(node)
return [mentionsLinePadding, <Tag {...attrs}>{ pchilds }</Tag>]
}
#processNode(node) {
if(node.nodeType === Node.TEXT_NODE) {
return this.#processTextNode(node)
} else if (node.nodeType == Node.ELEMENT_NODE) {
return this.#processElementNode(node)
} else if (node.nodeType == Node.COMMENT_NODE) {
// ignore
} else {
// since we start with childs of <body> a parent node should be guaranteed
console.warn(`RichContentParser: Encountered unexpected node type (${node.nodeType}); dropping: ${node.outerHTML ?? node.textContent}`)
}
return []
}
}
export default {
name: 'RichContent',
components: {
@ -440,13 +66,212 @@ export default {
default: false
}
},
// NEVER EVER TOUCH DATA INSIDE RENDER
render () {
const parser = new RichContentParser(this);
const result = parser.parse()
// Don't greentext MFM
const greentext = this.mfm ? false : this.greentext
// Pre-process HTML
const { newHtml: html } = preProcessPerLine(this.html, greentext)
let currentMentions = null // Current chain of mentions, we group all mentions together
// This is used to recover spacing removed when parsing mentions
let lastSpacing = ''
const lastTags = [] // Tags that appear at the end of post body
const writtenMentions = [] // All mentions that appear in post body
const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
// to collapse too many mentions in a row
const writtenTags = [] // All tags that appear in post body
// unique index for vue "tag" property
let mentionIndex = 0
let tagsIndex = 0
const renderImage = (tag) => {
return <StillImage
{...getAttrs(tag)}
class="img"
/>
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
const { url, tag, content } = linkData
return <HashtagLink url={url} tag={tag} content={content}/>
}
const renderMention = (attrs, children) => {
const linkData = getLinkData(attrs, children, mentionIndex++)
linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
writtenMentions.push(linkData)
if (currentMentions === null) {
currentMentions = []
}
currentMentions.push(linkData)
if (currentMentions.length > MENTIONS_LIMIT) {
invisibleMentions.push(linkData)
}
if (currentMentions.length === 1) {
return <MentionsLine mentions={ currentMentions } />
} else {
return ''
}
}
// Processor to use with html_tree_converter
const processItem = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (item.includes('\n')) {
currentMentions = null
}
if (emptyText) {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
lastSpacing = item
// Don't remove last space in a container (fixes poast mentions)
return (index !== array.length - 1) && (currentMentions !== null) ? item.trim() : item
}
currentMentions = null
if (item.includes(':')) {
item = ['', processTextForEmoji(
item,
this.emoji,
({ shortcode, url }) => {
return <StillImage
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
/>
}
)]
}
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
const [opener, children, closer] = item
const Tag = getTagName(opener)
const attrs = getAttrs(opener)
const previouslyMentions = currentMentions !== null
/* During grouping of mentions we trim all the empty text elements
* This padding is added to recover last space removed in case
* we have a tag right next to mentions
*/
const mentionsLinePadding =
// Padding is only needed if we just finished parsing mentions
previouslyMentions &&
// Don't add padding if content is string and has padding already
!(children && typeof children[0] === 'string' && children[0].match(/^\s/))
? lastSpacing
: ''
switch (Tag) {
case 'br':
currentMentions = null
break
case 'img': // replace images with StillImage
return ['', [mentionsLinePadding, renderImage(opener)], '']
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
if (attrs['class'] && attrs['class'].includes('mention')) {
// Handling mentions here
return renderMention(attrs, children)
} else {
currentMentions = null
break
}
case 'span':
if (this.handleLinks && attrs?.['class']?.includes?.('h-card')) {
return ['', children.map(processItem), '']
}
}
if (children !== undefined) {
return [
'',
[
mentionsLinePadding,
[opener, children.map(processItem), closer]
],
''
]
} else {
return ['', [mentionsLinePadding, item], '']
}
}
}
// Processor for back direction (for finding "last" stuff, just easier this way)
let encounteredTextReverse = false
const processItemReverse = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (emptyText) return item
if (!encounteredTextReverse) encounteredTextReverse = true
return unescape(item)
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
const Tag = opener === '' ? '' : getTagName(opener)
switch (Tag) {
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
const attrs = getAttrs(opener)
// should only be this
if (
(attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
(attrs['rel'] === 'tag') // Mastodon style
) {
return renderHashtag(attrs, children, encounteredTextReverse)
} else {
attrs.target = '_blank'
const newChildren = [...children].reverse().map(processItemReverse).reverse()
return <a {...attrs}>
{ newChildren }
</a>
}
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
// Render tag as is
if (children !== undefined) {
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
return <Tag {...getAttrs(opener)}>
{ newChildren }
</Tag>
} else {
return <Tag/>
}
}
return item
}
const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
// DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3?
const result = <span class="RichContent">
{ pass2 }
</span>
const event = {
writtenMentions: parser.writtenMentions,
invisibleMentions: parser.invisibleMentions
lastTags,
writtenMentions,
writtenTags,
invisibleMentions
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
@ -455,3 +280,62 @@ export default {
return result
}
}
const getLinkData = (attrs, children, index) => {
const stripTags = (item) => {
if (typeof item === 'string') {
return item
} else {
return item[1].map(stripTags).join('')
}
}
const textContent = children.map(stripTags).join('')
return {
index,
url: attrs.href,
tag: attrs['data-tag'],
content: flattenDeep(children).join(''),
textContent
}
}
/** Pre-processing HTML
*
* Currently this does one thing:
* - add green/cyantexting
*
* @param {String} html - raw HTML to process
* @param {Boolean} greentext - whether to enable greentexting or not
*/
export const preProcessPerLine = (html, greentext) => {
const greentextHandle = new Set(['p', 'div'])
const lines = convertHtmlToLines(html)
const newHtml = lines.reverse().map((item, index, array) => {
if (!item.text) return item
const string = item.text
// Greentext stuff
if (
// Only if greentext is engaged
greentext &&
// Only handle p's and divs. Don't want to affect blockquotes, code etc
item.level.every(l => greentextHandle.has(l)) &&
// Only if line begins with '>' or '<'
(string.includes('&gt;') || string.includes('&lt;'))
) {
const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
if (cleanedString.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else if (cleanedString.startsWith('&lt;')) {
return `<span class='cyantext'>${string}</span>`
}
}
return string
}).reverse().join('')
return { newHtml }
}

View file

@ -1,25 +1,11 @@
@import './../../_variables.scss';
.RichContent {
blockquote {
margin: 0 0 1em 0.2em;
padding: 0 0 0 1em;
border-width: 0 0 0 0.3em;
border-style: solid;
color: $fallback--faint;
color: var(--faint, $fallback--faint);
margin: 0.2em 0 0.2em 2em;
font-style: italic;
}
blockquote:last-child {
margin-bottom: 0;
}
/* Overrides pre-wrap rule for .StatusBody .text */
&.text pre {
border: 1px solid var(--border);
white-space: pre;
overflow: scroll;
padding: 0.2em 0.5em;
pre {
overflow: auto;
}
code,
@ -28,7 +14,6 @@
var,
pre {
font-family: var(--postCodeFont, monospace);
background: var(--input);
}
p {

View file

@ -6,8 +6,6 @@ import {
faGlobe
} from '@fortawesome/free-solid-svg-icons'
import scopeUtils from 'src/lib/scope_utils.js'
library.add(
faEnvelope,
faGlobe,
@ -15,11 +13,18 @@ library.add(
faLockOpen
)
const SCOPE_LEVELS = {
'direct': 0,
'private': 1,
'local': 2,
'unlisted': 2,
'public': 3
}
const ScopeSelector = {
props: [
'showAll',
'userDefault',
// scope of parent object
'originalScope',
'initialScope',
'onScopeChange'
@ -34,16 +39,16 @@ const ScopeSelector = {
return !this.showPublic && !this.showUnlisted && !this.showPrivate && !this.showDirect
},
showPublic () {
return this.shouldShow('public')
return this.originalScope !== 'direct' && this.shouldShow('public')
},
showLocal () {
return this.shouldShow('local')
return this.originalScope !== 'direct' && this.shouldShow('local')
},
showUnlisted () {
return this.shouldShow('unlisted')
return this.originalScope !== 'direct' && this.shouldShow('unlisted')
},
showPrivate () {
return this.shouldShow('private')
return this.originalScope !== 'direct' && this.shouldShow('private')
},
showDirect () {
return this.shouldShow('direct')
@ -60,10 +65,15 @@ const ScopeSelector = {
},
methods: {
shouldShow (scope) {
if (!this.originalScope)
if (!this.originalScope) {
return true
else
return scopeUtils.isSubScope(this.originalScope, scope)
}
if (this.originalScope === 'local') {
return scope === 'direct' || scope === 'local'
}
return SCOPE_LEVELS[scope] <= SCOPE_LEVELS[this.originalScope]
},
changeVis (scope) {
this.currentScope = scope

View file

@ -1,14 +1,17 @@
import { filter, trim, debounce } from 'lodash'
import { debounce } from 'lodash'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
const regexStart = '~r/'
const regexEnd = '/'
const FilteringTab = {
data () {
return {
muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.join('\n'),
muteWordsStringLocal: this.$store.getters.mergedConfig.muteWords.raw,
replyVisibilityOptions: ['all', 'following', 'self'].map(mode => ({
key: mode,
value: mode,
@ -31,7 +34,19 @@ const FilteringTab = {
this.muteWordsStringLocal = value
this.$store.dispatch('setOption', {
name: 'muteWords',
value: filter(value.split('\n'), (word) => trim(word).length > 0)
value: {
raw: value,
value: value.split('\n')
.filter((word) => word.trim().length > 0)
.map((word) => {
if (!(word.startsWith(regexStart) && word.endsWith(regexEnd))) {
return word
}
const regex = new RegExp(word.slice(regexStart.length, -regexEnd.length), 'i')
return regex
})
}
})
}, 500)
}

View file

@ -159,16 +159,6 @@
{{ $t('settings.show_page_backgrounds') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="centerAlignBio">
{{ $t('settings.center_align_bio') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="compactUserInfo">
{{ $t('settings.compact_user_info') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="stopGifs">
{{ $t('settings.stop_gifs') }}
@ -279,11 +269,6 @@
{{ $t('settings.right_sidebar') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="widenTimeline">
{{ $t('settings.widen_timeline') }}
</BooleanSetting>
</li>
<li>
<ChoiceSetting
v-if="user"
@ -335,11 +320,6 @@
{{ $t('settings.confirm_dialogs_deny_follow') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="modalOnDeleteDMConversation">
{{ $t('settings.confirm_dialogs_delete_dm_conv') }}
</BooleanSetting>
</li>
</ul>
</li>
</ul>

View file

@ -35,10 +35,6 @@ const ProfileTab = {
newLocked: this.$store.state.users.currentUser.locked,
newPermitFollowback: this.$store.state.users.currentUser.permit_followback,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
avatar_description: this.$store.state.users.currentUser.avatar_description || '',
header_description: this.$store.state.users.currentUser.header_description || '',
pleroma_background_image_description:
this.$store.state.users.currentUser.pleroma?.background_image_description || '',
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
bot: this.$store.state.users.currentUser.bot,
@ -134,16 +130,15 @@ const ProfileTab = {
note: this.newBio,
locked: this.newLocked,
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
bot: this.bot,
show_role: this.showRole,
status_ttl_days: this.expirePosts ? this.newPostTTLDays : -1,
permit_followback: this.permit_followback,
avatar_description: this.avatar_description,
header_description: this.header_description,
pleroma_background_image_description: this.pleroma_background_image_description,
accepts_direct_messages_from: this.userAcceptsDirectMessagesFrom
}
if (this.emailLanguage) {
@ -157,17 +152,6 @@ const ProfileTab = {
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.$store.dispatch('clearSettingsError')
})
.catch((error) => {
const msg = typeof error === "string" ? error : error.message
console.error(`Failed to update profile settings: ${error}`)
this.$store.dispatch('setSettingsError', {errorData: msg})
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'settings.saving_err_details',
messageArgs: [msg],
level: 'error'
})
})
},
changeVis (visibility) {
@ -189,7 +173,6 @@ const ProfileTab = {
if (file.size > this.$store.state.instance[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(this.$store.state.instance[slot + 'limit'])
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'upload.error.message',
messageArgs: [
@ -235,13 +218,10 @@ const ProfileTab = {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar, avatarName) {
that.$store.state.api.backendInteractor.updateProfileImages({
avatar, avatarName, avatar_description: that.avatar_description
})
that.$store.state.api.backendInteractor.updateProfileImages({ avatar, avatarName })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
that.$store.dispatch('clearSettingsError')
resolve()
})
.catch((error) => {
@ -261,13 +241,10 @@ const ProfileTab = {
if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({
banner, header_description: this.header_description
})
this.$store.state.api.backendInteractor.updateProfileImages({ banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.$store.dispatch('clearSettingsError')
this.bannerPreview = null
})
.catch(this.displayUploadError)
@ -277,21 +254,16 @@ const ProfileTab = {
if (!this.backgroundPreview && background !== '') { return }
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({
background,
pleroma_background_image_description: this.pleroma_background_image_description
})
this.$store.state.api.backendInteractor.updateProfileImages({ background })
.then((data) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.$store.dispatch('clearSettingsError')
this.backgroundPreview = null
})
.catch(this.displayUploadError)
.finally(() => { this.backgroundUploading = false })
},
displayUploadError (error) {
this.$store.dispatch('setSettingsError', {errorData: error})
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'upload.error.message',
messageArgs: [error.message],

View file

@ -4,11 +4,6 @@
margin: 0;
}
.setting-item .media-alt {
margin-bottom: 0.75em;
height: 2.5em;
}
.expire-posts-days {
margin-left: 1em;
}

View file

@ -120,21 +120,6 @@
:set-language="val => emailLanguage = val"
/>
</p>
<h3>{{ $t('settings.media_alt_avatar') }}</h3>
<textarea
v-model="avatar_description"
class="media-alt resize-height"
/>
<h3>{{ $t('settings.media_alt_banner') }}</h3>
<textarea
v-model="header_description"
class="media-alt resize-height"
/>
<h3>{{ $t('settings.media_alt_background') }}</h3>
<textarea
v-model="pleroma_background_image_description"
class="media-alt resize-height"
/>
<button
:disabled="newName && newName.length === 0"
class="btn button-default"

View file

@ -1,25 +1,22 @@
import { extractCommit } from 'src/services/version/version.service'
function joinURL(base, subpath) {
return URL.parse(subpath, base)?.href || "invalid base URL"
}
const pleromaFeCommitUrl = 'https://akkoma.dev/AkkomaGang/pleroma-fe/commit/'
const pleromaBeCommitUrl = 'https://akkoma.dev/AkkomaGang/akkoma/commit/'
const VersionTab = {
data () {
const instance = this.$store.state.instance
return {
backendCommitUrl: instance.backendCommitUrl,
backendVersion: instance.backendVersion,
frontendCommitUrl: instance.frontendCommitUrl,
frontendVersion: instance.frontendVersion
}
},
computed: {
frontendVersionLink () {
return joinURL(this.frontendCommitUrl, this.frontendVersion)
return pleromaFeCommitUrl + this.frontendVersion
},
backendVersionLink () {
return joinURL(this.backendCommitUrl, extractCommit(this.backendVersion))
return pleromaBeCommitUrl + extractCommit(this.backendVersion)
}
}
}

View file

@ -9,7 +9,6 @@ import {
faHome,
faComments,
faBolt,
faBookmark,
faUserPlus,
faBullhorn,
faSearch,
@ -26,7 +25,6 @@ library.add(
faHome,
faComments,
faBolt,
faBookmark,
faUserPlus,
faBullhorn,
faSearch,
@ -45,6 +43,10 @@ const SideDrawer = {
}),
created () {
this.closeGesture = GestureService.swipeGesture(GestureService.DIRECTION_LEFT, this.toggleDrawer)
if (this.currentUser && this.currentUser.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: { UserCard },
computed: {
@ -57,9 +59,6 @@ const SideDrawer = {
unseenNotificationsCount () {
return this.unseenNotifications.length
},
unreadDMConversationsCount () {
return this.$store.state.users.currentUser?.pleroma?.unread_conversation_count || 0
},
suggestionsEnabled () {
return this.$store.state.instance.suggestionsEnabled
},

View file

@ -55,24 +55,6 @@
/> {{ $t("nav.timelines") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'dms' }">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="envelope"
/> {{ $t("nav.dm_conversations") }}
<span
v-if="unreadDMConversationsCount > 0"
class="badge badge-notification"
>
{{ unreadDMConversationsCount }}
</span>
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
@ -85,18 +67,6 @@
/> {{ $t("nav.lists") }}
</router-link>
</li>
<li
v-if="currentUser"
@click="toggleDrawer"
>
<router-link :to="{ name: 'bookmarks' }">
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding"
icon="bookmark"
/> {{ $t("nav.bookmarks") }}
</router-link>
</li>
</ul>
<ul v-if="currentUser">
<li @click="toggleDrawer">

View file

@ -169,8 +169,8 @@ const Status = {
},
computed: {
...controlledOrUncontrolledGetters(['replying', 'quoting', 'mediaPlaying']),
muteWordRules () {
return this.$store.getters.parsedConfigVal('muteWords')
muteWords () {
return this.mergedConfig.muteWords.value
},
showReasonMutedThread () {
return (
@ -215,7 +215,6 @@ const Status = {
retweeter () { return this.statusoid.user.name || this.statusoid.user.screen_name_ui },
retweeterHtml () { return this.statusoid.user.name },
retweeterProfileLink () { return this.generateUserProfileLink(this.statusoid.user.id, this.statusoid.user.screen_name) },
retweeterVisibility () { return this.statusoid.visibility },
status () {
if (this.retweet) {
return this.statusoid.retweeted_status
@ -231,7 +230,7 @@ const Status = {
return !!this.currentUser
},
muteWordHits () {
return muteWordHits(this.status, this.muteWordRules)
return muteWordHits(this.status, this.muteWords)
},
rtBotStatus () {
return this.statusoid.user.bot
@ -441,9 +440,6 @@ const Status = {
visibilityLocalized () {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
retweeterVisibilityLocalized () {
return this.$i18n.t('general.scope_in_timeline.' + this.statusoid.visibility)
},
isEdited () {
return this.status.edited_at !== null
},

View file

@ -99,43 +99,20 @@
<router-link
v-else
:to="retweeterProfileLink"
>
{{ retweeter }}
</router-link>
>{{ retweeter }}</router-link>
</div>
{{ ' ' }}
<div
class="repeat-tooltip"
>
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
<FAIcon
icon="retweet"
class="repeat-icon"
:title="$t('tool_tip.repeat')"
/>
{{ $t('timeline.repeated') }}
</div>
<span
v-if="retweeterVisibility"
class="visibility-icon"
:title="retweeterVisibilityLocalized"
>
<FAIcon
fixed-width
class="fa-scale-110"
:icon="visibilityIcon(retweeterVisibility)"
/>
</span>
{{ ' ' }}
<Timeago
class="timeago"
:time="statusoid.created_at"
:with-direction="!compact"
:auto-update="60"
/>
</div>
</div>
@ -542,7 +519,6 @@
:reply-to="status.id"
:attentions="status.attentions"
:replied-user="status.user"
:copy-message-language="status.language"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleReplying"
@ -557,7 +533,6 @@
:quote-id="status.id"
:attentions="[status.user]"
:replied-user="status.user"
:copy-message-language="status.language"
:copy-message-scope="status.visibility"
:subject="replySubject"
@posted="toggleQuoting"

View file

@ -1,6 +1,5 @@
import fileType from 'src/services/file_type/file_type.service'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { toPlainText } from 'src/services/html_converter/parser.service.js'
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -42,8 +41,7 @@ const StatusContent = {
postLength: this.status.text.length,
parseReadyDone: false,
renderMisskeyMarkdown,
translateFrom: null,
translating: false
translateFrom: null
}
},
computed: {
@ -82,7 +80,7 @@ const StatusContent = {
return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
},
attachmentTypes () {
return this.status.attachments.map(file => fileType.fileType(file))
return this.status.attachments.map(file => fileType.fileType(file.mimetype))
},
translationLanguages () {
return (this.$store.state.instance.supportedTranslationLanguages.source || []).map(lang => ({ key: lang.code, value: lang.code, label: lang.name }))
@ -109,7 +107,7 @@ const StatusContent = {
.filter(mention => !mention.notifying)
.forEach(mention => {
const { content, url } = mention
const cleanedString = toPlainText(content) // remove all tags
const cleanedString = content.replace(/<[^>]+?>/gi, '') // remove all tags
if (!cleanedString.startsWith('@')) return
const handle = cleanedString.slice(1)
const host = url.replace(/^https?:\/\//, '').replace(/\/.+?$/, '')
@ -137,10 +135,7 @@ const StatusContent = {
},
translateStatus () {
const translateTo = this.$store.getters.mergedConfig.translationLanguage || this.$store.state.instance.interfaceLanguage
this.translating = true
this.$store.dispatch(
'translateStatus', { id: this.status.id, language: translateTo, from: this.translateFrom }
).finally(() => { this.translating = false })
this.$store.dispatch('translateStatus', { id: this.status.id, language: translateTo, from: this.translateFrom })
}
}
}

View file

@ -3,7 +3,6 @@
.StatusBody {
display: flex;
flex-direction: column;
overflow: hidden;
.translation {
border: 1px solid var(--accent, $fallback--link);
@ -24,6 +23,24 @@
transition: 0.05s;
}
._mfm_x2_ {
.emoji {
height: 100px;
}
}
._mfm_x3_ {
.emoji {
height: 150px;
}
}
._mfm_x4_ {
.emoji {
height: 200px;
}
}
.attachments {
margin-top: 0.5em;
}

View file

@ -1,7 +1,7 @@
<template>
<div
class="StatusBody"
:class="{ '-compact': compact }"
:class="{ '-compact': compact, 'mfm-disabled': !renderMisskeyMarkdown }"
>
<div class="body">
<div
@ -46,7 +46,7 @@
class="media-body-wrapper"
>
<RichContent
:class="{ '-single-line': singleLine, 'mfm-status': (status.media_type === 'text/x.misskeymarkdown') }"
:class="{ '-single-line': singleLine }"
class="text media-body"
:html="status.raw_html"
:emoji="status.emojis"
@ -91,7 +91,6 @@
{{ ' ' }}
<button
class="btn button-default"
:disabled="translating"
@click="translateStatus"
>
{{ $t('status.translate') }}

View file

@ -1,431 +0,0 @@
/**
* "FEP-c16b: Formatting MFM functions" attributes that Akkoma supports
*/
.StatusContent:not(.mfm-disabled) {
/*
* Exactly match *key's size for better compatibility.
* Using an em-based value here is also needed to make scaling work.
*/
.mfm-status .emoji {
--emoji-size: 2em;
}
/* The following are the non-animated MFM */
.mfm-center {
display: block;
text-align: center;
}
.mfm-flip {
display: inline-block;
transform: scaleX(-1);
}
.mfm-flip[data-mfm-v] {
transform: scaleY(-1);
}
.mfm-flip[data-mfm-v][data-mfm-h] {
transform: scale(-1, -1);
}
.mfm-font[data-mfm-serif] {
font-family: serif;
}
.mfm-font[data-mfm-monospace] {
font-family: monospace;
}
.mfm-font[data-mfm-cursive] {
font-family: cursive;
}
.mfm-font[data-mfm-fantasy] {
font-family: fantasy;
}
.mfm-font[data-mfm-emoji] {
font-family: emoji;
}
.mfm-font[data-mfm-math] {
font-family: math;
}
.mfm-blur {
filter: blur(6px);
transition: filter 0.3s;
&:hover {
filter: blur(0);
}
}
.mfm-rotate {
display: inline-block;
transform: rotate(calc(var(--mfm-deg, 90) * 1deg));
transform-origin: center center;
}
.mfm-x2 {
--mfm-zoom-size: 200%;
}
.mfm-x3 {
--mfm-zoom-size: 400%;
}
.mfm-x4 {
--mfm-zoom-size: 600%;
}
.mfm-x2,
.mfm-x3,
.mfm-x4,
.mfm-tada {
.emoji {
--emoji-size: 2em;
}
font-size: var(--mfm-zoom-size);
.mfm-x2,
.mfm-x3,
.mfm-x4,
.mfm-tada {
/* only half effective */
font-size: calc(var(--mfm-zoom-size) / 2 + 50%);
.mfm-x2,
.mfm-x3,
.mfm-x4,
.mfm-tada {
/* disabled */
font-size: 100%;
}
}
}
.mfm-position {
display: inline-block;
transform: translate(calc(var(--mfm-x, 0) * 1em), calc(var(--mfm-y, 0) * 1em));
}
.mfm-scale {
display: inline-block;
transform: scale(var(--mfm-x, 1), var(--mfm-y, 1));
}
.mfm-fg {
color: var(--mfm-color, #f00);
}
.mfm-bg {
background-color: var(--mfm-color, #0f0);
}
/* The following are the animated MFM */
/* .mfm-hover means that we should only play animation when hovering over the StatusContent
* So either StatusContent does not have this class,
* or it has the class and we are hovering over StatusContent
*/
&:not(.mfm-hover:not(:hover)) {
.mfm-jelly {
display: inline-block;
animation: mfm-rubberBand var(--mfm-speed, 1s) linear infinite both;
}
.mfm-twitch {
display: inline-block;
animation: mfm-twitch var(--mfm-speed, 0.5s) ease infinite;
}
.mfm-shake {
display: inline-block;
animation: mfm-shake var(--mfm-speed, 0.5s) ease infinite;
}
.mfm-spin {
display: inline-block;
animation: mfm-spin var(--mfm-speed, 1.5s) linear infinite;
}
.mfm-spin[data-mfm-y] {
animation-name: mfm-spinY;
}
.mfm-spin[data-mfm-x] {
animation-name: mfm-spinX;
}
.mfm-spin[data-mfm-alternate] {
animation-direction: alternate;
}
.mfm-spin[data-mfm-left] {
animation-direction: reverse;
}
.mfm-jump {
display: inline-block;
animation: mfm-jump var(--mfm-speed, 0.75s) linear infinite;
}
.mfm-bounce {
display: inline-block;
animation: mfm-bounce var(--mfm-speed, 0.75s) linear infinite;
transform-origin: center bottom;
}
.mfm-rainbow {
animation: mfm-rainbow var(--mfm-speed, 1s) linear infinite;
}
.mfm-tada {
display: inline-block;
animation: mfm-tada var(--mfm-speed, 1s) linear infinite both;
--mfm-zoom-size: 150%;
}
}
/* animation keyframes */
@keyframes mfm-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes mfm-spinX {
0% { transform: perspective(128px) rotateX(0deg); }
100% { transform: perspective(128px) rotateX(360deg); }
}
@keyframes mfm-spinY {
0% { transform: perspective(128px) rotateY(0deg); }
100% { transform: perspective(128px) rotateY(360deg); }
}
@keyframes mfm-jump {
0% { transform: translateY(0); }
25% { transform: translateY(-16px); }
50% { transform: translateY(0); }
75% { transform: translateY(-8px); }
100% { transform: translateY(0); }
}
@keyframes mfm-bounce {
0% { transform: translateY(0) scale(1, 1); }
25% { transform: translateY(-16px) scale(1, 1); }
50% { transform: translateY(0) scale(1, 1); }
75% { transform: translateY(0) scale(1.5, 0.75); }
100% { transform: translateY(0) scale(1, 1); }
}
@keyframes mfm-twitch {
0% { transform: translate(7px, -2px); }
5% { transform: translate(-3px, 1px); }
10% { transform: translate(-7px, -1px); }
15% { transform: translate(0, -1px); }
20% { transform: translate(-8px, 6px); }
25% { transform: translate(-4px, -3px); }
30% { transform: translate(-4px, -6px); }
35% { transform: translate(-8px, -8px); }
40% { transform: translate(4px, 6px); }
45% { transform: translate(-3px, 1px); }
50% { transform: translate(2px, -10px); }
55% { transform: translate(-7px, 0); }
60% { transform: translate(-2px, 4px); }
65% { transform: translate(3px, -8px); }
70% { transform: translate(6px, 7px); }
75% { transform: translate(-7px, -2px); }
80% { transform: translate(-7px, -8px); }
85% { transform: translate(9px, 3px); }
90% { transform: translate(-3px, -2px); }
95% { transform: translate(-10px, 2px); }
100% { transform: translate(-2px, -6px); }
}
@keyframes mfm-shake {
0% { transform: translate(-3px, -1px) rotate(-8deg); }
5% { transform: translate(0, -1px) rotate(-10deg); }
10% { transform: translate(1px, -3px) rotate(0deg); }
15% { transform: translate(1px, 1px) rotate(11deg); }
20% { transform: translate(-2px, 1px) rotate(1deg); }
25% { transform: translate(-1px, -2px) rotate(-2deg); }
30% { transform: translate(-1px, 2px) rotate(-3deg); }
35% { transform: translate(2px, 1px) rotate(6deg); }
40% { transform: translate(-2px, -3px) rotate(-9deg); }
45% { transform: translate(0, -1px) rotate(-12deg); }
50% { transform: translate(1px, 2px) rotate(10deg); }
55% { transform: translate(0, -3px) rotate(8deg); }
60% { transform: translate(1px, -1px) rotate(8deg); }
65% { transform: translate(0, -1px) rotate(-7deg); }
70% { transform: translate(-1px, -3px) rotate(6deg); }
75% { transform: translate(0, -2px) rotate(4deg); }
80% { transform: translate(-2px, -1px) rotate(3deg); }
85% { transform: translate(1px, -3px) rotate(-10deg); }
90% { transform: translate(1px, 0) rotate(3deg); }
95% { transform: translate(-2px, 0) rotate(-3deg); }
100% { transform: translate(2px, 1px) rotate(2deg); }
}
@keyframes mfm-rubberBand {
0% { transform: scale3d(1, 1, 1); }
30% { transform: scale3d(1.25, 0.75, 1); }
40% { transform: scale3d(0.75, 1.25, 1); }
50% { transform: scale3d(1.15, 0.85, 1); }
65% { transform: scale3d(0.95, 1.05, 1); }
75% { transform: scale3d(1.05, 0.95, 1); }
100% { transform: scale3d(1, 1, 1); }
}
@keyframes mfm-rainbow {
0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
}
@keyframes mfm-tada {
0%,
100% { transform: scale3d(1, 1, 1); }
10%,
20% { transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); }
30%,
50%,
70%,
90% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); }
40%,
60%,
80% { transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); }
}
/**
* Legacy MFM
* This is for backwards compatibility with posts formatted on Akkoma before support for FEP-c16b
* Note that it uses the keyframes as defined above for the FEP-c16b compatible MFM representation
*/
.mfm {
display: inline-block;
}
/* The following are the legacy non-animated MFM */
._mfm_flip_[data-h][data-v] {
transform: scale(-1, -1);
}
._mfm_flip_[data-v] {
transform: scaleY(-1);
}
._mfm_flip_:not([data-v]) {
transform: scaleX(-1);
}
._mfm_x2_ {
font-size: 200%;
}
._mfm_x3_ {
font-size: 400%;
}
._mfm_x4_ {
font-size: 600%;
}
._mfm_x2_ {
.emoji {
height: 100px;
}
}
._mfm_x3_ {
.emoji {
height: 150px;
}
}
._mfm_x4_ {
.emoji {
height: 200px;
}
}
._mfm_blur_ {
filter: blur(6px);
transition: filter 0.3s;
}
._mfm_blur_:hover {
filter: blur(0);
}
._mfm_rotate_ {
transform: rotate(90deg);
transform-origin: center center;
}
/* The following are the legacy animated MFM */
/* .mfm-hover means that we should only play animation when hovering over the StatusContent
* So either StatusContent does not have this class,
* or it has the class and we are hovering over StatusContent
*/
&:not(.mfm-hover:not(:hover)) {
._mfm_tada_ {
font-size: 150%;
animation: mfm-tada 1s linear infinite both;
}
._mfm_jelly_ {
animation: mfm-rubberBand 1s linear infinite both;
}
._mfm_twitch_ {
animation: mfm-twitch 0.5s ease infinite;
}
._mfm_shake_ {
animation: mfm-shake 0.5s ease infinite;
}
._mfm_spin_ {
animation: mfm-spin 0.5s linear infinite;
}
._mfm_spin_[data-x] {
animation-name: mfm-spinX;
}
._mfm_spin_[data-y] {
animation-name: mfm-spinY;
}
._mfm_spin_[left] {
animation-direction: reverse;
}
._mfm_spin_[alternate] {
animation-direction: alternate;
}
._mfm_jump_ {
animation: mfm-jump 0.75s linear infinite;
}
._mfm_bounce_ {
animation: mfm-bounce 0.75s linear infinite;
transform-origin: center bottom;
}
._mfm_rainbow_ {
animation: mfm-rainbow 1s linear infinite;
}
}
}

View file

@ -82,6 +82,9 @@ const StatusContent = {
if (!this.status.nsfw) {
return false
}
if (this.status.summary && this.localCollapseSubjectDefault) {
return false
}
return true
},
attachmentSize () {
@ -103,9 +106,6 @@ const StatusContent = {
renderMisskeyMarkdown () {
return this.mergedConfig.renderMisskeyMarkdown
},
hasResolvedQuote () {
return !!this.status.quote
},
...mapGetters(['mergedConfig']),
...mapState({
currentUser: state => state.users.currentUser

View file

@ -1,7 +1,7 @@
<template>
<div
class="StatusContent"
:class="{ '-compact': compact, 'mfm-hover': renderMfmOnHover, 'mfm-disabled': !renderMisskeyMarkdown, 'quote-resolved': hasResolvedQuote }"
:class="{ '-compact': compact, 'mfm-hover': renderMfmOnHover, 'mfm-disabled': !renderMisskeyMarkdown }"
>
<slot name="header" />
<StatusBody
@ -64,7 +64,6 @@
</template>
<script src="./status_content.js"></script>
<style lang="scss" src="./mfm.scss" />
<style lang="scss">
.StatusContent {
flex: 1;
@ -76,15 +75,28 @@
height: 50px;
}
}
&.mfm-hover:not(:hover) {
.mfm {
animation: none !important;
}
}
&.mfm-disabled {
span {
font-size: 100% !important;
}
.mfm {
animation: none !important;
}
.emoji {
height: 32px !important;
}
}
}
.quote-resolved .quote-inline,
.quote-inline,
.quote + .link-preview {
display: none;
}
.quote-resolved .quote .quote-inline {
display: inline;
display: none;
}
</style>

View file

@ -6,21 +6,13 @@ const StillImage = {
'imageLoadError',
'imageLoadHandler',
'alt',
'title',
'height',
'width',
'noStopGifs'
'width'
],
data () {
return {
stopGifs:
!this.noStopGifs
&& (
this.$store.getters.mergedConfig.stopGifs
|| window.matchMedia('(prefers-reduced-motion: reduce)').matches
),
stopGifs: this.$store.getters.mergedConfig.stopGifs || window.matchMedia('(prefers-reduced-motion: reduce)').matches,
isAnimated: false,
isPixelArt: false,
imageTypeLabel: ''
}
},
@ -28,16 +20,6 @@ const StillImage = {
animated () {
return this.stopGifs && this.isAnimated
},
titleText () {
return this.title || this.alt
},
ariaLabel () {
// if the title (a UI hint) differs from the alt text (describing image content),
// we wat to add an aria-label to pass along the UI hint to screen readers
if (this.title && this.alt != this.title)
return this.title
return undefined
},
style () {
const appendPx = (str) => /\d$/.test(str) ? str + 'px' : str
return {
@ -52,18 +34,11 @@ const StillImage = {
if (!image) return
this.imageLoadHandler && this.imageLoadHandler(image)
this.detectAnimation(image)
this.detectPixelArt(image)
this.drawThumbnail()
},
onError () {
this.imageLoadError && this.imageLoadError()
},
detectPixelArt (image) {
// Safe maximum: 32x32 image, equivalent or smaller
this.isPixelArt ||= image.naturalHeight * image.naturalWidth <= 32 * 32;
// Common size for oldweb badges.
this.isPixelArt ||= image.naturalWidth == 88 && image.naturalHeight == 31;
},
detectAnimation (image) {
const mediaProxyAvailable = this.$store.state.instance.mediaProxyAvailable
@ -84,51 +59,39 @@ const StillImage = {
},
detectAnimationWithFetch (image) {
// Browser Cache should ensure image doesn't get loaded twice if cache exists
return fetch(image.src, {
fetch(image.src, {
referrerPolicy: 'same-origin'
})
.then(data => {
// We don't need to read the whole file so only call it once
return data.body.getReader().read()
data.body.getReader().read()
.then(reader => {
// Ordered from least to most intensive
if (this.isGIF(reader.value)) {
this.isAnimated = true
this.setLabel('GIF')
return true
return
}
if (this.isAnimatedWEBP(reader.value)) {
this.isAnimated = true
this.setLabel('WEBP')
return true
return
}
if (this.isAnimatedPNG(reader.value)) {
this.isAnimated = true
this.setLabel('APNG')
return true
}
return false
})
})
.catch(() => {
// this.imageLoadError && this.imageLoadError()
return null
})
},
detectWithMediaProxy (image) {
this.detectAnimationWithFetch(image)
},
async detectWithoutMediaProxy (image) {
// If media is local, we can still fetch,
// otherwise CORS wont allow it and we fall back to checking extensions
// (XXX: ideally wed _only_ fetch if its an allowed domain;
// i.e. local media or proxy, but we currently have no way of knowing)
const classifiedAsAnim = await this.detectAnimationWithFetch(image)
if (classifiedAsAnim != null) {
return
}
// Otherwise we'll just make a guess based on extension
detectWithoutMediaProxy (image) {
// We'll just assume that gifs and webp are animated
const extension = image.src.split('.').pop().toLowerCase()
if (extension === 'gif') {
@ -141,15 +104,18 @@ const StillImage = {
this.setLabel('WEBP')
return
}
// Beware: APNGs also sometimes use just a plain png extension!
// (but this would mislabel too many images as "animated")
if (extension === 'apng') {
this.isAnimated = true
this.setLabel('APNG')
return
// Beware the apng! use this if ye dare
// if (extension === 'png') {
// this.isAnimated = true
// this.setLabel('PNG')
// return
// }
// Hail mary for extensionless
if (extension.includes('/')) {
// Don't mind the CORS error barrage
this.detectAnimationWithFetch(image)
}
// Hail mary for extensionless files we cannot fetch
},
setLabel (name) {
this.imageTypeLabel = name;
@ -241,7 +207,7 @@ const StillImage = {
}
context.clearRect(0, 0, canvas.width, canvas.height); // Clear the previous unscaled image
context.imageSmoothingEnabled = !this.isPixelArt;
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
// Draw the good one for realsies

View file

@ -2,9 +2,8 @@
<div
ref="still-image"
class="still-image"
:class="{ animated: animated, pixelart: isPixelArt }"
:class="{ animated: animated }"
:style="style"
:aria-label="ariaLabel"
>
<div
v-if="animated && imageTypeLabel"
@ -21,7 +20,7 @@
ref="src"
:key="src"
:alt="alt"
:title="titleText"
:title="alt"
:src="src"
:referrerpolicy="referrerpolicy"
@load="onLoad"
@ -96,8 +95,5 @@
visibility: visible;
}
}
&.pixelart {
image-rendering: pixelated;
}
}
</style>

View file

@ -22,7 +22,6 @@ const Timeline = {
'title',
'userId',
'listId',
'conversationId',
'tag',
'embedded',
'count',
@ -120,7 +119,6 @@ const Timeline = {
showImmediately,
userId: this.userId,
listId: this.listId,
conversationId: this.conversationId,
tag: this.tag
})
},
@ -183,7 +181,6 @@ const Timeline = {
showImmediately: true,
userId: this.userId,
listId: this.listId,
conversationId: this.conversationId,
tag: this.tag
}).then(({ statuses }) => {
if (statuses && statuses.length === 0) {

View file

@ -14,13 +14,6 @@
z-index: 2;
}
.timeline-extra-heading {
width: 100%;
padding-top: 0.5em;
padding-bottom: 0.5em;
background-color: var(--panel, #182230);
}
&.-nonpanel {
.timeline-heading {
text-align: center;

View file

@ -52,12 +52,6 @@
</button>
</div>
</div>
<div
v-if="$slots.extraHeading"
class="timeline-extra-heading"
>
<slot name="extraHeading" />
</div>
<div :class="classes.body">
<div
ref="timeline"

View file

@ -13,7 +13,7 @@ export const timelineNames = () => {
return {
'friends': 'nav.home_timeline',
'bookmarks': 'nav.bookmarks',
'dm_conversation': 'nav.dm_conversation',
'dms': 'nav.dms',
'public-timeline': 'nav.public_tl',
'public-external-timeline': 'nav.twkn',
'bubble-timeline': 'nav.bubble_timeline'

View file

@ -80,6 +80,22 @@
>{{ $t("nav.bookmarks") }}</span>
</router-link>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'dms', params: { username: currentUser.screen_name } }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding "
icon="envelope"
/>
<span
:title="$t('nav.dms')"
:aria-label="$t('nav.dms')"
>{{ $t("nav.dms") }}</span>
</router-link>
</li>
</ul>
</template>

View file

@ -80,6 +80,22 @@
>{{ $t("nav.bookmarks") }}</span>
</router-link>
</li>
<li v-if="currentUser">
<router-link
class="menu-item"
:to="{ name: 'dms', params: { username: currentUser.screen_name } }"
>
<FAIcon
fixed-width
class="fa-scale-110 fa-old-padding "
icon="envelope"
/>
<span
:title="$t('nav.dms')"
:aria-label="$t('nav.dms')"
>{{ $t("nav.dms") }}</span>
</router-link>
</li>
</ul>
</template>

View file

@ -19,6 +19,7 @@ export const timelineNames = () => {
return {
'friends': 'nav.home_timeline',
'bookmarks': 'nav.bookmarks',
'dms': 'nav.dms',
'public-timeline': 'nav.public_tl',
'public-external-timeline': 'nav.twkn',
'bubble-timeline': 'nav.bubble_timeline'

View file

@ -6,7 +6,7 @@
<StillImage
v-if="user"
class="avatar"
:alt="user.avatar_description || user.screen_name_ui"
:alt="user.screen_name_ui"
:title="user.screen_name_ui"
:src="imgSrc(user.profile_image_url_original)"
:image-load-error="imageLoadError"

View file

@ -12,7 +12,6 @@ import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faImages,
faRss,
faSearchPlus,
faExternalLinkAlt,
@ -22,7 +21,6 @@ import {
library.add(
faRss,
faBell,
faImages,
faSearchPlus,
faExternalLinkAlt,
faEdit
@ -30,7 +28,7 @@ library.add(
export default {
props: [
'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar', 'showMediaButton'
'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
],
data () {
return {
@ -56,6 +54,14 @@ export default {
'user-card-bordered': this.bordered === true // set border for all sides
}]
},
style () {
return {
backgroundImage: [
`linear-gradient(to bottom, var(--profileTint), var(--profileTint))`,
`url(${this.user.cover_photo})`
].join(', ')
}
},
isOtherUser () {
return this.user.id !== this.$store.state.users.currentUser.id
},
@ -111,15 +117,6 @@ export default {
shouldConfirmMute () {
return this.mergedConfig.modalOnMute
},
compactUserInfo () {
return this.$store.getters.mergedConfig.compactUserInfo
&& (this.$store.state.interface.layoutType !== 'mobile')
&& this.switcher
},
hasProfileMedia() {
const user = this.user
return (user && (user.profile_image_url_original || user.cover_photo || user.background_image))
},
...mapGetters(['mergedConfig'])
},
components: {
@ -189,53 +186,13 @@ export default {
zoomAvatar () {
const attachment = {
url: this.user.profile_image_url_original,
description: this.user.avatar_description,
mimetype: 'image'
}
this.$store.dispatch('setMedia', [attachment])
this.$store.dispatch('setCurrentMedia', attachment)
},
makeMediaObject(link, altText) {
// Pseudo media attachment object with just the fields relevant for gallery display
return {
type: "image",
mimetype: "image/something",
url: link,
// proper media attachments omit field if no alt, but Mastodon decided in
// user responses to return empty string for "no alt". Normalise this here
description: altText || undefined
}
},
showProfileMedia () {
const user = this.user
const media = []
let startMedia = undefined
if (user.profile_image_url_original) {
const avatar = this.makeMediaObject(user.profile_image_url_original, user.avatar_description)
media.push(avatar)
if (!startMedia) startMedia = avatar
}
if (user.cover_photo) {
const header = this.makeMediaObject(user.cover_photo, user.header_description)
media.push(header)
if (!startMedia) startMedia = header
}
if (user.background_image) {
const background = this.makeMediaObject(user.background_image, user.pleroma?.background_image_description)
media.push(background)
if (!startMedia) startMedia = background
}
if (startMedia) {
this.$store.dispatch('setMedia', media)
this.$store.dispatch('setCurrentMedia', startMedia)
}
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { repliedUser: this.user })
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
}
}
}

View file

@ -21,17 +21,6 @@
position: relative;
}
.user-buttons {
grid-area: edit;
display: flex;
padding: .5em 0 .5em 0;
justify-self: end;
}
.user-profile-media-button {
margin-left: .5em;
}
.panel-body {
word-wrap: break-word;
border-bottom-right-radius: inherit;
@ -46,61 +35,25 @@
left: 0;
right: 0;
bottom: 0;
z-index: -2;
mask: linear-gradient(to top, white, transparent) bottom no-repeat,
linear-gradient(to top, white, white);
// Autoprefixer seem to ignore this one, and also syntax is different
-webkit-mask-composite: xor;
mask-composite: exclude;
background-size: cover;
mask-size: 100% 60%;
border-top-left-radius: calc(var(--panelRadius) - 1px);
border-top-right-radius: calc(var(--panelRadius) - 1px);
background-color: var(--profileBg);
// this avoids transition issues if --profileBg or --bg are partially transparent
mask: linear-gradient(white 95%, transparent);
mask-size: 100% 100%;
mask-repeat: no-repeat;
overflow: hidden;
z-index: -2;
&.hide-bio {
mask: linear-gradient(white 50%, transparent);
}
.image-tint {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: -2;
width: 100%;
height: 100%;
background-image: linear-gradient(
in srgb-linear to bottom,
var(--profileTint) 80%,
var(--bg)
);
}
.image-banner {
position: absolute;
top: 0;
left: 0;
z-index: -3;
// Approximate cubic-eased transition; plain linear does not achieve satisfying result
mask: linear-gradient(
to bottom,
white 70%,
#fffffff7 76%,
#ffffffbe 82%,
#ffffff41 88%,
#ffffff08 96%,
transparent
);
mask-size: 100% 40px;
}
}
&-bio {
text-align: center;
display: block;
line-height: 1.3;
padding: 1em;
@ -147,14 +100,15 @@
padding: 0 26px;
.container {
min-width: 0;
padding: 16px 0 6px;
display: grid;
grid-template-areas:
"pfp name edit"
"pfp summary summary"
"stats stats stats";
grid-template-columns: auto 1fr auto;
align-items: start;
display: flex;
align-items: flex-start;
max-height: 56px;
> * {
min-width: 0;
}
.Avatar {
--_avatarShadowBox: var(--avatarShadow);
@ -169,7 +123,6 @@
}
&-avatar-link {
grid-area: pfp;
position: relative;
cursor: pointer;
@ -200,8 +153,8 @@
.external-link-button, .edit-profile-button {
cursor: pointer;
width: 2.3em;
text-align: right;
width: 2.5em;
text-align: center;
margin: -0.5em 0;
padding: 0.5em 0;
@ -212,16 +165,12 @@
}
.user-summary {
grid-area: summary;
display: grid;
grid-template-areas:
"name name name name name"
"hand role lock avg _";
grid-template-columns:
auto auto auto auto 1fr;
justify-items: start;
display: block;
margin-left: 0.6em;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 0;
// This is so that text doesn't get overlapped by avatar's shadow if it has
// big one
z-index: 1;
@ -229,80 +178,54 @@
--emoji-size: 1.7em;
.user-locked {
margin-left: 0.5em;
grid-area: lock;
}
.user-screen-name {
min-width: 1px;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
grid-area: hand;
}
.dailyAvg {
min-width: 1px;
margin-left: 1em;
font-size: 0.7em;
color: $fallback--text;
color: var(--text, $fallback--text);
grid-area: avg;
}
.user-roles {
.top-line,
.bottom-line {
display: flex;
grid-area: role;
.user-role {
color: $fallback--text;
color: var(--alertNeutralText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--alertNeutral, $fallback--fg);
}
}
}
.user-counts {
grid-area: stats;
display: flex;
line-height:16px;
padding-top: 0.5em;
text-align: center;
justify-content: space-around;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
align-self: center;
.user-count {
padding: .5em 0 .5em 0;
margin: 0 .5em;
h5 {
font-size:1em;
font-weight: bolder;
margin: 0 0 0.25em;
}
a {
text-decoration: none;
}
}
}
.user-name {
text-align: start;
text-overflow: ellipsis;
overflow: hidden;
margin-left: 0.6em;
flex: 1 1 auto;
margin-right: 1em;
font-size: 1.1em;
grid-area: name;
align-self: center;
white-space: nowrap;
max-width: 100%;
z-index: 1; // so shadow from user avatar doesn't overlap it
}
.bottom-line {
font-weight: light;
font-size: 1.1em;
align-items: baseline;
.lock-icon {
margin-left: 0.5em;
}
.user-screen-name {
min-width: 1px;
flex: 0 1 auto;
text-overflow: ellipsis;
overflow: hidden;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
}
.dailyAvg {
min-width: 1px;
flex: 0 0 auto;
margin-left: 1em;
font-size: 0.7em;
color: $fallback--text;
color: var(--text, $fallback--text);
}
.user-role {
flex: none;
color: $fallback--text;
color: var(--alertNeutralText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--alertNeutral, $fallback--fg);
}
}
.user-meta {
@ -367,21 +290,34 @@
margin: 0;
}
}
&.-compact {
.container {
grid-template-areas:
"pfp name stats edit"
"pfp summary stats edit";
grid-template-columns: auto auto 1fr auto;
}
.user-counts {
padding-top: 0;
justify-content: space-evenly;
}
}
}
.sidebar .edit-profile-button {
display: none;
}
.user-counts {
display: flex;
line-height:16px;
padding: .5em 1.5em 0em 1.5em;
text-align: center;
justify-content: space-between;
color: $fallback--lightText;
color: var(--lightText, $fallback--lightText);
flex-wrap: wrap;
}
.user-count {
flex: 1 0 auto;
padding: .5em 0 .5em 0;
margin: 0 .5em;
h5 {
font-size:1em;
font-weight: bolder;
margin: 0 0 0.25em;
}
a {
text-decoration: none;
}
}

View file

@ -4,23 +4,12 @@
:class="classes"
>
<div
class="background-image"
:class="{ 'hide-bio': hideBio }"
>
<div
class="image-tint"
/>
<img
class="image-banner"
width="100%"
:src="user.cover_photo"
/>
</div>
:style="style"
class="background-image"
/>
<div class="panel-heading -flexible-height">
<div
class="user-info"
:class="{ '-compact': compactUserInfo }"
>
<div class="user-info">
<div class="container">
<a
v-if="allowZoomingAvatar"
@ -40,7 +29,6 @@
</a>
<router-link
v-else
class="user-info-avatar-link"
:to="userProfileLink(user)"
>
<UserAvatar
@ -48,124 +36,94 @@
:user="user"
/>
</router-link>
<RichContent
:title="user.name"
class="user-name"
:html="user.name"
:emoji="user.emoji"
/>
<div class="user-summary">
<router-link
class="user-screen-name"
:title="user.screen_name_ui"
:to="userProfileLink(user)"
>
@{{ user.screen_name_ui }}
</router-link>
<span
v-if="!hideBio && (user.deactivated || !!visibleRole || user.bot)"
class="user-roles"
>
<span
v-if="user.deactivated"
class="alert user-role"
<div class="top-line">
<RichContent
:title="user.name"
class="user-name"
:html="user.name"
:emoji="user.emoji"
/>
<button
v-if="!isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@click.stop="openProfileTab"
>
{{ $t('user_card.deactivated') }}
<FAIcon
fixed-width
class="icon"
icon="edit"
:title="$t('user_card.edit_profile')"
/>
</button>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
target="_blank"
class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
icon="external-link-alt"
/>
</a>
<a
v-if="isOtherUser"
:href="user.statusnet_profile_url + '.rss'"
target="_blank"
class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
icon="rss"
/>
</a>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
:relationship="relationship"
/>
</div>
<div class="bottom-line">
<router-link
class="user-screen-name"
:title="user.screen_name_ui"
:to="userProfileLink(user)"
>
@{{ user.screen_name_ui }}
</router-link>
<template v-if="!hideBio">
<span
v-if="user.deactivated"
class="alert user-role"
>
{{ $t('user_card.deactivated') }}
</span>
<span
v-if="!!visibleRole"
class="alert user-role"
>
{{ $t(`general.role.${visibleRole}`) }}
</span>
<span
v-if="user.bot"
class="alert user-role"
>
{{ $t('user_card.bot') }}
</span>
</template>
<span v-if="user.locked">
<FAIcon
class="lock-icon"
icon="lock"
size="sm"
/>
</span>
<span
v-if="!!visibleRole"
class="alert user-role"
>
{{ $t(`general.role.${visibleRole}`) }}
</span>
<span
v-if="user.bot"
class="alert user-role"
>
{{ $t('user_card.bot') }}
</span>
</span>
<span
v-if="user.locked"
class="user-locked"
>
<FAIcon
class="lock-icon"
icon="lock"
size="sm"
/>
</span>
<span
v-if="!mergedConfig.hideUserStats && !hideBio"
class="dailyAvg"
>{{ dailyAvg }} {{ $t('user_card.per_day') }}</span>
</div>
<div
v-if="!mergedConfig.hideUserStats && switcher"
class="user-counts"
>
<div
class="user-count"
@click.prevent="setProfileView('statuses')"
>
<h5>{{ $t('user_card.statuses') }}</h5>
<span>{{ user.statuses_count }} <br></span>
v-if="!mergedConfig.hideUserStats && !hideBio"
class="dailyAvg"
>{{ dailyAvg }} {{ $t('user_card.per_day') }}</span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('friends')"
>
<h5>{{ $t('user_card.followees') }}</h5>
<span>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('followers')"
>
<h5>{{ $t('user_card.followers') }}</h5>
<span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
</div>
</div>
<div class="user-buttons">
<button
v-if="!isOtherUser && user.is_local"
class="button-unstyled edit-profile-button"
@click.stop="openProfileTab"
>
<FAIcon
fixed-width
class="icon"
icon="edit"
:title="$t('user_card.edit_profile')"
/>
</button>
<a
v-if="isOtherUser && !user.is_local"
:href="user.statusnet_profile_url"
target="_blank"
class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
icon="external-link-alt"
/>
</a>
<a
v-if="isOtherUser"
:href="user.statusnet_profile_url + '.rss'"
target="_blank"
class="button-unstyled external-link-button"
>
<FAIcon
class="icon"
icon="rss"
/>
</a>
<AccountActions
v-if="isOtherUser && loggedIn"
:user="user"
:relationship="relationship"
/>
</div>
</div>
<div class="user-meta">
@ -226,23 +184,6 @@
</option>
</Select>
</div>
<div
class="user-profile-media-button"
v-if="showMediaButton"
>
<button
class="btn button-default"
:title="$i18n.t('user_card.show_profile_media')"
:aria-label="$i18n.t('user_card.show_profile_media')"
:disabled="!hasProfileMedia"
@click="showProfileMedia"
>
<FAIcon
icon="images"
class="fa-scale-110 fa-old-padding"
/>
</button>
</div>
</div>
<div
v-if="loggedIn && isOtherUser"
@ -328,13 +269,38 @@
v-if="!hideBio"
class="panel-body"
>
<div
v-if="!mergedConfig.hideUserStats && switcher"
class="user-counts"
>
<div
class="user-count"
@click.prevent="setProfileView('statuses')"
>
<h5>{{ $t('user_card.statuses') }}</h5>
<span>{{ user.statuses_count }} <br></span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('friends')"
>
<h5>{{ $t('user_card.followees') }}</h5>
<span>{{ hideFollowsCount ? $t('user_card.hidden') : user.friends_count }}</span>
</div>
<div
class="user-count"
@click.prevent="setProfileView('followers')"
>
<h5>{{ $t('user_card.followers') }}</h5>
<span>{{ hideFollowersCount ? $t('user_card.hidden') : user.followers_count }}</span>
</div>
</div>
<RichContent
v-if="!hideBio"
class="user-card-bio"
:html="user.description_html"
:emoji="user.emoji"
:handle-links="true"
:style='{"text-align": $store.getters.mergedConfig.centerAlignBio ? "center" : "start"}'
/>
</div>
<teleport to="#modal">

View file

@ -9,7 +9,6 @@
:switcher="true"
:selected="timeline.viewing"
:allow-zooming-avatar="true"
:show-media-button="true"
rounded="top"
/>
<div
@ -256,6 +255,7 @@
.user-profile-field-name, .user-profile-field-value {
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
padding: 0.5em 1.5em;
box-sizing: border-box;

View file

@ -486,7 +486,6 @@
"cGreen": "Grün (Retweet)",
"cOrange": "Orange (Favorisieren)",
"cRed": "Rot (Abbrechen)",
"center_align_bio": "Zentrale Textausrichtung in der Bio",
"change_email": "Ändere Email",
"change_email_error": "Es trat ein Problem auf beim Versuch, deine Email Adresse zu ändern.",
"change_password": "Passwort ändern",
@ -497,7 +496,6 @@
"checkboxRadius": "Auswahlfelder",
"collapse_subject": "Beiträge mit Inhaltswarnungen einklappen",
"columns": "Spalten",
"compact_user_info": "Kompakte Benutzerinfos wenn genug Platz",
"composing": "Verfassen",
"confirm_dialogs": "Bestätigung erforderlich für:",
"confirm_dialogs_approve_follow": "Annehmen einer Followanfrage",
@ -936,7 +934,6 @@
"title": "Version"
},
"virtual_scrolling": "Anzeige der Zeitleiste optimieren",
"widen_timeline": "Zeitleiste verbreitern, um horizontalen Platz zu füllen",
"word_filter": "Wortfilter",
"wordfilter": "Wortfilter"
},

View file

@ -7,21 +7,15 @@
"replace": "Αντικατάσταση"
},
"mrf_policies": "Ενεργοποιημένες πολιτικές MRF",
"mrf_policies_desc": "Οι πολιτικές MRF επηρεάζουν τη συμπεριφορά του instance. Οι ακόλουθες πολιτικές είναι ενεργοποιημένες:",
"mrf_policies_desc": "",
"simple": {
"accept": "Αποδοχή",
"accept_desc": "Αυτό το instance αποδέχεται μηνύματα μόνο από τα ακόλουθα instances:",
"ftl_removal": "Αφαίρεση από το χρονολόγιο \"Γνωστού Δίκτυου\"",
"ftl_removal_desc": "Αυτό το instance αφαιρεί αυτά τα instances από το χρονολόγιο \"Γνωστού Δικτύου\":",
"media_nsfw": "Επιβολή ορισμού μέσων ως ευαίσθητων",
"media_nsfw_desc": "Αυτό το instance αναγκάζει τα μέσα να ορίζονται ως ευαίσθητα στις αναρτήσεις στα ακόλουθα instances:",
"media_removal": "Αφαίρεση Μέσων",
"media_removal_desc": "Αυτό το instance αφαιρεί τα μέσα από τις αναρτήσεις των ακόλουθων instances:",
"quarantine": "Καραντίνα",
"quarantine_desc": "Αυτό το instance δε θα στέλνει αναρτήσεις στα ακόλουθα instances:",
"reason": "Λόγος",
"reject": "Απόρριψη",
"reject_desc": "Αυτό το instance δε θα δέχεται μηνύματα από τα παρακάτω instances:",
"simple_policies": "Πολιτικές του instance"
}
},
@ -38,20 +32,11 @@
"inactive_message": "Αυτή η ανακοίνωση είναι ανενεργή",
"page_header": "Ανακοινώσεις",
"post_action": "Ανάρτηση",
"post_error": "Σφάλμα: {error}",
"post_form_header": "Ανάρτηση ανακοίνωσης",
"post_placeholder": "Περιεχόμενο ανακοίνωσης",
"published_time_display": "Δημοσιεύτηκε {time}",
"start_time_display": "Ξεκινάει από {time}",
"title": "Ανακοίνωση"
},
"chats": {
"chats": "Συνομιλίες",
"delete": "Διαγραφή",
"delete_confirm": "Θέλετε σίγουρα να διαγράψετε αυτό το μήνυμα;",
"empty_chat_list_placeholder": "Δεν έχετε καμία συνομιλία. Ξεκινήστε μια νέα συνομιλία!",
"empty_message_error": "Δε μπορεί να σταλεί κενό μήνυμα",
"error_loading_chat": "Κάτι δεν πήγε καλά κατά τη φόρτωση της συνομιλίας.",
"error_sending_message": "Κάτι πήγε λάθος κατά την αποστολή του μηνύματος.",
"message_user": "Στείλε μήνυμα στον/στην {nickname}",
"more": "Περισσότερα",
@ -62,15 +47,11 @@
"today": "Σήμερα"
},
"domain_mute_card": {
"mute": "Σίγαση",
"mute_progress": "Σίγαση…",
"unmute": "Αφαίρεση σίγασης",
"unmute_progress": "Αφαίρεση σίγασης…"
"mute": "Σίγαση"
},
"emoji": {
"add_emoji": "Εισαγωγή emoji",
"load_all": "Φόρτωση όλων των {emojiAmount} emoji",
"load_all_hint": "Φορτώθηκαν τα πρώτα {saneAmount} emoji, η φόρτωση όλων των emoji μπορεί να προκαλέσει θέματα απόδοσης.",
"recent": "Χρησιμοποιήθηκαν πρόσφατα",
"search_emoji": "Αναζήτηση για ένα emoji",
"stickers": "Αυτοκόλλητα"
@ -82,10 +63,7 @@
"export": "Εξαγωγή"
},
"features_panel": {
"media_proxy": "Διαμεσολαβητής μέσων",
"text_limit": "Όριο κειμένου",
"title": "Δυνατότητες",
"upload_limit": "Όριο upload"
"text_limit": "Όριο κειμένου"
},
"file_type": {
"audio": "Ήχος",
@ -101,8 +79,6 @@
"enable": "Ενεργοποίηση",
"error_retry": "Παρακαλώ δοκιμάστε ξανά",
"flash_content": "Κάντε κλικ για την εμφάνιση Flash περιεχομένου με τη χρήση του Ruffle (Πειραματικό, μπορεί να μη λειτουργεί).",
"flash_fail": "Η φόρτωση περιεχομένου flash απέτυχε, δείτε στην κονσόλα για λεπτομέρειες.",
"generic_error": "Προέκυψε ένα σφάλμα",
"loading": "Φόρτωση…",
"more": "Περισσότερα",
"optional": "προαιρετικό",
@ -128,7 +104,6 @@
"save_without_cropping": "Αποθήκευση χωρίς περικοπή"
},
"importer": {
"error": "Προέκυψε ένα σφάλμα κατά την εισαγωγή αυτού του αρχείου.",
"success": "Εισήχθη επιτυχώς."
},
"languages": {

View file

@ -236,7 +236,6 @@
"lists": {
"create": "Create",
"delete": "Delete list",
"exclusive_description": "Remove users on list from home timeline",
"following_only": "Limit to Following",
"lists": "Lists",
"new": "New List",
@ -244,27 +243,6 @@
"search": "Search users",
"title": "List title"
},
"dm_conv": {
"default_name": "Conversation {id}",
"delete_confirm": "Are you sure you want to remove the conversation {identifier}?",
"delete_confirm_accept_button": "Yes, remove",
"delete_confirm_cancel_button": "No, keep",
"delete_confirm_title": "Remove DM conversation",
"delete_tooltip": "remove conversation from list",
"last_active_date": "Last activity",
"last_active_member": "Last active",
"last_message_title": "Last message",
"mark_all_read_button": "Mark all as read",
"mark_single_read_tooltip": "mark as read",
"page_header": "Direct Conversation",
"recipients_edit_add_new_title": "Add new member:",
"recipients_edit_current_title": "Core members besides yourself:",
"recipients_edit_mode_button": "Edit core members",
"recipients_edit_mode_button_tooltip": "Edit core members",
"recipients_edit_title": "Core members of {conversation_name}",
"recipients_save": "Save changes to core members",
"unread_msg": "has unread messages"
},
"login": {
"authentication_code": "Authentication code",
"description": "Log in with OAuth",
@ -328,9 +306,6 @@
"bubble_timeline": "Bubble timeline",
"bubble_timeline_description": "Posts from instances close to yours, as recommended by the admins",
"chats": "Chats",
"dm_conv_list": "Direct Conversations",
"dm_conversation": "Direct Conversation",
"dm_conversations": "Direct Conversations",
"dms": "Direct messages",
"friend_requests": "Follow requests",
"home_timeline": "Home timeline",
@ -383,8 +358,6 @@
"expired": "Poll ended {0} ago",
"expires_in": "Poll ends in {0}",
"expiry": "Poll age",
"indicate_disclosure": "Source instance will publish voter indentity and their votes",
"indicate_anonymous": "Source instance pledged to keep voter identity anonymous",
"multiple_choices": "Multiple choices",
"not_enough_options": "Too few unique options in poll",
"option": "Option",
@ -515,7 +488,6 @@
"blocks_tab": "Blocks",
"bot": "This is a bot account",
"btnRadius": "Buttons",
"center_align_bio": "Center text in user bio",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
"cOrange": "Orange (Favorite)",
@ -530,13 +502,11 @@
"checkboxRadius": "Checkboxes",
"collapse_subject": "Collapse posts with content warnings",
"columns": "Columns",
"compact_user_info": "Compact user info when enough space",
"composing": "Composing",
"confirm_dialogs": "Require confirmation for:",
"confirm_dialogs_approve_follow": "Accepting a follow request",
"confirm_dialogs_block": "Blocking someone",
"confirm_dialogs_delete": "Deleting a post",
"confirm_dialogs_delete_dm_conv": "Deleting a DM conversation",
"confirm_dialogs_deny_follow": "Rejecting a follow request",
"confirm_dialogs_mute": "Muting someone",
"confirm_dialogs_repeat": "Repeating a post",
@ -637,9 +607,6 @@
"mascot": "Mastodon FE Mascot",
"max_depth_in_thread": "Maximum number of levels in thread to display by default",
"max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"media_alt_avatar": "Avatar alt text",
"media_alt_background": "Background alt text",
"media_alt_banner": "Banner alt text",
"mention_link_bolden_you": "Highlight mention of you when you are mentioned",
"mention_link_display": "Display mention links",
"mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
@ -752,7 +719,6 @@
"right_sidebar": "Reverse order of columns",
"save": "Save changes",
"saving_err": "Error saving settings",
"saving_err_details": "Error saving settings: {0}",
"saving_ok": "Settings saved",
"scope_copy": "Copy scope when replying (DMs are always copied)",
"search_user_to_block": "Search whom you want to block",
@ -760,7 +726,7 @@
"security": "Security",
"security_tab": "Security",
"sensitive_by_default": "Mark posts as sensitive by default",
"sensitive_if_subject": "Automatically mark post as sensitive if a content warning is specified",
"sensitive_if_subject": "Automatically mark images as sensitive if a content warning is specified",
"set_new_avatar": "Set new avatar",
"set_new_mascot": "Set new mascot",
"set_new_profile_background": "Set new profile background",
@ -982,7 +948,6 @@
},
"virtual_scrolling": "Optimize timeline rendering",
"use_blurhash": "Use blurhashes for NSFW thumbnails",
"widen_timeline": "Widen the Timeline to fill horizontal space",
"word_filter": "Word filter",
"wordfilter": "Wordfilter"
},
@ -1215,7 +1180,6 @@
"replies": "With Replies",
"report": "Report",
"requested_by": "Has requested to follow you",
"show_profile_media": "Show profile images and their alt text",
"show_repeats": "Show repeats",
"statuses": "Posts",
"subscribe": "Subscribe",

View file

@ -407,8 +407,7 @@
"private": "Ce statut sera visible par seulement vos abonné⋅e⋅s",
"public": "Ce statut sera visible par tout le monde",
"unlisted": "Ce statut ne sera pas visible dans les flux publics ni les flux fédérés"
},
"toggle_content_warning": "Activer/désactiver l'avertissement"
}
},
"registration": {
"awaiting_email_confirmation": "Votre compte a été enregistré et un courriel envoyé à votre adresse. Veuillez consulter votre boîte mail pour terminer la registration.",
@ -492,7 +491,6 @@
"cGreen": "Vert (partager)",
"cOrange": "Orange (aimer)",
"cRed": "Rouge (annuler)",
"center_align_bio": "Centrer le texte de la biographie",
"change_email": "Changer de courriel",
"change_email_error": "Il y a eu un problème pour changer votre courriel.",
"change_password": "Changez votre mot de passe",
@ -503,7 +501,6 @@
"checkboxRadius": "Cases à cocher",
"collapse_subject": "Réduire les messages avec des avertissements",
"columns": "Colonnes",
"compact_user_info": "Utiliser l'affichage compacte des biographies quand possible",
"composing": "Composition",
"confirm_dialogs": "Demander confirmation :",
"confirm_dialogs_approve_follow": "Accepter une demande de suivi",
@ -539,8 +536,6 @@
"enable_web_push_notifications": "Activer les notifications de push web",
"enter_current_password_to_confirm": "Entrez votre mot de passe actuel pour confirmer votre identité",
"expert_mode": "Avancé",
"expire_posts_enabled": "Supprimer les statuts après le nombre de jours demandé",
"expire_posts_input_placeholder": "Nombre de jours",
"export_theme": "Enregistrer le thème",
"file_export_import": {
"backup_restore": "Sauvegarde des Paramètres",
@ -604,7 +599,7 @@
"list_backups_error": "Erreur pendant la sauvegarde des listes: {error}",
"lock_account_description": "Limitez votre compte aux abonnés acceptés uniquement",
"loop_video": "Vidéos en boucle",
"loop_video_silent_only": "Boucle uniquement les vidéos sans son (les « gifs » de Mastodon)",
"loop_video_silent_only": "Boucle uniquement les vidéos sans le son (les « gifs » de Mastodon)",
"mascot": "Mascotte de l'interface Mastodon",
"max_depth_in_thread": "Nombre maximum de niveaux à afficher dans les fils par défaut",
"max_thumbnails": "Nombre maximum de miniatures par statuts",
@ -682,9 +677,7 @@
"pad_emoji": "Entourer les émoji d'espaces après leur sélections",
"panelRadius": "Fenêtres",
"pause_on_unfocused": "Suspendre le streaming lorsque l'onglet n'est pas actif",
"permit_followback_description": "Accepter toutes les demandes de suivi envoyées par les comptes que vous suivez",
"play_videos_in_modal": "Jouer les vidéos directement dans le visionneur de médias",
"post_language": "Langue par défaut des statuts",
"post_look_feel": "Apparence des status",
"post_status_content_type": "Type de contenu du statuts",
"posts": "Statuts",
@ -752,7 +745,6 @@
"show_admin_badge": "Afficher le badge d'Admin sur mon profil",
"show_moderator_badge": "Afficher le badge de Modo' sur mon profil",
"show_nav_shortcuts": "Afficher plus de raccourcis de navigations dans le panneau supérieur",
"show_page_backgrounds": "Afficher des fonds d'écran propres aux pages, ex : les biographies",
"show_panel_nav_shortcuts": "Afficher les raccourcis de navigation du flux dans le panneau supérieur",
"show_scrollbars": "Afficher la barre de défilement",
"show_wider_shortcuts": "Plus d'espace entre les raccourcis dans le panneau supérieur",
@ -928,13 +920,8 @@
"upload_a_photo": "Envoyer une photo",
"useStreamingApi": "Recevoir les messages et notifications en temps réel",
"useStreamingApiWarning": "(Non recommandé, expérimental, connu pour rater des messages)",
"use_blurhash": "Flouter les miniatures des images sensibles",
"use_contain_fit": "Ne pas rogner les miniatures des pièces-jointes",
"use_one_click_nsfw": "Ouvrir les pièces-jointes sensibles avec un seul clic",
"user_accepts_direct_messages_from": "Accepter les messages directs de",
"user_accepts_direct_messages_from_everybody": "Tout le monde",
"user_accepts_direct_messages_from_nobody": "Personne",
"user_accepts_direct_messages_from_people_i_follow": "Comptes que je suis",
"user_mutes": "Comptes",
"user_profile_default_tab": "Onglet affiché par défaut dans les profils",
"user_profiles": "Profils utilisateurs",
@ -1057,7 +1044,6 @@
"collapse": "Fermer",
"conversation": "Conversation",
"error": "Erreur lors de l'affichage du flux : {0}",
"follow_tag": "Suivre le hashtag",
"load_older": "Afficher des status plus ancien",
"no_more_statuses": "Pas plus de statuts",
"no_retweet_hint": "Le message est marqué en abonnés-seulement ou direct et ne peut pas être partagé",
@ -1067,7 +1053,6 @@
"show_new": "Afficher plus",
"socket_broke": "Connexion temps-réel perdue : CloseEvent code {0}",
"socket_reconnected": "Connexion temps-réel établie",
"unfollow_tag": "Désabonner du hashtag",
"up_to_date": "À jour"
},
"toast": {
@ -1132,7 +1117,6 @@
"block_confirm_title": "Bloquer l'utilisateur",
"block_progress": "Blocage…",
"blocked": "Bloqué !",
"blocks_you": "Vous bloque !",
"bot": "Robot",
"deactivated": "Désactivé",
"deny": "Rejeter",
@ -1147,10 +1131,7 @@
"follow_cancel": "Annuler la demande d'abonnement",
"follow_progress": "Demande en cours…",
"follow_sent": "Demande envoyée !",
"follow_tag": "Suivre le hashtag",
"follow_unfollow": "Désabonner",
"followed_tags": "Hashtags suivis",
"followed_users": "Utilisateurs suivis",
"followees": "Abonnements",
"followers": "Abonné·es",
"following": "Suivi !",
@ -1175,14 +1156,12 @@
"mute_domain": "Bloquer la domaine",
"mute_progress": "Masquage…",
"muted": "Masqué",
"not_following_any_hashtags": "Aucun hashtag suivi",
"note": "Note privée",
"per_day": "par jour",
"remote_follow": "Suivre d'une autre instance",
"remove_follower": "Désabonner",
"replies": "Statuts et réponses",
"report": "Signalement",
"requested_by": "Vous a envoyé une demande de suivi",
"show_repeats": "Montrer les partages",
"statuses": "Statuts",
"subscribe": "Abonner",
@ -1192,13 +1171,11 @@
"unfollow_confirm_accept_button": "Oui : me désabonner",
"unfollow_confirm_cancel_button": "Non : garder l'abonnement",
"unfollow_confirm_title": "Désabonner",
"unfollow_tag": "Désabonner du hashtag",
"unmute": "Démasquer",
"unmute_progress": "Démasquage…",
"unsubscribe": "Désabonner"
},
"user_profile": {
"field_validated": "Lien vérifié",
"profile_does_not_exist": "Désolé, ce profil n'existe pas.",
"profile_loading_error": "Désolé, il y a eu une erreur au chargement du profil.",
"timeline_title": "Flux du compte"

View file

@ -503,13 +503,7 @@
"columns": "Colonne",
"composing": "Composizione",
"confirm_new_password": "Conferma la nuova password",
"conversation_display": "Stile di visualizzazione delle conversazioni",
"conversation_display_linear": "Stile lineare",
"conversation_display_tree": "Stile ad albero",
"conversation_other_replies_button": "Mostra il bottone \"altre risposte\"",
"conversation_other_replies_button_below": "Sotto i post",
"current_avatar": "La tua icona attuale",
"current_mascot": "La tua mascotte attuale",
"current_password": "La tua password attuale",
"data_import_export_tab": "Importa o esporta dati",
"default_vis": "Visibilità predefinita dei messaggi",
@ -517,15 +511,11 @@
"delete_account_description": "Elimina definitivamente i tuoi dati e disattiva il tuo profilo.",
"delete_account_error": "C'è stato un problema durante l'eliminazione del tuo profilo. Se il problema persiste contatta l'amministratore della tua stanza.",
"delete_account_instructions": "Digita la tua password nel campo sottostante per eliminare il tuo profilo.",
"disable_sticky_headers": "Non fissare i titoli delle colonne in cima allo schermo",
"discoverable": "Permetti la scoperta di questo profilo a servizi di ricerca ed altro",
"domain_mutes": "Domini",
"download_backup": "Scarica",
"email_language": "Lingua delle email ricevute dal server",
"emoji_reactions_on_timeline": "Mostra reazioni nelle sequenze",
"enable_web_push_notifications": "Abilita notifiche web push",
"enter_current_password_to_confirm": "Inserisci la tua password per identificarti",
"expert_mode": "Mostra avanzate",
"export_theme": "Salva impostazioni",
"file_export_import": {
"backup_restore": "Archiviazione impostazioni",
@ -553,23 +543,18 @@
"hide_all_muted_posts": "Nascondi messaggi silenziati",
"hide_attachments_in_convo": "Nascondi gli allegati presenti nelle conversazioni",
"hide_attachments_in_tl": "Nascondi gli allegati presenti nelle sequenze",
"hide_bot_indication": "Nascondi indicatore bot nei post",
"hide_favorites_description": "Non mostrare la lista dei miei preferiti (gli utenti verranno comunque notificati)",
"hide_filtered_statuses": "Nascondi messaggi filtrati",
"hide_followers_count_description": "Non mostrare quanti seguaci ho",
"hide_followers_description": "Non mostrare i miei seguaci",
"hide_follows_count_description": "Non mostrare quanti utenti seguo",
"hide_follows_description": "Non mostrare chi seguo",
"hide_isp": "Nascondi pannello della stanza",
"hide_list_aliases_error_action": "Chiudi",
"hide_media_previews": "Nascondi anteprime",
"hide_muted_posts": "Nascondi messaggi degli utenti silenziati",
"hide_muted_threads": "Nascondi conversazioni silenziate",
"hide_post_stats": "Nascondi statistiche dei messaggi (es. il numero di preferenze)",
"hide_shoutbox": "Nascondi muro dei graffiti",
"hide_user_stats": "Nascondi statistiche dell'utente (es. il numero di seguaci)",
"hide_wallpaper": "Nascondi sfondo della stanza",
"hide_wordfiltered_statuses": "Nascondi post filtrati per parola",
"import_blocks_from_a_csv_file": "Importa blocchi da un file CSV",
"import_followers_from_a_csv_file": "Importa una lista di chi segui da un file CSV",
"import_mutes_from_a_csv_file": "Importa silenziati da un file CSV",
@ -582,14 +567,10 @@
"invalid_theme_imported": "Il file selezionato non è un tema supportato da Pleroma. Il tuo tema non è stato modificato.",
"limited_availability": "Non disponibile nel tuo browser",
"links": "Collegamenti",
"list_aliases_error": "Errore nel recupero degli alias: {error}",
"list_backups_error": "Errore nel recupero della lista dei backup: {error}",
"lock_account_description": "Vaglia manualmente i nuovi seguaci",
"loop_video": "Riproduci video in ciclo continuo",
"loop_video_silent_only": "Riproduci solo video muti in ciclo continuo (es. le \"gif\" di Mastodon)",
"mascot": "Mascotte di MastodonFE",
"max_thumbnails": "Numero massimo di anteprime per messaggio",
"mention_links": "Collegamenti delle menzioni",
"mfa": {
"authentication_methods": "Metodi di accesso",
"confirm_and_enable": "Conferma ed abilita OTP",
@ -613,12 +594,6 @@
},
"minimal_scopes_mode": "Riduci opzioni di visibilità",
"more_settings": "Altre impostazioni",
"move_account": "Sposta account",
"move_account_error": "Errore nello spostamento dell'account: {error}",
"move_account_notes": "Se vuoi spostare questo account da qualche altra parte, devi andare all'account di destinazione e aggiungere un alias che punta qui.",
"move_account_target": "Account di destinazione (es. {example})",
"moved_account": "Account spostato.",
"mute_bot_posts": "Silenzia post dei bot",
"mute_export": "Esporta silenziati",
"mute_export_button": "Esporta i silenziati in un file CSV",
"mute_import": "Carica silenziati",
@ -628,7 +603,6 @@
"mutes_tab": "Silenziati",
"name": "Nome",
"name_bio": "Nome ed introduzione",
"new_alias_target": "Aggiungi nuovo alias (es. {example})",
"new_email": "Nuova email",
"new_password": "Nuova password",
"no_blocks": "Nessun utente bloccato",
@ -646,7 +620,6 @@
"notification_visibility_likes": "Preferiti",
"notification_visibility_mentions": "Menzioni",
"notification_visibility_moves": "Migrazioni utenti",
"notification_visibility_polls": "Termine dei poll in cui hai votato",
"notification_visibility_repeats": "Condivisioni",
"notifications": "Notifiche",
"nsfw_clickthrough": "Fai click per visualizzare gli allegati offuscati",
@ -655,9 +628,7 @@
"panelRadius": "Pannelli",
"pause_on_unfocused": "Interrompi l'aggiornamento continuo mentre la scheda è in secondo piano",
"play_videos_in_modal": "Riproduci video in un riquadro a sbalzo",
"post_look_feel": "Aspetto dei post",
"post_status_content_type": "Tipo di contenuto dei messaggi",
"posts": "Post",
"preload_images": "Precarica immagini",
"presets": "Valori predefiniti",
"profile_background": "Sfondo del tuo profilo",
@ -671,8 +642,6 @@
"profile_tab": "Profilo",
"radii_help": "Imposta il raggio degli angoli (in pixel)",
"refresh_token": "Aggiorna token",
"remove_alias": "Rimuovi questo alias",
"remove_backup": "Elimina",
"replies_in_timeline": "Risposte nelle sequenze",
"reply_visibility_all": "Mostra tutte le risposte",
"reply_visibility_following": "Mostra solo le risposte rivolte a me o agli utenti che seguo",
@ -697,15 +666,12 @@
"security_tab": "Sicurezza",
"sensitive_by_default": "Tutti i miei messaggi sono scabrosi",
"set_new_avatar": "Scegli una nuova icona",
"set_new_mascot": "Imposta nuova mascotte",
"set_new_profile_background": "Scegli un nuovo sfondo",
"set_new_profile_banner": "Scegli un nuovo gonfalone",
"setting_changed": "Valore personalizzato",
"setting_server_side": "Questa impostazione è legata al tuo profilo e ha effetto su tutte le sessioni e tutti i client",
"settings": "Impostazioni",
"show_admin_badge": "Mostra l'insegna di amministratore sul mio profilo",
"show_moderator_badge": "Mostra l'insegna di moderatore sul mio profilo",
"show_scrollbars": "Mostra le barre di scorrimento delle colonne laterali",
"stop_gifs": "Riproduci GIF al passaggio del cursore",
"streaming": "Mostra automaticamente i nuovi messaggi quando sei in cima alla pagina",
"style": {
@ -814,80 +780,66 @@
},
"filter_hint": {
"always_drop_shadow": "Attenzione: quest'ombra usa sempre {0} se il tuo browser lo supporta.",
"avatar_inset": "Tieni presente che combinare ombre (sia incavate che non) sulle icone utente potrebbe dare risultati strani con avatar trasparenti.",
"drop_shadow_syntax": "{0} non supporta il parametro {1} con la keyword {2}.",
"inset_classic": "Le ombre incavate usano {0}",
"spread_zero": "Le ombre con espansione maggiore di zero appariranno come se l'espansione fosse zero"
"avatar_inset": "Tieni presente che combinare ombre (sia incluse che non) sulle icone utente potrebbe dare risultati strani con quelle trasparenti.",
"drop_shadow_syntax": "{0} non supporta il parametro {1} né la keyword {2}.",
"inset_classic": "Le ombre incluse usano {0}",
"spread_zero": "Lo spandimento maggiore di zero si azzera sulle ombre"
},
"hintV3": "Per le ombre puoi anche usare la sintassi {0} per usare l'altro slot colore.",
"inset": "Incavatura",
"override": "Sovrascrivi",
"shadow_id": "Ombra #{value}",
"spread": "Espansione"
"hintV3": "Per le ombre puoi anche usare la sintassi {0} per sfruttare il secondo colore.",
"inset": "Includi",
"override": "Sostituisci",
"shadow_id": "Ombra numero {value}",
"spread": "Spandi"
},
"switcher": {
"clear_all": "Azzera tutto",
"clear_opacity": "Azzera opacità",
"clear_opacity": "Rimuovi opacità",
"help": {
"fe_downgraded": "La versione di PleromaFE è riportata ad una versione precedente.",
"fe_upgraded": "Il motore dei temi di PleromaFE è stato aggiornato insieme all'interfaccia.",
"future_version_imported": "Il tema importato è stato creato per una versione più nuova del frontend.",
"migration_napshot_gone": "Per qualche motivo non è stata trovata l'anteprima del tema, non tutto potrebbe essere come ricordi.",
"migration_snapshot_ok": "Per sicurezza, è stata caricata l'anteprima del tema. Puoi provare a caricarne i contenuti.",
"older_version_imported": "Il file importato è stato creato per una versione precedente del frontend.",
"snapshot_missing": "Il file non è provvisto di anteprima, quindi potrebbe essere diverso da come appare.",
"fe_downgraded": "L'interfaccia è stata portata ad una versione precedente.",
"fe_upgraded": "Lo schema dei temi è stato aggiornato insieme all'interfaccia.",
"future_version_imported": "Il tema importato è stato creato per una versione più recente dell'interfaccia.",
"migration_napshot_gone": "Anteprima del tema non trovata, non tutto potrebbe essere come ricordi.",
"migration_snapshot_ok": "Ho caricato l'anteprima del tema. Puoi provare a caricarne i contenuti.",
"older_version_imported": "Il tema importato è stato creato per una versione precedente dell'interfaccia.",
"snapshot_missing": "Il tema non è provvisto di anteprima, quindi potrebbe essere diverso da come appare.",
"snapshot_present": "Tutti i valori sono sostituiti dall'anteprima del tema. Puoi invece caricare i suoi contenuti.",
"snapshot_source_mismatch": "Conflitto di versione: probabilmente il frontend è stato deaggiornato e poi aggiornato di nuovo. Se hai modificato il tema con una vecchia versione usa il tema precedente, altrimenti usa quello nuovo.",
"upgraded_from_v2": "PleromaFE è stato aggiornato, il tema potrebbe essere un pochino diverso da come lo ricordi.",
"v2_imported": "Il file importato è stato creato per un vecchio frontend. Cerchiamo di massimizzare la compatibilità, ma potrebbero esserci inconsistenze."
"snapshot_source_mismatch": "Conflitto di versione: probabilmente l'interfaccia è stata portata indietro e poi aggiornata di nuovo. Se hai modificato il tema con una vecchia versione usa il tema precedente, altrimenti puoi usare il nuovo.",
"upgraded_from_v2": "L'interfaccia è stata aggiornata, il tema potrebbe essere diverso da come lo ricordi.",
"v2_imported": "Il tema importato è stato creato per una vecchia interfaccia. Non tutto potrebbe essere come inteso."
},
"keep_as_is": "Mantieni com'è",
"keep_as_is": "Mantieni tal quale",
"keep_color": "Mantieni colori",
"keep_fonts": "Mantieni font",
"keep_opacity": "Mantieni opacità",
"keep_roundness": "Mantieni vertici",
"keep_shadows": "Mantieni ombre",
"load_theme": "Carica tema",
"reset": "Azzera",
"reset": "Reimposta",
"save_load_hint": "Le opzioni \"mantieni\" conservano le impostazioni correnti quando selezioni o carichi un tema, e le salvano quando ne esporti uno. Quando nessuna casella è selezionata, tutte le impostazioni correnti saranno salvate nel tema.",
"use_snapshot": "Versione precedente",
"use_source": "Nuova versione"
}
},
"subject_input_always_show": "Mostra sempre il campo avvertenza sul contenuto",
"subject_line_behavior": "Copia avvertenza sul contenuto quando rispondi",
"subject_line_email": "Come nelle email: \"re: avvertenza\"",
"subject_line_mastodon": "Come su Mastodon: copia com'è",
"subject_input_always_show": "Mostra sempre il campo Oggetto",
"subject_line_behavior": "Copia oggetto quando rispondi",
"subject_line_email": "Come nelle email: \"re: oggetto\"",
"subject_line_mastodon": "Come in Mastodon: copia tal quale",
"subject_line_noop": "Non copiare",
"text": "Testo",
"theme": "Tema",
"theme_help": "Usa colori esadecimali (#rrvvbb) per personalizzare il tuo tema colori.",
"theme_help_v2_1": "Puoi anche sovrascrivere colore ed opacità di alcuni elementi spuntando la casella. Usa il pulsante \"Azzera\" per azzerare tutte le sovrascritture.",
"theme_help_v2_2": "Le icone vicino alcuni elementi sono indicatori del contrasto fra testo e sfondo, passaci sopra col puntatore per ulteriori informazioni. Se usano la trasparenza, questi indicatori mostrano come sarebbero nel peggior caso possibile.",
"third_column_mode": "Quando c'è abbastanza spazio, mostra una terza colonna contenente",
"third_column_mode_none": "Non mostrare proprio la terza colonna",
"third_column_mode_notifications": "Colonna notifiche",
"third_column_mode_postform": "Modulo post principale e navigazione",
"theme_help": "Usa colori esadecimali (#rrggbb) per personalizzare il tuo schema di colori.",
"theme_help_v2_1": "Puoi anche forzare colore ed opacità di alcuni elementi selezionando la casella. Usa il pulsante \"Azzera\" per azzerare tutte le forzature.",
"theme_help_v2_2": "Le icone vicino alcuni elementi sono indicatori del contrasto fra testo e sfondo, passaci sopra col puntatore per ulteriori informazioni. Se usani trasparenze, questi indicatori mostrano il peggior caso possibile.",
"token": "Token",
"tooltipRadius": "Suggerimenti/allerte",
"translation_language": "Lingua finale di traduzione automatica",
"tree_advanced": "Mostra bottoni aggiuntivi per aprire e chiudere catene di risposte nelle conversazioni",
"tree_fade_ancestors": "Mostra antenati del post corrente in testo semitrasparente",
"tooltipRadius": "Suggerimenti/avvisi",
"type_domains_to_mute": "Cerca domini da silenziare",
"upload_a_photo": "Carica una foto",
"upload_a_photo": "Carica un'immagine",
"useStreamingApi": "Ricevi messaggi e notifiche in tempo reale",
"useStreamingApiWarning": "",
"use_blurhash": "Usa blurhash per anteprime NSFW",
"useStreamingApiWarning": "(Sconsigliato, sperimentale, può saltare messaggi)",
"use_contain_fit": "Non ritagliare le anteprime degli allegati",
"use_one_click_nsfw": "Apri allegati NSFW con un solo click",
"user_accepts_direct_messages_from": "Accetta post «diretti» da",
"user_accepts_direct_messages_from_everybody": "Tutti",
"user_accepts_direct_messages_from_nobody": "Nessuno",
"user_accepts_direct_messages_from_people_i_follow": "Persone che seguo",
"use_one_click_nsfw": "Apri media offuscati con un solo click",
"user_mutes": "Utenti",
"user_profile_default_tab": "Scheda predefinita sul profilo degli utenti",
"user_profiles": "Profili utente",
"user_settings": "Impostazioni utente",
"user_settings": "Impostazioni Utente",
"valid_until": "Valido fino a",
"values": {
"false": "no",
@ -895,141 +847,86 @@
},
"version": {
"backend_version": "Versione backend",
"frontend_version": "Versione frontend",
"frontend_version": "Versione interfaccia",
"title": "Versione"
},
"virtual_scrolling": "Velocizza rendering sequenze",
"word_filter": "Filtro per parola",
"wordfilter": "Filtro per parola"
},
"settings_profile": {
"creating": "Creazione del nuovo profilo di impostazioni \"{profile}\"…",
"synchronization_error": "Non è stato possibile sincronizzare le impostazioni: {err}",
"synchronized": "Impostazioni sincronizzate!",
"synchronizing": "Sincronizzazione del profilo di impostazioni \"{profile}\"…"
"virtual_scrolling": "Velocizza l'elaborazione delle sequenze",
"word_filter": "Parole filtrate"
},
"status": {
"ancestor_follow": "Vedi {numReplies} altra risposta sotto questo post | Vedi {numReplies} altre risposte sotto questo post",
"ancestor_follow_with_icon": "{icon} {text}",
"attachment_stop_flash": "Ferma Flash player",
"bookmark": "Aggiungi segnalibro",
"collapse_attachments": "Riduci allegati",
"copy_link": "Copia collegamento al post",
"delete": "Elimina post",
"delete_confirm": "Vuoi davvero eliminare questo post?",
"delete_confirm_accept_button": "Sì, eliminalo",
"delete_confirm_cancel_button": "No, tienilo",
"delete_confirm_title": "Conferma eliminazione",
"edit": "Modifica",
"edit_history": "Cronologia modifiche",
"edit_history_modal_title": "Modificato {historyCount} volta | Modificato {historyCount} volte",
"edited_at": "Modificato {time}",
"copy_link": "Copia collegamento",
"delete": "Elimina messaggio",
"delete_confirm": "Vuoi veramente eliminare questo messaggio?",
"expand": "Espandi",
"external_source": "Fonte originale",
"external_source": "Vai all'origine",
"favorites": "Preferiti",
"hide_attachment": "Nascondi allegato",
"hide_content": "Nascondi contenuto",
"hide_full_subject": "Nascondi avvertenza sul contenuto intera",
"many_attachments": "Il post ha {number} allegato | Il post ha {number} allegati",
"hide_content": "Nascondi contenuti",
"hide_full_subject": "Nascondi oggetto intero",
"mentions": "Menzioni",
"move_down": "Muovi allegato a destra",
"move_up": "Muovi allegato a sinistra",
"mute_conversation": "Silenzia conversazione",
"nsfw": "NSFW",
"open_gallery": "Apri galleria",
"override_translation_source_language": "Sovrascrivi lingua di origine",
"pin": "Fissa in cima al profilo",
"pinned": "Fissato",
"nsfw": "DISDICEVOLE",
"pin": "Intesta al profilo",
"pinned": "Intestato",
"plus_more": "+{number} altri",
"redraft": "Elimina e correggi",
"redraft_confirm": "Vuoi davvero eliminare e correggere questo post? Le interazioni al post originale non saranno mantenute.",
"redraft_confirm_accept_button": "Sì, elimina e correggi",
"redraft_confirm_cancel_button": "No, tieni l'originale",
"redraft_confirm_title": "Conferma elimina e correggi",
"remove_attachment": "Rimuovi allegato",
"repeat_confirm": "Vuoi davvero condividere questo post?",
"repeat_confirm_accept_button": "Sì, condividilo",
"repeat_confirm_cancel_button": "No, non condividere",
"repeat_confirm_title": "Conferma condivisione",
"repeats": "Condivisioni",
"repeats": "Condivisi",
"replies_list": "Risposte:",
"replies_list_with_others": "Mostra {numReplies} altra risposta | Mostra {numReplies} altre risposte",
"reply_to": "In risposta a",
"show_all_attachments": "Mostra tutti gli allegati",
"show_all_conversation": "Mostra conversazione intera ({numStatus} altro post) | Mostra conversazione intera ({numStatus} altri post)",
"show_all_conversation_with_icon": "{icon} {text}",
"show_attachment_description": "Anteprima descrizione (apri l'allegato per la descrizione intera)",
"show_attachment_in_modal": "Mostra allegato in una finestra",
"show_content": "Mostra contenuto",
"show_full_subject": "Mostra tutta l'avvertenza sul contenuto",
"show_only_conversation_under_this": "Mostra solo le risposte a questo post",
"status_deleted": "Questo post è stato eliminato",
"status_unavailable": "Post non disponibile",
"thread_follow": "Visualizza {numStatus} altra risposta | Visualizza {numStatus} altre risposte",
"thread_follow_with_icon": "{icon} {text}",
"thread_hide": "Nascondi questa conversazione",
"thread_muted": "Conversazione silenziata",
"show_content": "Mostra contenuti",
"show_full_subject": "Mostra oggetto intero",
"status_deleted": "Questo messagio è stato cancellato",
"status_unavailable": "Messaggio non disponibile",
"thread_muted": "Discussione silenziata",
"thread_muted_and_words": ", contiene:",
"thread_show": "Mostra questa conversazione",
"thread_show_full": "Mostra {numStatus} risposta | Mostra tutte e {numStatus} le risposte",
"thread_show_full_with_icon": "{icon} {text}",
"translate": "Traduci",
"translated_from": "Tradotto da {language}",
"unbookmark": "Rimuovi segnalibro",
"unmute_conversation": "Desilenzia conversazione",
"unpin": "Rimuovi dalla cima del profilo",
"unmute_conversation": "Riabilita conversazione",
"unpin": "De-intesta",
"you": "(Tu)"
},
"time": {
"in_future": "fra {0}",
"in_past": "{0} fa",
"now": "proprio adesso",
"now_short": "ora",
"now": "adesso",
"now_short": "adesso",
"unit": {
"days": "{0} giorno | {0} giorni",
"days": "{0} giorni",
"days_short": "{0} g",
"hours": "{0} ora | {0} ore",
"hours_short": "{0} ora | {0} ore",
"minutes": "{0} minuto | {0} minuti",
"hours": "{0} ore",
"hours_short": "{0} h",
"minutes": "{0} minuti",
"minutes_short": "{0} min",
"months": "{0} mese | {0} mesi",
"months_short": "{0} mese | {0} mesi",
"seconds": "{0} secondo | {0} secondi",
"months": "{0} mesi",
"months_short": "{0} mes",
"seconds": "{0} secondi",
"seconds_short": "{0} sec",
"weeks": "{0} settimana | {0} settimane",
"weeks_short": "{0} sett",
"years": "{0} anno | {0} anni",
"weeks": "{0} settimane",
"weeks_short": "{0} stm",
"years": "{0} anni",
"years_short": "{0} a"
}
},
"timeline": {
"collapse": "Riduci",
"collapse": "Ripiega",
"conversation": "Conversazione",
"error": "Errore nel caricare la sequenza: {0}",
"follow_tag": "Segui hashtag",
"load_older": "Carica post precedenti",
"no_more_statuses": "Non ci sono altri post",
"no_retweet_hint": "Il messaggio è «solo per follower» o «diretto», quindi non può essere condiviso",
"no_statuses": "Nessun post",
"load_older": "Carica messaggi precedenti",
"no_more_statuses": "Fine dei messaggi",
"no_retweet_hint": "Il messaggio è diretto o solo per seguaci e non può essere condiviso",
"no_statuses": "Nessun messaggio",
"reload": "Ricarica",
"repeated": "ha condiviso",
"show_new": "Mostra nuovi",
"socket_broke": "Connessione tempo reale interrotta: CloseEvent codice {0}",
"socket_broke": "Connessione tempo reale interrotta: codice {0}",
"socket_reconnected": "Connesso in tempo reale",
"unfollow_tag": "Smetti di seguire hashtag",
"up_to_date": "Aggiornato"
},
"toast": {
"no_translation_target_set": "Nessuna lingua finale di traduzione impostata: la traduzione potrebbe fallire. Imposta una lingua finale di traduzione nelle tue impostazioni."
},
"tool_tip": {
"accept_follow_request": "Accetta richiesta di follow",
"add_reaction": "Aggiungi reazione",
"accept_follow_request": "Accetta seguace",
"add_reaction": "Reagisci",
"bookmark": "Aggiungi segnalibro",
"favorite": "Rendi preferito",
"media_upload": "Carica media",
"quote": "Cita",
"reject_follow_request": "Rifiuta richiesta di follow",
"favorite": "Gradisci",
"media_upload": "Carica allegati",
"reject_follow_request": "Rifiuta seguace",
"repeat": "Condividi",
"reply": "Rispondi",
"user_settings": "Impostazioni utente"
@ -1037,7 +934,7 @@
"upload": {
"error": {
"base": "Caricamento fallito.",
"default": "Riprova più tardi",
"default": "Riprova in seguito",
"file_too_big": "File troppo pesante [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"message": "Caricamento fallito: {0}"
},
@ -1051,115 +948,79 @@
},
"user_card": {
"admin_menu": {
"activate_account": "Riattiva account",
"deactivate_account": "Disattiva account",
"delete_account": "Elimina account",
"activate_account": "Attiva profilo",
"deactivate_account": "Disattiva profilo",
"delete_account": "Elimina profilo",
"delete_user": "Elimina utente",
"delete_user_data_and_deactivate_confirmation": "Questo eliminerà permanentemente i dati dall'account e lo disattiverà. Sei sicuro al 100%?",
"disable_any_subscription": "Proibisci a tutti di seguire l'utente",
"disable_remote_subscription": "Proibisci ad istanze remote di seguire l'utente",
"force_nsfw": "Marca tutti i post come NSFW",
"force_unlisted": "Rendi tutti i post «non in elenco»",
"grant_admin": "Rendi amministratore",
"grant_moderator": "Rendi moderatore",
"disable_any_subscription": "Rendi utente non seguibile",
"disable_remote_subscription": "Blocca i tentativi di seguirlo da altre stanze",
"force_nsfw": "Oscura tutti i messaggi",
"force_unlisted": "Nascondi tutti i messaggi",
"grant_admin": "Crea Amministratore",
"grant_moderator": "Crea Moderatore",
"moderation": "Moderazione",
"quarantine": "Impedisci la federazione dei post degli utenti",
"revoke_admin": "Rimuovi da amministratore",
"revoke_moderator": "Rimuovi da moderatore",
"sandbox": "Rendi tutti i messaggi \"solo per follower\"",
"strip_media": "Rimuovi media dai messaggi"
"quarantine": "I messaggi non arriveranno alle altre stanze",
"revoke_admin": "Divesti Amministratore",
"revoke_moderator": "Divesti Moderatore",
"sandbox": "Rendi tutti i messaggi solo per seguaci",
"strip_media": "Rimuovi ogni allegato ai messaggi"
},
"approve": "Accetta",
"approve_confirm": "Sei sicuro di voler permettere a questo utente di seguirti?",
"approve_confirm_accept_button": "Sì, accetta",
"approve_confirm_cancel_button": "No, annulla",
"approve_confirm_title": "Accetta richiesta di follow",
"approve": "Approva",
"block": "Blocca",
"block_confirm": "Sei sicuro di voler bloccare {user}?",
"block_confirm_accept_button": "Sì, blocca",
"block_confirm_cancel_button": "No, non bloccare",
"block_confirm_title": "Blocca utente",
"block_progress": "Blocco…",
"blocked": "Bloccato!",
"blocks_you": "Ti blocca!",
"bot": "Bot",
"deactivated": "Disattivato",
"deny": "Rifiuta",
"deny_confirm": "Sei sicuro di voler rifiutare la richiesta di follow di questo utente?",
"deny_confirm_accept_button": "Sì, rifiuta",
"deny_confirm_cancel_button": "No, annulla",
"deny_confirm_title": "Rifiuta richiesta di follow",
"domain_muted": "Sblocca dominio",
"deny": "Nega",
"edit_profile": "Modifica profilo",
"favorites": "Preferiti",
"follow": "Segui",
"follow_cancel": "Annulla richiesta",
"follow_progress": "Richiedo…",
"follow_sent": "Richiesta inviata!",
"follow_tag": "Segui l'hashtag",
"follow_unfollow": "Smetti di seguire",
"followed_tags": "Hashtag seguiti",
"followed_users": "Utenti seguiti",
"followees": "Seguiti",
"followers": "Follower",
"following": "Seguito!",
"follow_unfollow": "Disconosci",
"followees": "Segue",
"followers": "Seguaci",
"following": "Seguìto!",
"follows_you": "Ti segue!",
"hidden": "Nascosto",
"hide_repeats": "Nascondi condivisioni",
"highlight": {
"disabled": "Nessuno sfondo",
"side": "Striscia laterale",
"solid": "Sfondo monocolore",
"striped": "Sfondo a righe"
"disabled": "Nessun risalto",
"side": "Nastro a lato",
"solid": "Un colore",
"striped": "A righe"
},
"its_you": "Sei tu!",
"media": "Media",
"mention": "Menziona",
"message": "Contatta",
"mute": "Silenzia",
"mute_confirm": "Sei sicuro di voler silenziare {user}?",
"mute_confirm_accept_button": "Sì, silenzia",
"mute_confirm_cancel_button": "No, non silenziare",
"mute_confirm_title": "Silenzia utente",
"mute_domain": "Blocca dominio",
"mute_progress": "Silenziando…",
"mute_progress": "Silenzio…",
"muted": "Silenziato",
"not_following_any_hashtags": "Non stai seguendo nessun hashtag",
"note": "Nota privata",
"per_day": "al giorno",
"remote_follow": "Segui da remoto",
"remove_follower": "Rimuovi follower",
"replies": "Con risposte",
"report": "Segnala",
"requested_by": "Ha chiesto di seguirti",
"show_repeats": "Mostra condivisioni",
"statuses": "Post",
"subscribe": "Iscriviti",
"statuses": "Messaggi",
"subscribe": "Abbònati",
"unblock": "Sblocca",
"unblock_progress": "Sblocco…",
"unfollow_confirm": "Sei sicuro di voler smettere di seguire {user}?",
"unfollow_confirm_accept_button": "Sì, smetti di seguire",
"unfollow_confirm_cancel_button": "No, non smettere di seguire",
"unfollow_confirm_title": "Smetti di seguire l'utente",
"unfollow_tag": "Smetti di seguire l'hashtag",
"unmute": "Desilenzia",
"unmute_progress": "Desilenziamento…",
"unsubscribe": "Disiscriviti"
"unmute": "Riabilita",
"unmute_progress": "Riabilito…",
"unsubscribe": "Disdici"
},
"user_profile": {
"field_validated": "Collegamento verificato",
"profile_does_not_exist": "Spiacente, questo profilo non esiste.",
"profile_loading_error": "Spiacente, c'è stato un errore nel caricamento del profilo.",
"timeline_title": "Sequenza dell'utente"
},
"user_reporting": {
"add_comment_description": "La segnalazione sarà inviata ai moderatori della tua istanza. Puoi fornire una motivazione per cui stai segnalando questo account qui sotto:",
"additional_comments": "Commenti aggiuntivi",
"forward_description": "Il profilo appartiene ad un altro server. Inviare la segnalazione anche a quello?",
"add_comment_description": "La segnalazione sarà inviata ai moderatori della tua stanza. Puoi motivarla qui sotto:",
"additional_comments": "Osservazioni accessorie",
"forward_description": "Il profilo appartiene ad un'altra stanza. Inviare la segnalazione anche a quella?",
"forward_to": "Inoltra a {0}",
"generic_error": "C'è stato un errore nell'elaborazione della tua richiesta.",
"submit": "Invia",
"title": "Segnala {0}"
"title": "Segnalo {0}"
},
"who_to_follow": {
"more": "Altro",

View file

@ -1,7 +1,6 @@
{
"about": {
"bubble_instances": "ローカルバブルインスタンス",
"bubble_instances_description": "かんりしゃがだいひょうするためにえらんだインスタンス",
"mrf": {
"federation": "フェデレーション",
"keyword": {

View file

@ -1,124 +0,0 @@
{
"about": {
"bubble_instances": "Vietiniai burbulo serveriai",
"bubble_instances_description": "Administratorių parinkti serveriai, kurie atstovauja šios serverio vietinę teritoriją",
"mrf": {
"federation": "Federacija",
"keyword": {
"ftl_removal": "Pašalinimas iš „Viso žinomo tinklo“ laiko skalės",
"is_replaced_by": "→",
"keyword_policies": "Raktažodžių politika",
"reject": "Atmesti",
"replace": "Pakeisti"
},
"mrf_policies": "Įjungta MRF politika",
"mrf_policies_desc": "MRF politika valdo serverio federacijos elgseną. Įjungtos toliau nurodytos politikos:",
"simple": {
"accept": "Priimti",
"accept_desc": "Šis serveris priima žinutes tik iš toliau nurodytų serverių:",
"ftl_removal": "Pašalinimas iš „Žinomo tinklo“ laiko skalės",
"ftl_removal_desc": "Šis serveris pašalina šiuos serverius iš „Žinomo tinklo“ laiko skalės:",
"instance": "Serveris",
"media_nsfw": "Medija priverstinai nustatyta kaip jautri",
"media_nsfw_desc": "Šis serveris priverčia nustatyti mediją kaip jautrią toliau nurodytų serverių įrašuose:",
"media_removal": "Medijos pašalinimas",
"media_removal_desc": "Šis serveris pašalina mediją iš toliau nurodytų serverių įrašų:",
"not_applicable": "Nėra",
"quarantine": "Karantinas",
"quarantine_desc": "Šis serveris nesiųs įrašų į toliau nurodytus serverius:",
"reason": "Priežastis",
"reject": "Atmesti",
"reject_desc": "Šis serveris nepriims žinučių iš toliau nurodytų serverių:",
"simple_policies": "Konkretaus serverio politika"
}
},
"staff": "Personalas"
},
"announcements": {
"all_day_prompt": "Tai visos dienos renginys",
"cancel_edit_action": "Atsisakyti",
"close_error": "Užverti",
"delete_action": "Ištrinti",
"edit_action": "Redaguoti",
"end_time_display": "Pasibaigia {time}",
"end_time_prompt": "Pabaigos laikas: ",
"inactive_message": "Šis skelbimas neaktyvus",
"mark_as_read_action": "Žymėti kaip skaitytą",
"page_header": "Skelbimai",
"post_action": "Siųsti",
"post_error": "Klaida: {error}",
"post_form_header": "Skelbti skelbimą"
},
"chats": {
"chats": "Pokalbiai",
"delete": "Ištrinti",
"more": "Daugiau",
"new": "Naujas pokalbis",
"you": "Jūs:"
},
"display_date": {
"today": "Šiandien"
},
"domain_mute_card": {
"mute": "Nutildyti",
"mute_progress": "Nutildoma…",
"unmute": "Atšaukti nutildymą",
"unmute_progress": "Atšaukiamas nutildymas…"
},
"emoji": {
"add_emoji": "Įterpti jaustuką",
"custom": "Pasirinktinis jaustukas",
"emoji": "Jaustukas",
"stickers": "Lipdukai",
"unicode": "Unikodo jaustukas"
},
"exporter": {
"export": "Eksportuoti"
},
"file_type": {
"audio": "Garso įrašas",
"file": "Failas",
"image": "Vaizdas",
"video": "Vaizdo įrašas"
},
"general": {
"more": "Daugiau",
"scope_in_timeline": {
"direct": "Tiesioginis",
"local": "Vietinis šį įrašą gali matyti tik jūsų serveris",
"private": "Tik sekėjams",
"public": "Vieša",
"unlisted": "Neįtrauktas į sąrašą"
},
"show_less": "Rodyti mažiau",
"show_more": "Rodyti daugiau",
"submit": "Pateikti",
"verify": "Patvirtinti"
},
"image_cropper": {
"cancel": "Atšaukti"
},
"importer": {
"submit": "Pateikti"
},
"user_card": {
"follow_tag": "Sekti saitažodį",
"not_following_any_hashtags": "Nesekate jokių saitažodžių.",
"unfollow_confirm_accept_button": "Taip, nebesekti",
"unfollow_confirm_cancel_button": "Ne, nenaikinti sekimą",
"unfollow_confirm_title": "Nebesekti naudotoją",
"unfollow_tag": "Nebesekti saitažodį"
},
"user_reporting": {
"additional_comments": "Papildomi komentarai",
"forward_description": "Paskyra yra iš kito serverio. Siųsti ataskaitos kopiją ir ten?",
"forward_to": "Persiųsti į {0}",
"generic_error": "Įvyko klaida apdorojant jūsų užklausą.",
"submit": "Pateikti",
"title": "Pranešama apie {0}"
},
"who_to_follow": {
"more": "Daugiau",
"who_to_follow": "Ką sekti"
}
}

View file

@ -599,7 +599,7 @@
"links": "Łącza",
"list_aliases_error": "Błąd pobierania aliasów: {error}",
"list_backups_error": "Błąd pobierania listy kopii zapasowych: {error}",
"lock_account_description": "Wymagaj potwierdzenia nowych śledzących",
"lock_account_description": "Spraw, by konto mogli wyświetlać tylko zatwierdzeni obserwujący",
"loop_video": "Zapętlaj filmy",
"loop_video_silent_only": "Zapętlaj tylko filmy bez dźwięku (np. mastodonowe „gify”)",
"mascot": "Maskotka Mastodon FE",
@ -679,7 +679,6 @@
"pad_emoji": "Dodaj odstęp z obu stron emoji podczas dodawania selektorem",
"panelRadius": "Panele",
"pause_on_unfocused": "Wstrzymuj strumieniowanie kiedy karta nie jest aktywna",
"permit_followback_description": "Automatycznie potwierdź śledzenie przez użytkowników którch już śledzisz",
"play_videos_in_modal": "Odtwarzaj filmy bezpośrednio w przeglądarce mediów",
"post_look_feel": "Wygląd wpisów",
"post_status_content_type": "Domyślny typ zawartości wpisów",
@ -1149,7 +1148,7 @@
"followed_users": "Śledzeni użytkownicy",
"followees": "Obserwowani",
"followers": "Obserwujący",
"following": "Obserwujesz!",
"following": "Obserwowany!",
"follows_you": "Obserwuje cię!",
"hidden": "Ukryte",
"hide_repeats": "Ukryj powtórzenia",

View file

@ -129,131 +129,6 @@
"generic_error": "Bir hata oluştu",
"loading": "Yükleniyor…",
"more": "Daha",
"optional": "Seçenek",
"peek": "Göz at",
"retry": "Tekrar deneyin",
"role": {
"admin": "Yönetici",
"moderator": "Moderatör"
},
"scope_in_timeline": {
"direct": "Doğrudan",
"local": "Yerel - bu gönderiyi yalnızca sizin örneğiniz görebilir",
"private": "Yalnızca takipçiler",
"public": "Herkese açık",
"unlisted": "Listelenmemiş"
},
"show_less": "Daha az göster",
"show_more": "Daha fazla göster",
"submit": "Gönder",
"verify": "Doğrulama"
},
"image_cropper": {
"cancel": "İptal",
"crop_picture": "Resmi kırp",
"save": "Kaydet",
"save_without_cropping": "Kırpmadan kaydet"
},
"importer": {
"error": "Bu dosya içe aktarılırken bir hata oluştu.",
"submit": "Gönder",
"success": "Başarıyla içe aktarıldı."
},
"interactions": {
"favs_repeats": "Tekrarlar ve favoriler",
"follows": "Yeni takipler",
"load_older": "Eski etkileşimleri yükle",
"moves": "Kullanıcı taşıma"
},
"languages": {
"ar": "Arabic",
"az": "Azerbaycan Türkçesi",
"bg": "Bulgarian",
"cs": "Czech",
"da": "Danish",
"de": "German",
"el": "Greek",
"en": "English",
"eo": "Esperanto",
"es": "Spanish",
"fa": "Persian",
"fi": "Finnish",
"fr": "French",
"ga": "Irish",
"he": "Hebrew",
"hi": "Hindi",
"hu": "Hungarian",
"id": "Indonesian",
"it": "Italian",
"ja": "Japanese",
"ko": "Korean",
"lt": "Lithuanian",
"lv": "Latvian",
"nl": "Dutch",
"pl": "Polish",
"pt": "Portuguese",
"ru": "Russian",
"sk": "Slovak",
"sv": "Swedish",
"tr": "Türkçe",
"translated_from": {
"ar": "@:languages.ar adresinden çevrildi",
"az": "@:languages.az adresinden çevrildi",
"bg": "@:languages.bg adresinden çevrildi",
"cs": "@:languages.cs adresinden çevrildi",
"da": "@:languages.da adresinden çevrildi",
"de": "@:languages.de adresinden çevrildi",
"el": "@:languages.el adresinden çevrildi",
"en": "@:languages.en adresinden çevrildi",
"eo": "@:languages.eo adresinden çevrildi",
"es": "@:languages.es adresinden çevrildi",
"fa": "@:languages.fa adresinden çevrildi",
"fi": "@:languages.fi adresinden çevrildi",
"fr": "@:languages.fr adresinden çevrildi",
"ga": "@:languages.ga adresinden çevrildi",
"he": "@:languages.he adresinden çevrildi",
"hi": "@:languages.hi adresinden çevrildi",
"hu": "@:languages.hu adresinden çevrildi",
"id": "@:languages.id adresinden çevrildi",
"it": "@:languages.it adresinden çevrildi",
"ja": "@:languages.ja adresinden çevrildi",
"ko": "@:languages.ko adresinden çevrildi",
"lt": "@:languages.lt adresinden çevrildi",
"lv": "@:languages.lv adresinden çevrildi",
"nl": "@:languages.nl adresinden çevrildi",
"pl": "@:languages.pl adresinden çevrildi",
"pt": "@:languages.pt adresinden çevrildi",
"ru": "@:languages.ru adresinden çevrildi",
"sk": "@:languages.sk adresinden çevrildi",
"sv": "@:languages.sv adresinden çevrildi",
"tr": "@:languages.tr adresinden çevrildi",
"uk": "@:languages.uk adresinden çevrildi",
"zh": "@:languages.zh adresinden çevrildi"
},
"uk": "Ukrainian",
"zh": "Chinese"
},
"lists": {
"create": "Oluştur",
"delete": "Listeyi sil",
"following_only": "Takip Etmeyi Sınırla",
"lists": "Listeler",
"new": "Yeni Liste",
"save": "Değişiklikleri kaydet",
"search": "Kullanıcıları ara",
"title": "Liste başlığı"
},
"login": {
"authentication_code": "Kimlik doğrulama kodu",
"description": "OAuth ile oturum açın",
"enter_recovery_code": "Bir kurtarma kodu girin",
"enter_two_factor_code": "İki faktörlü bir kod girin",
"heading": {
"recovery": "İki faktörlü kurtarma",
"totp": "İki faktörlü kimlik doğrulama"
},
"hint": "Tartışmaya katılmak için giriş yapın",
"login": "Giriş yap",
"logout": ıkış yap"
"optional": "Seçenek"
}
}

View file

@ -492,7 +492,6 @@
"cGreen": "绿色(转发)",
"cOrange": "橙色(喜欢)",
"cRed": "红色(取消)",
"center_align_bio": "使用户简介中的文本居中",
"change_email": "更改邮箱",
"change_email_error": "更改你的邮箱时发生错误。",
"change_password": "更改密码",
@ -503,7 +502,6 @@
"checkboxRadius": "复选框",
"collapse_subject": "折叠带内容警告的帖文",
"columns": "分栏",
"compact_user_info": "空间充足时显示紧凑用户信息",
"composing": "写作",
"confirm_dialogs": "需要确认当:",
"confirm_dialogs_approve_follow": "接受关注请求",
@ -684,7 +682,6 @@
"pause_on_unfocused": "在离开页面时暂停时间线推送",
"permit_followback_description": "自动批准已关注用户的关注请求",
"play_videos_in_modal": "在弹出框内播放视频",
"post_language": "默认发布的语言",
"post_look_feel": "文章的样子跟感受",
"post_status_content_type": "默认发布的内容类型",
"posts": "帖文",
@ -950,7 +947,6 @@
"title": "版本"
},
"virtual_scrolling": "优化时间线渲染",
"widen_timeline": "加宽时间线以填充水平空间",
"word_filter": "词语过滤",
"wordfilter": "词语过滤器"
},

View file

@ -4,20 +4,9 @@ import { each, get, set, cloneDeep } from 'lodash'
let loaded = false
// use this to avoid cloning and persisitng private runtime state
// (runtime state can be reconstructed and may include non-cloneable objects like functions)
const clonePublicKeys = (state) => {
const clonedState = {}
for (const key in state) {
if (!key.startsWith('__'))
set(clonedState, key, cloneDeep(state[key]))
}
return clonedState
}
const defaultReducer = (state, paths) => (
paths.length === 0 ? clonePublicKeys(state) : paths.reduce((substate, path) => {
set(substate, path, clonePublicKeys(get(state, path)))
paths.length === 0 ? state : paths.reduce((substate, path) => {
set(substate, path, get(state, path))
return substate
}, {})
)
@ -30,7 +19,7 @@ const saveImmedeatelyActions = [
'setOption',
'setClientData',
'setToken',
'clearTokens',
'clearToken',
'emojiUsed',
]
@ -81,7 +70,7 @@ export default function createPersistedState ({
subscriber(store)((mutation, state) => {
try {
if (saveImmedeatelyActions.includes(mutation.type)) {
setState(key, reducer(state, paths), storage)
setState(key, reducer(cloneDeep(state), paths), storage)
.then(success => {
if (typeof success !== 'undefined') {
if (mutation.type === 'setOption' || mutation.type === 'setCurrentUser') {

View file

@ -1,28 +0,0 @@
const SCOPE_LEVELS = {
'direct': 0,
'private': 1,
'unlisted': 2,
'local': 3,
'public': 3
}
export default {
negotiate: (defaultScope, maxScope) => {
if (!maxScope)
return defaultScope;
if (maxScope === 'local')
return defaultScope === 'direct' ? defaultScope : 'local';
if (SCOPE_LEVELS[defaultScope] <= SCOPE_LEVELS[maxScope])
return defaultScope;
else
return maxScope;
},
isSubScope: (original, subscope) => {
if (original === 'local')
return (subscope === 'direct' || subscope === original);
return SCOPE_LEVELS[subscope] <= SCOPE_LEVELS[original];
}
}

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