aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaksim Pechnikov <parallel588@gmail.com>2020-06-29 09:16:00 +0300
committerMaksim Pechnikov <parallel588@gmail.com>2020-06-29 09:16:00 +0300
commit12519a54b55140a3e5f76e67ac53914654c2a8b0 (patch)
tree53ca006f6350008f8d38d2c3a1bf877c29389bf8
parent08444c390348076231f15bd84b613cf39380bb72 (diff)
parentd0c9aef668fdfca2cefbd795ab3d258cbb1ea42b (diff)
Merge branch 'develop' of git.pleroma.social:pleroma/pleroma-fe into develop
-rw-r--r--CHANGELOG.md6
-rw-r--r--src/boot/after_store.js95
-rw-r--r--src/components/account_actions/account_actions.vue1
-rw-r--r--src/components/emoji_input/emoji_input.js1
-rw-r--r--src/components/emoji_input/suggestor.js6
-rw-r--r--src/components/extra_buttons/extra_buttons.vue1
-rw-r--r--src/components/poll/poll.vue2
-rw-r--r--src/components/popover/popover.js11
-rw-r--r--src/components/settings_modal/tabs/profile_tab.js29
-rw-r--r--src/components/settings_modal/tabs/profile_tab.scss17
-rw-r--r--src/components/settings_modal/tabs/profile_tab.vue53
-rw-r--r--src/components/status/status.vue2
-rw-r--r--src/components/status_content/status_content.vue24
-rw-r--r--src/components/user_card/user_card.vue20
-rw-r--r--src/components/user_profile/user_profile.js8
-rw-r--r--src/components/user_profile/user_profile.vue74
-rw-r--r--src/i18n/en.json7
-rw-r--r--src/i18n/it.json3
-rw-r--r--src/i18n/nl.json27
-rw-r--r--src/i18n/ru.json1
-rw-r--r--src/i18n/service_worker_messages.js35
-rw-r--r--src/lib/notification-i18n-loader.js12
-rw-r--r--src/modules/statuses.js41
-rw-r--r--src/modules/users.js19
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js12
-rw-r--r--src/services/notification_utils/notification_utils.js44
-rw-r--r--src/sw.js47
-rw-r--r--test/unit/specs/modules/users.spec.js36
-rw-r--r--test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js13
29 files changed, 538 insertions, 109 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e3eaf17..887588f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Changed
- Greentext now has separate color slot for it
- Removed the use of with_move parameters when fetching notifications
+- Push notifications now are the same as normal notfication, and are localized.
### Fixed
- Weird bug related to post being sent seemingly after pasting with keyboard (hopefully)
@@ -16,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Added private notifications option for push notifications
- 'Copy link' button for statuses (in the ellipsis menu)
- Autocomplete domains from list of known instances
+- 'Bot' settings option and badge
+- Added profile meta data fields that can be set in profile settings
### Changed
- Registration page no longer requires email if the server is configured not to require it
@@ -25,12 +28,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add better visual indication for drag-and-drop for files
### Fixed
+- Custom Emoji will display in poll options now.
- Status ellipsis menu closes properly when selecting certain options
- Cropped images look correct in Chrome
- Newlines in the muted words settings work again
- Clicking on non-latin hashtags won't open a new window
- Uploading and drag-dropping multiple files works correctly now.
- Subject field now appears disabled when posting
+- Fix status ellipsis menu being cut off in notifications column
+- Fixed autocomplete sometimes not returning the right user when there's already some results
## [2.0.3] - 2020-05-02
### Fixed
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 0db03547..1796eb1b 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -8,38 +8,64 @@ import backendInteractorService from '../services/backend_interactor_service/bac
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
import { applyTheme } from '../services/style_setter/style_setter.js'
-const getStatusnetConfig = async ({ store }) => {
+let staticInitialResults = null
+
+const parsedInitialResults = () => {
+ if (!document.getElementById('initial-results')) {
+ return null
+ }
+ if (!staticInitialResults) {
+ staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent)
+ }
+ return staticInitialResults
+}
+
+const preloadFetch = async (request) => {
+ const data = parsedInitialResults()
+ if (!data || !data[request]) {
+ return window.fetch(request)
+ }
+ const requestData = atob(data[request])
+ return {
+ ok: true,
+ json: () => JSON.parse(requestData),
+ text: () => requestData
+ }
+}
+
+const getInstanceConfig = async ({ store }) => {
try {
- const res = await window.fetch('/api/statusnet/config.json')
+ const res = await preloadFetch('/api/v1/instance')
if (res.ok) {
const data = await res.json()
- const { name, closed: registrationClosed, textlimit, uploadlimit, server, vapidPublicKey, safeDMMentionsEnabled } = data.site
-
- store.dispatch('setInstanceOption', { name: 'name', value: name })
- store.dispatch('setInstanceOption', { name: 'registrationOpen', value: (registrationClosed === '0') })
- store.dispatch('setInstanceOption', { name: 'textlimit', value: parseInt(textlimit) })
- store.dispatch('setInstanceOption', { name: 'server', value: server })
- store.dispatch('setInstanceOption', { name: 'safeDM', value: safeDMMentionsEnabled !== '0' })
-
- // TODO: default values for this stuff, added if to not make it break on
- // my dev config out of the box.
- if (uploadlimit) {
- store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadlimit.uploadlimit) })
- store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadlimit.avatarlimit) })
- store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadlimit.backgroundlimit) })
- store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadlimit.bannerlimit) })
- }
+ const textlimit = data.max_toot_chars
+ const vapidPublicKey = data.pleroma.vapid_public_key
+
+ store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
+ } else {
+ throw (res)
+ }
+ } catch (error) {
+ console.error('Could not load instance config, potentially fatal')
+ console.error(error)
+ }
+}
- return data.site.pleromafe
+const getBackendProvidedConfig = async ({ store }) => {
+ try {
+ const res = await window.fetch('/api/pleroma/frontend_configurations')
+ if (res.ok) {
+ const data = await res.json()
+ return data.pleroma_fe
} else {
throw (res)
}
} catch (error) {
- console.error('Could not load statusnet config, potentially fatal')
+ console.error('Could not load backend-provided frontend config, potentially fatal')
console.error(error)
}
}
@@ -132,7 +158,7 @@ const getTOS = async ({ store }) => {
const getInstancePanel = async ({ store }) => {
try {
- const res = await window.fetch('/instance/panel.html')
+ const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
@@ -195,18 +221,28 @@ const resolveStaffAccounts = ({ store, accounts }) => {
const getNodeInfo = async ({ store }) => {
try {
- const res = await window.fetch('/nodeinfo/2.0.json')
+ const res = await preloadFetch('/nodeinfo/2.0.json')
if (res.ok) {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
+ store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName })
+ store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
+ store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'chatAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
+ const uploadLimits = metadata.uploadLimits
+ store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
+ store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) })
+ store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) })
+ store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) })
+ store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits })
+
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
@@ -257,7 +293,7 @@ const getNodeInfo = async ({ store }) => {
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
- const configInfos = await Promise.all([getStatusnetConfig({ store }), getStaticConfig()])
+ const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
@@ -280,6 +316,11 @@ const checkOAuthToken = async ({ store }) => {
const afterStoreSetup = async ({ store, i18n }) => {
const width = windowWidth()
store.dispatch('setMobileLayout', width <= 800)
+
+ const overrides = window.___pleromafe_dev_overrides || {}
+ const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
+ store.dispatch('setInstanceOption', { name: 'server', value: server })
+
await setConfig({ store })
const { customTheme, customThemeSource } = store.state.config
@@ -299,16 +340,18 @@ const afterStoreSetup = async ({ store, i18n }) => {
}
// Now we can try getting the server settings and logging in
+ // Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
checkOAuthToken({ store }),
- getTOS({ store }),
getInstancePanel({ store }),
- getStickers({ store }),
- getNodeInfo({ store })
+ getNodeInfo({ store }),
+ getInstanceConfig({ store })
])
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
+ getTOS({ store })
+ getStickers({ store })
const router = new VueRouter({
mode: 'history',
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index 744b77d5..029e7096 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -3,6 +3,7 @@
<Popover
trigger="click"
placement="bottom"
+ :bound-to="{ x: 'container' }"
>
<div
slot="content"
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index f4c3479c..7974a66d 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -431,6 +431,7 @@ const EmojiInput = {
const offsetBottom = offsetTop + offsetHeight
panel.style.top = offsetBottom + 'px'
+ if (!picker) return
picker.$el.style.top = offsetBottom + 'px'
picker.$el.style.bottom = 'auto'
}
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index 15a71eff..8330345b 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -13,7 +13,7 @@ import { debounce } from 'lodash'
const debounceUserSearch = debounce((data, input) => {
data.updateUsersList(input)
-}, 500, { leading: true, trailing: false })
+}, 500)
export default data => input => {
const firstChar = input[0]
@@ -97,8 +97,8 @@ export const suggestUsers = data => input => {
replacement: '@' + screen_name + ' '
}))
- // BE search users if there are no matches
- if (newUsers.length === 0 && data.updateUsersList) {
+ // BE search users to get more comprehensive results
+ if (data.updateUsersList) {
debounceUserSearch(data, noPrefix)
}
return newUsers
diff --git a/src/components/extra_buttons/extra_buttons.vue b/src/components/extra_buttons/extra_buttons.vue
index bca93ea7..68db6fd8 100644
--- a/src/components/extra_buttons/extra_buttons.vue
+++ b/src/components/extra_buttons/extra_buttons.vue
@@ -3,6 +3,7 @@
trigger="click"
placement="top"
class="extra-button-popover"
+ :bound-to="{ x: 'container' }"
>
<div
slot="content"
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
index 56e91cca..adbb0555 100644
--- a/src/components/poll/poll.vue
+++ b/src/components/poll/poll.vue
@@ -17,7 +17,7 @@
<span class="result-percentage">
{{ percentageForOption(option.votes_count) }}%
</span>
- <span>{{ option.title }}</span>
+ <span v-html="option.title_html"></span>
</div>
<div
class="result-fill"
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index 5881d266..a40a9195 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -1,4 +1,3 @@
-
const Popover = {
name: 'Popover',
props: {
@@ -10,6 +9,9 @@ const Popover = {
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
+ // Takes a selector to use as a replacement for the parent container
+ // for getting boundaries for x an y axis
+ boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
@@ -27,6 +29,10 @@ const Popover = {
}
},
methods: {
+ containerBoundingClientRect () {
+ const container = this.boundToSelector ? this.$el.closest(this.boundToSelector) : this.$el.offsetParent
+ return container.getBoundingClientRect()
+ },
updateStyles () {
if (this.hidden) {
this.styles = {
@@ -45,7 +51,8 @@ const Popover = {
// Minor optimization, don't call a slow reflow call if we don't have to
const parentBounds = this.boundTo &&
(this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
- this.$el.offsetParent.getBoundingClientRect()
+ this.containerBoundingClientRect()
+
const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 8658b097..e6db802d 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -1,4 +1,5 @@
import unescape from 'lodash/unescape'
+import merge from 'lodash/merge'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
@@ -16,6 +17,7 @@ const ProfileTab = {
newLocked: this.$store.state.users.currentUser.locked,
newNoRichText: this.$store.state.users.currentUser.no_rich_text,
newDefaultScope: this.$store.state.users.currentUser.default_scope,
+ newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
hideFollows: this.$store.state.users.currentUser.hide_follows,
hideFollowers: this.$store.state.users.currentUser.hide_followers,
hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
@@ -23,6 +25,7 @@ const ProfileTab = {
showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role,
discoverable: this.$store.state.users.currentUser.discoverable,
+ bot: this.$store.state.users.currentUser.bot,
allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
pickAvatarBtnVisible: true,
bannerUploading: false,
@@ -62,6 +65,18 @@ const ProfileTab = {
...this.$store.state.instance.emoji,
...this.$store.state.instance.customEmoji
] })
+ },
+ userSuggestor () {
+ return suggestor({
+ users: this.$store.state.users.users,
+ updateUsersList: (query) => this.$store.dispatch('searchUsers', { query })
+ })
+ },
+ fieldsLimits () {
+ return this.$store.state.instance.fieldsLimits
+ },
+ maxFields () {
+ return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
}
},
methods: {
@@ -74,17 +89,21 @@ const ProfileTab = {
// Backend notation.
/* eslint-disable camelcase */
display_name: this.newName,
+ fields_attributes: this.newFields.filter(el => el != null),
default_scope: this.newDefaultScope,
no_rich_text: this.newNoRichText,
hide_follows: this.hideFollows,
hide_followers: this.hideFollowers,
discoverable: this.discoverable,
+ bot: this.bot,
allow_following_move: this.allowFollowingMove,
hide_follows_count: this.hideFollowsCount,
hide_followers_count: this.hideFollowersCount,
show_role: this.showRole
/* eslint-enable camelcase */
} }).then((user) => {
+ this.newFields.splice(user.fields.length)
+ merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
})
@@ -92,6 +111,16 @@ const ProfileTab = {
changeVis (visibility) {
this.newDefaultScope = visibility
},
+ addField () {
+ if (this.newFields.length < this.maxFields) {
+ this.newFields.push({ name: '', value: '' })
+ return true
+ }
+ return false
+ },
+ deleteField (index, event) {
+ this.$delete(this.newFields, index)
+ },
uploadFile (slot, e) {
const file = e.target.files[0]
if (!file) { return }
diff --git a/src/components/settings_modal/tabs/profile_tab.scss b/src/components/settings_modal/tabs/profile_tab.scss
index 4aab81eb..b3dcf42c 100644
--- a/src/components/settings_modal/tabs/profile_tab.scss
+++ b/src/components/settings_modal/tabs/profile_tab.scss
@@ -79,4 +79,21 @@
.setting-subitem {
margin-left: 1.75em;
}
+
+ .profile-fields {
+ display: flex;
+
+ &>.emoji-input {
+ flex: 1 1 auto;
+ margin: 0 .2em .5em;
+ }
+
+ &>.icon-container {
+ width: 20px;
+
+ &>.icon-cancel {
+ vertical-align: sub;
+ }
+ }
+ }
}
diff --git a/src/components/settings_modal/tabs/profile_tab.vue b/src/components/settings_modal/tabs/profile_tab.vue
index fff4f970..0f9210a6 100644
--- a/src/components/settings_modal/tabs/profile_tab.vue
+++ b/src/components/settings_modal/tabs/profile_tab.vue
@@ -95,6 +95,59 @@
{{ $t('settings.discoverable') }}
</Checkbox>
</p>
+ <div v-if="maxFields > 0">
+ <p>{{ $t('settings.profile_fields.label') }}</p>
+ <div
+ v-for="(_, i) in newFields"
+ :key="i"
+ class="profile-fields"
+ >
+ <EmojiInput
+ v-model="newFields[i].name"
+ enable-emoji-picker
+ hide-emoji-button
+ :suggest="userSuggestor"
+ >
+ <input
+ v-model="newFields[i].name"
+ :placeholder="$t('settings.profile_fields.name')"
+ >
+ </EmojiInput>
+ <EmojiInput
+ v-model="newFields[i].value"
+ enable-emoji-picker
+ hide-emoji-button
+ :suggest="userSuggestor"
+ >
+ <input
+ v-model="newFields[i].value"
+ :placeholder="$t('settings.profile_fields.value')"
+ >
+ </EmojiInput>
+ <div
+ class="icon-container"
+ >
+ <i
+ v-show="newFields.length > 1"
+ class="icon-cancel"
+ @click="deleteField(i)"
+ />
+ </div>
+ </div>
+ <a
+ v-if="newFields.length < maxFields"
+ class="add-field faint"
+ @click="addField"
+ >
+ <i class="icon-plus" />
+ {{ $t("settings.profile_fields.add_field") }}
+ </a>
+ </div>
+ <p>
+ <Checkbox v-model="bot">
+ {{ $t('settings.bot') }}
+ </Checkbox>
+ </p>
<button
:disabled="newName && newName.length === 0"
class="btn btn-default"
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 336f912a..7ec29b28 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -418,7 +418,7 @@ $status-margin: 0.75em;
max-width: 85%;
font-weight: bold;
- img {
+ img.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 7adb67ae..efc2485e 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -164,23 +164,23 @@ $status-margin: 0.75em;
word-break: break-all;
}
+ img, video {
+ max-width: 100%;
+ max-height: 400px;
+ vertical-align: middle;
+ object-fit: contain;
+
+ &.emoji {
+ width: 32px;
+ height: 32px;
+ }
+ }
+
.status-content {
font-family: var(--postFont, sans-serif);
line-height: 1.4em;
white-space: pre-wrap;
- img, video {
- max-width: 100%;
- max-height: 400px;
- vertical-align: middle;
- object-fit: contain;
-
- &.emoji {
- width: 32px;
- height: 32px;
- }
- }
-
blockquote {
margin: 0.2em 0 0.2em 2em;
font-style: italic;
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index c4a5ce9d..9529d7f6 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -70,10 +70,20 @@
>
@{{ user.screen_name }}
</router-link>
- <span
- v-if="!hideBio && !!visibleRole"
- class="alert staff"
- >{{ visibleRole }}</span>
+ <template v-if="!hideBio">
+ <span
+ v-if="!!visibleRole"
+ class="alert user-role"
+ >
+ {{ visibleRole }}
+ </span>
+ <span
+ v-if="user.bot"
+ class="alert user-role"
+ >
+ bot
+ </span>
+ </template>
<span v-if="user.locked"><i class="icon icon-lock" /></span>
<span
v-if="!mergedConfig.hideUserStats && !hideBio"
@@ -458,7 +468,7 @@
color: var(--text, $fallback--text);
}
- .staff {
+ .user-role {
flex: none;
text-transform: capitalize;
color: $fallback--text;
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index 95760bf8..201727d4 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -124,6 +124,14 @@ const UserProfile = {
onTabSwitch (tab) {
this.tab = tab
this.$router.replace({ query: { tab } })
+ },
+ linkClicked ({ target }) {
+ if (target.tagName === 'SPAN') {
+ target = target.parentNode
+ }
+ if (target.tagName === 'A') {
+ window.open(target.href, '_blank')
+ }
}
},
watch: {
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index 1871d46c..361a3b5c 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -11,6 +11,31 @@
:allow-zooming-avatar="true"
rounded="top"
/>
+ <div
+ v-if="user.fields_html && user.fields_html.length > 0"
+ class="user-profile-fields"
+ >
+ <dl
+ v-for="(field, index) in user.fields_html"
+ :key="index"
+ class="user-profile-field"
+ >
+ <!-- eslint-disable vue/no-v-html -->
+ <dt
+ :title="user.fields_text[index].name"
+ class="user-profile-field-name"
+ @click.prevent="linkClicked"
+ v-html="field.name"
+ />
+ <dd
+ :title="user.fields_text[index].value"
+ class="user-profile-field-value"
+ @click.prevent="linkClicked"
+ v-html="field.value"
+ />
+ <!-- eslint-enable vue/no-v-html -->
+ </dl>
+ </div>
<tab-switcher
:active-tab="tab"
:render-only-focused="true"
@@ -108,11 +133,60 @@
<script src="./user_profile.js"></script>
<style lang="scss">
+@import '../../_variables.scss';
.user-profile {
flex: 2;
flex-basis: 500px;
+ .user-profile-fields {
+ margin: 0 0.5em;
+ img {
+ object-fit: contain;
+ vertical-align: middle;
+ max-width: 100%;
+ max-height: 400px;
+
+ &.emoji {
+ width: 18px;
+ height: 18px;
+ }
+ }
+
+ .user-profile-field {
+ display: flex;
+ margin: 0.25em auto;
+ max-width: 32em;
+ border: 1px solid var(--border, $fallback--border);
+ border-radius: $fallback--inputRadius;
+ border-radius: var(--inputRadius, $fallback--inputRadius);
+
+ .user-profile-field-name {
+ flex: 0 1 30%;
+ font-weight: 500;
+ text-align: right;
+ color: var(--lightText);
+ min-width: 120px;
+ border-right: 1px solid var(--border, $fallback--border);
+ }
+
+ .user-profile-field-value {
+ flex: 1 1 70%;
+ color: var(--text);
+ margin: 0 0 0 0.25em;
+ }
+
+ .user-profile-field-name, .user-profile-field-value {
+ line-height: 18px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ padding: 0.5em 1.5em;
+ box-sizing: border-box;
+ }
+ }
+ }
+
.userlist-placeholder {
display: flex;
justify-content: center;
diff --git a/src/i18n/en.json b/src/i18n/en.json
index eefe10e5..2840904f 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -266,6 +266,7 @@
"block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.",
"blocks_tab": "Blocks",
+ "bot": "This is a bot account",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
@@ -333,6 +334,12 @@
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame",
+ "profile_fields": {
+ "label": "Profile metadata",
+ "add_field": "Add Field",
+ "name": "Label",
+ "value": "Content"
+ },
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & Bio",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index 6c8be351..7311f0b6 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -255,7 +255,8 @@
"top_bar": "Barra superiore",
"panel_header": "Titolo pannello",
"badge_notification": "Notifica",
- "popover": "Suggerimenti, menù, sbalzi"
+ "popover": "Suggerimenti, menù, sbalzi",
+ "toggled": "Scambiato"
},
"common_colors": {
"rgbo": "Icone, accenti, medaglie",
diff --git a/src/i18n/nl.json b/src/i18n/nl.json
index af728b6e..bf270f87 100644
--- a/src/i18n/nl.json
+++ b/src/i18n/nl.json
@@ -28,7 +28,12 @@
"enable": "Inschakelen",
"confirm": "Bevestigen",
"verify": "Verifiëren",
- "generic_error": "Er is een fout opgetreden"
+ "generic_error": "Er is een fout opgetreden",
+ "peek": "Spiek",
+ "close": "Sluiten",
+ "retry": "Opnieuw proberen",
+ "error_retry": "Probeer het opnieuw",
+ "loading": "Laden…"
},
"login": {
"login": "Log in",
@@ -90,7 +95,7 @@
"text/bbcode": "BBCode"
},
"content_warning": "Onderwerp (optioneel)",
- "default": "Zojuist geland in L.A.",
+ "default": "Tijd voor anime!",
"direct_warning": "Deze post zal enkel zichtbaar zijn voor de personen die genoemd zijn.",
"posting": "Plaatsen",
"scope": {
@@ -377,7 +382,7 @@
"button": "Knop",
"text": "Nog een boel andere {0} en {1}",
"mono": "inhoud",
- "input": "Zojuist geland in L.A.",
+ "input": "Tijd voor anime!",
"faint_link": "handige gebruikershandleiding",
"fine_print": "Lees onze {0} om niets nuttig te leren!",
"header_faint": "Alles komt goed",
@@ -451,7 +456,7 @@
"user_mutes": "Gebruikers",
"useStreamingApi": "Berichten en meldingen in real-time ontvangen",
"useStreamingApiWarning": "(Afgeraden, experimenteel, kan berichten overslaan)",
- "type_domains_to_mute": "Voer domeinen in om te negeren",
+ "type_domains_to_mute": "Zoek domeinen om te negeren",
"upload_a_photo": "Upload een foto",
"fun": "Plezier",
"greentext": "Meme pijlen",
@@ -470,7 +475,15 @@
"frontend_version": "Frontend Versie",
"backend_version": "Backend Versie",
"title": "Versie"
- }
+ },
+ "mutes_and_blocks": "Negeringen en Blokkades",
+ "profile_fields": {
+ "value": "Inhoud",
+ "name": "Label",
+ "add_field": "Veld Toevoegen",
+ "label": "Profiel metadata"
+ },
+ "bot": "Dit is een bot account"
},
"timeline": {
"collapse": "Inklappen",
@@ -708,7 +721,9 @@
"unpin": "Van profiel losmaken",
"delete": "Status verwijderen",
"repeats": "Herhalingen",
- "favorites": "Favorieten"
+ "favorites": "Favorieten",
+ "thread_muted_and_words": ", heeft woorden:",
+ "thread_muted": "Thread genegeerd"
},
"time": {
"years_short": "{0}j",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index f9a72954..aa78db26 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -130,6 +130,7 @@
"background": "Фон",
"bio": "Описание",
"btnRadius": "Кнопки",
+ "bot": "Это аккаунт бота",
"cBlue": "Ответить, читать",
"cGreen": "Повторить",
"cOrange": "Нравится",
diff --git a/src/i18n/service_worker_messages.js b/src/i18n/service_worker_messages.js
new file mode 100644
index 00000000..270ed043
--- /dev/null
+++ b/src/i18n/service_worker_messages.js
@@ -0,0 +1,35 @@
+/* eslint-disable import/no-webpack-loader-syntax */
+// This module exports only the notification part of the i18n,
+// which is useful for the service worker
+
+const messages = {
+ ar: require('../lib/notification-i18n-loader.js!./ar.json'),
+ ca: require('../lib/notification-i18n-loader.js!./ca.json'),
+ cs: require('../lib/notification-i18n-loader.js!./cs.json'),
+ de: require('../lib/notification-i18n-loader.js!./de.json'),
+ eo: require('../lib/notification-i18n-loader.js!./eo.json'),
+ es: require('../lib/notification-i18n-loader.js!./es.json'),
+ et: require('../lib/notification-i18n-loader.js!./et.json'),
+ eu: require('../lib/notification-i18n-loader.js!./eu.json'),
+ fi: require('../lib/notification-i18n-loader.js!./fi.json'),
+ fr: require('../lib/notification-i18n-loader.js!./fr.json'),
+ ga: require('../lib/notification-i18n-loader.js!./ga.json'),
+ he: require('../lib/notification-i18n-loader.js!./he.json'),
+ hu: require('../lib/notification-i18n-loader.js!./hu.json'),
+ it: require('../lib/notification-i18n-loader.js!./it.json'),
+ ja: require('../lib/notification-i18n-loader.js!./ja_pedantic.json'),
+ ja_easy: require('../lib/notification-i18n-loader.js!./ja_easy.json'),
+ ko: require('../lib/notification-i18n-loader.js!./ko.json'),
+ nb: require('../lib/notification-i18n-loader.js!./nb.json'),
+ nl: require('../lib/notification-i18n-loader.js!./nl.json'),
+ oc: require('../lib/notification-i18n-loader.js!./oc.json'),
+ pl: require('../lib/notification-i18n-loader.js!./pl.json'),
+ pt: require('../lib/notification-i18n-loader.js!./pt.json'),
+ ro: require('../lib/notification-i18n-loader.js!./ro.json'),
+ ru: require('../lib/notification-i18n-loader.js!./ru.json'),
+ te: require('../lib/notification-i18n-loader.js!./te.json'),
+ zh: require('../lib/notification-i18n-loader.js!./zh.json'),
+ en: require('../lib/notification-i18n-loader.js!./en.json')
+}
+
+export default messages
diff --git a/src/lib/notification-i18n-loader.js b/src/lib/notification-i18n-loader.js
new file mode 100644
index 00000000..71f9156a
--- /dev/null
+++ b/src/lib/notification-i18n-loader.js
@@ -0,0 +1,12 @@
+// This somewhat mysterious module will load a json string
+// and then extract only the 'notifications' part. This is
+// meant to be used to load the partial i18n we need for
+// the service worker.
+module.exports = function (source) {
+ var object = JSON.parse(source)
+ var smol = {
+ notifications: object.notifications || {}
+ }
+
+ return JSON.stringify(smol)
+}
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 9a2e0df1..073b15f1 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -13,7 +13,7 @@ import {
omitBy
} from 'lodash'
import { set } from 'vue'
-import { isStatusNotification } from '../services/notification_utils/notification_utils.js'
+import { isStatusNotification, prepareNotificationObject } from '../services/notification_utils/notification_utils.js'
import apiService from '../services/api/api.service.js'
import { muteWordHits } from '../services/status_parser/status_parser.js'
@@ -344,42 +344,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
state.notifications.idStore[notification.id] = notification
if ('Notification' in window && window.Notification.permission === 'granted') {
- const notifObj = {}
- const status = notification.status
- const title = notification.from_profile.name
- notifObj.icon = notification.from_profile.profile_image_url
- let i18nString
- switch (notification.type) {
- case 'like':
- i18nString = 'favorited_you'
- break
- case 'repeat':
- i18nString = 'repeated_you'
- break
- case 'follow':
- i18nString = 'followed_you'
- break
- case 'move':
- i18nString = 'migrated_to'
- break
- case 'follow_request':
- i18nString = 'follow_request'
- break
- }
-
- if (notification.type === 'pleroma:emoji_reaction') {
- notifObj.body = rootGetters.i18n.t('notifications.reacted_with', [notification.emoji])
- } else if (i18nString) {
- notifObj.body = rootGetters.i18n.t('notifications.' + i18nString)
- } else if (isStatusNotification(notification.type)) {
- notifObj.body = notification.status.text
- }
-
- // Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
- if (status && status.attachments && status.attachments.length > 0 && !status.nsfw &&
- status.attachments[0].mimetype.startsWith('image/')) {
- notifObj.image = status.attachments[0].url
- }
+ const notifObj = prepareNotificationObject(notification, rootGetters.i18n)
const reasonsToMuteNotif = (
notification.seen ||
@@ -393,7 +358,7 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
)
)
if (!reasonsToMuteNotif) {
- let desktopNotification = new window.Notification(title, notifObj)
+ let desktopNotification = new window.Notification(notifObj.title, notifObj)
// Chrome is known for not closing notifications automatically
// according to MDN, anyway.
setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
diff --git a/src/modules/users.js b/src/modules/users.js
index f9329f2a..68d02931 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,6 +1,6 @@
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import oauthApi from '../services/new_api/oauth.js'
-import { compact, map, each, merge, last, concat, uniq } from 'lodash'
+import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'lodash'
import { set } from 'vue'
import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js'
@@ -10,7 +10,7 @@ export const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
- merge(oldItem, item)
+ mergeWith(oldItem, item, mergeArrayLength)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
@@ -23,6 +23,13 @@ export const mergeOrAdd = (arr, obj, item) => {
}
}
+const mergeArrayLength = (oldValue, newValue) => {
+ if (isArray(oldValue) && isArray(newValue)) {
+ oldValue.length = newValue.length
+ return mergeWith(oldValue, newValue, mergeArrayLength)
+ }
+}
+
const getNotificationPermission = () => {
const Notification = window.Notification
@@ -116,7 +123,7 @@ export const mutations = {
},
setCurrentUser (state, user) {
state.lastLoginName = user.screen_name
- state.currentUser = merge(state.currentUser || {}, user)
+ state.currentUser = mergeWith(state.currentUser || {}, user, mergeArrayLength)
},
clearCurrentUser (state) {
state.currentUser = false
@@ -428,10 +435,10 @@ const users = {
store.commit('setUserForNotification', notification)
})
},
- searchUsers (store, { query }) {
- return store.rootState.api.backendInteractor.searchUsers({ query })
+ searchUsers ({ rootState, commit }, { query }) {
+ return rootState.api.backendInteractor.searchUsers({ query })
.then((users) => {
- store.commit('addNewUsers', users)
+ commit('addNewUsers', users)
return users
})
},
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index c7ed65a4..3bdb92f3 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -56,6 +56,12 @@ export const parseUser = (data) => {
value: addEmojis(field.value, data.emojis)
}
})
+ output.fields_text = data.fields.map(field => {
+ return {
+ name: unescape(field.name.replace(/<[^>]*>/g, '')),
+ value: unescape(field.value.replace(/<[^>]*>/g, ''))
+ }
+ })
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
@@ -258,6 +264,12 @@ export const parseStatus = (data) => {
output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
output.external_url = data.url
output.poll = data.poll
+ if (output.poll) {
+ output.poll.options = (output.poll.options || []).map(field => ({
+ ...field,
+ title_html: addEmojis(field.title, data.emojis)
+ }))
+ }
output.pinned = data.pinned
output.muted = data.muted
} else {
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index eb479227..5cc19215 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -43,3 +43,47 @@ export const filteredNotificationsFromStore = (store, types) => {
export const unseenNotificationsFromStore = store =>
filter(filteredNotificationsFromStore(store), ({ seen }) => !seen)
+
+export const prepareNotificationObject = (notification, i18n) => {
+ const notifObj = {
+ tag: notification.id
+ }
+ const status = notification.status
+ const title = notification.from_profile.name
+ notifObj.title = title
+ notifObj.icon = notification.from_profile.profile_image_url
+ let i18nString
+ switch (notification.type) {
+ case 'like':
+ i18nString = 'favorited_you'
+ break
+ case 'repeat':
+ i18nString = 'repeated_you'
+ break
+ case 'follow':
+ i18nString = 'followed_you'
+ break
+ case 'move':
+ i18nString = 'migrated_to'
+ break
+ case 'follow_request':
+ i18nString = 'follow_request'
+ break
+ }
+
+ if (notification.type === 'pleroma:emoji_reaction') {
+ notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
+ } else if (i18nString) {
+ notifObj.body = i18n.t('notifications.' + i18nString)
+ } else if (isStatusNotification(notification.type)) {
+ notifObj.body = notification.status.text
+ }
+
+ // Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
+ if (status && status.attachments && status.attachments.length > 0 && !status.nsfw &&
+ status.attachments[0].mimetype.startsWith('image/')) {
+ notifObj.image = status.attachments[0].url
+ }
+
+ return notifObj
+}
diff --git a/src/sw.js b/src/sw.js
index 6cecb3f3..f5e34dd6 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -1,6 +1,19 @@
/* eslint-env serviceworker */
import localForage from 'localforage'
+import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js'
+import { prepareNotificationObject } from './services/notification_utils/notification_utils.js'
+import Vue from 'vue'
+import VueI18n from 'vue-i18n'
+import messages from './i18n/service_worker_messages.js'
+
+Vue.use(VueI18n)
+const i18n = new VueI18n({
+ // By default, use the browser locale, we will update it if neccessary
+ locale: 'en',
+ fallbackLocale: 'en',
+ messages
+})
function isEnabled () {
return localForage.getItem('vuex-lz')
@@ -12,15 +25,33 @@ function getWindowClients () {
.then((clientList) => clientList.filter(({ type }) => type === 'window'))
}
-self.addEventListener('push', (event) => {
- if (event.data) {
- event.waitUntil(isEnabled().then((isEnabled) => {
- return isEnabled && getWindowClients().then((list) => {
- const data = event.data.json()
+const setLocale = async () => {
+ const state = await localForage.getItem('vuex-lz')
+ const locale = state.config.interfaceLanguage || 'en'
+ i18n.locale = locale
+}
+
+const maybeShowNotification = async (event) => {
+ const enabled = await isEnabled()
+ const activeClients = await getWindowClients()
+ await setLocale()
+ if (enabled && (activeClients.length === 0)) {
+ const data = event.data.json()
+
+ const url = `${self.registration.scope}api/v1/notifications/${data.notification_id}`
+ const notification = await fetch(url, { headers: { Authorization: 'Bearer ' + data.access_token } })
+ const notificationJson = await notification.json()
+ const parsedNotification = parseNotification(notificationJson)
- if (list.length === 0) return self.registration.showNotification(data.title, data)
- })
- }))
+ const res = prepareNotificationObject(parsedNotification, i18n)
+
+ self.registration.showNotification(res.title, res)
+ }
+}
+
+self.addEventListener('push', async (event) => {
+ if (event.data) {
+ event.waitUntil(maybeShowNotification(event))
}
})
diff --git a/test/unit/specs/modules/users.spec.js b/test/unit/specs/modules/users.spec.js
index 670acfc8..dfa5684d 100644
--- a/test/unit/specs/modules/users.spec.js
+++ b/test/unit/specs/modules/users.spec.js
@@ -18,6 +18,42 @@ describe('The users module', () => {
expect(state.users).to.eql([user])
expect(state.users[0].name).to.eql('Dude')
})
+
+ it('merging array field in new information for old users', () => {
+ const state = cloneDeep(defaultState)
+ const user = {
+ id: '1',
+ fields: [
+ { name: 'Label 1', value: 'Content 1' }
+ ]
+ }
+ const firstModUser = {
+ id: '1',
+ fields: [
+ { name: 'Label 2', value: 'Content 2' },
+ { name: 'Label 3', value: 'Content 3' }
+ ]
+ }
+ const secondModUser = {
+ id: '1',
+ fields: [
+ { name: 'Label 4', value: 'Content 4' }
+ ]
+ }
+
+ mutations.addNewUsers(state, [user])
+ expect(state.users[0].fields).to.have.length(1)
+ expect(state.users[0].fields[0].name).to.eql('Label 1')
+
+ mutations.addNewUsers(state, [firstModUser])
+ expect(state.users[0].fields).to.have.length(2)
+ expect(state.users[0].fields[0].name).to.eql('Label 2')
+ expect(state.users[0].fields[1].name).to.eql('Label 3')
+
+ mutations.addNewUsers(state, [secondModUser])
+ expect(state.users[0].fields).to.have.length(1)
+ expect(state.users[0].fields[0].name).to.eql('Label 4')
+ })
})
describe('findUser', () => {
diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
index 166fce2b..ccb57942 100644
--- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
+++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -290,6 +290,19 @@ describe('API Entities normalizer', () => {
expect(field).to.have.property('value').that.contains('<img')
})
+ it('removes html tags from user profile fields', () => {
+ const user = makeMockUserMasto({ emojis: makeMockEmojiMasto(), fields: [{ name: 'user', value: '<a rel="me" href="https://example.com/@user">@user</a>' }] })
+
+ const parsedUser = parseUser(user)
+
+ expect(parsedUser).to.have.property('fields_text').to.be.an('array')
+
+ const field = parsedUser.fields_text[0]
+
+ expect(field).to.have.property('name').that.equal('user')
+ expect(field).to.have.property('value').that.equal('@user')
+ })
+
it('adds hide_follows and hide_followers user settings', () => {
const user = makeMockUserMasto({ pleroma: { hide_followers: true, hide_follows: false, hide_followers_count: false, hide_follows_count: true } })