diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e3eaf1730a04b5d103f048c9627f5134a65130a..887588f32f0e8e3a659941381de54dea958844b9 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 0db035475f7f8ce7ba9f59492da471cd2b7e3413..1796eb1be9b42b6093768608d1c99e861add8721 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 744b77d5399b9dba509389ecc73c7ac7501e41e7..029e70968a0d69b90b0bfdd311c148f44e99ca65 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 f4c3479c7d6c3abaf3c079bd9b3484df22e3688b..7974a66d93c6853e8e6115367a311c152b1ba42a 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 15a71effa1f6055bd658ba5443a543419b68e680..8330345bb093d7515c57502d754939f17ecbd93f 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 bca93ea7448fe381fadeee4309ff14d034a770a2..68db6fd8f1159a5452092dff310a3ea0e61889b9 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 56e91cca502f800cf870a3661a034937af2f897f..adbb05558e3d28a6c0ac95fb7cdc47c0c4e02848 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 5881d266b45bd9510059d8b4fa9d7e798d538c22..a40a919521aeb75e8d5dfaf132fb05a7e6d80d04 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 8658b09770a7a2765c6cd8a095358adf644a7b87..e6db802ddb5c7de4f995f86f78d432074fd14826 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 4aab81eb7e04ddf868bae5c09732c1d3031fb1f8..b3dcf42c865238cc54a079c5910dbbb7d9e9f10b 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 fff4f970c0caa367844fec09211f1207102b6b69..0f9210a6d1947c76c337ce8d2d5f2800a23a3688 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 336f912a7064ad3a60b161f48d46cc97b9073c47..7ec29b2884c2d5d34671c9cfaee9ec8d015a13d6 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 7adb67ae34c42555816384405737b6e47682c6ae..efc2485ead4037181637e2ee4236ae04f42ec585 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 c4a5ce9d6d3af565c8372bc16eb91148a077abb7..9529d7f6c0affea540981cf932598a458a41e539 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 95760bf84b5ae3847de6451618e6c4fff80b0008..201727d49a046cdd90a8e5fca43a9aa7de2a1c4f 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 1871d46c6200f790eab2ae7be8e223a4ef90e7fb..361a3b5c80b2db5ded883e30f76766a18bd9f21a 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 eefe10e5ea34cc2e0cde5f2b6584478ef97bf52d..2840904fc0c1ad7aa879e81fa7f9cb475ee1c175 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 6c8be351f80887f783e1dcbe4d128731d174347d..7311f0b69ed9308644f9df0720814cc851f7388a 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 af728b6eb7fd7d196b932c9d180f1fa0321b11b2..bf270f87d8dfdc2a300f420134bcd3e6c55e4969 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 f9a729544fde979edb1daf35fbaf701df74bd6ce..aa78db26e3ead4b439ac201432047f86c178b1cb 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 0000000000000000000000000000000000000000..270ed043c59d142a9e2dbfb06cc7cd057d294a61 --- /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 0000000000000000000000000000000000000000..71f9156a87594b589035451c0f91656d602bccd5 --- /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 9a2e0df1e9646427995bd2573dfa024465fdcde3..073b15f1b6174132240270e2145c6a975e67ddc4 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 f9329f2a81b5b6419051b6b389ceb8fcee3536f7..68d029315c134981441060ad28684bf8cac538cb 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 c7ed65a4d42300bba9be1e4f3781a137f4c75b53..3bdb92f3ff2e923986eb700ab3b09e6f61d6e492 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 eb479227ca4749737f1b525a4aaa6965dd5f9db6..5cc19215aaf388c1c7852d47455b6cb9525fb1e3 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 6cecb3f380004f918b313f2068ac67541fdeccb1..f5e34dd608787c0c3db66226049ac05fdcbe6858 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 670acfc8221251431ce3ed5879fd3b0f2e2b91db..dfa5684d15bc5def3973d318a7339b6ac6952ef4 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 166fce2b7a37f934521ac4720dbaaffebd6e4692..ccb57942c871da6d8be6657e0e386dde88743096 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 } })