From 830e8fdb453c01f091b63dddbc634deb3cdd5d43 Mon Sep 17 00:00:00 2001 From: Sol Fisher Romanoff <sol@solfisher.com> Date: Sat, 10 Dec 2022 21:03:12 +0200 Subject: [PATCH 01/29] Fix user moderation dropdown clipping --- src/components/user_card/user_card.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss index 07ab7bec..2cae1c35 100644 --- a/src/components/user_card/user_card.scss +++ b/src/components/user_card/user_card.scss @@ -2,7 +2,7 @@ .user-card { position: relative; - z-index: 1; + z-index: 10; &:hover { --_still-image-img-visibility: visible; From 6e1ba218df450f2a7e900e794bd6275002f0650e Mon Sep 17 00:00:00 2001 From: Sol Fisher Romanoff <sol@solfisher.com> Date: Sat, 10 Dec 2022 21:17:41 +0200 Subject: [PATCH 02/29] Don't show timeline links if disabled and logged out --- src/components/desktop_nav/desktop_nav.js | 4 ++++ src/components/desktop_nav/desktop_nav.vue | 2 ++ src/components/timeline_menu/timeline_menu_content.js | 3 ++- src/components/timeline_menu/timeline_menu_content.vue | 4 ++-- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js index f4900c38..2fb8a5ac 100644 --- a/src/components/desktop_nav/desktop_nav.js +++ b/src/components/desktop_nav/desktop_nav.js @@ -98,11 +98,15 @@ export default { logoLeft () { return this.$store.state.instance.logoLeft }, currentUser () { return this.$store.state.users.currentUser }, privateMode () { return this.$store.state.instance.private }, + federating () { return this.$store.state.instance.federating }, shouldConfirmLogout () { return this.$store.getters.mergedConfig.modalOnLogout }, showBubbleTimeline () { return this.$store.state.instance.localBubbleInstances.length > 0 + }, + restrictedTimelines () { + return this.$store.state.instance.restrict_unauthenticated.timelines } }, methods: { diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index 92d3fa5b..a52989a5 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -44,6 +44,7 @@ /> </router-link> <router-link + v-if="currentUser || !(privateMode || restrictedTimelines.public)" :to="{ name: 'public-timeline' }" class="nav-icon" > @@ -67,6 +68,7 @@ /> </router-link> <router-link + v-if="federating && (currentUser || !(privateMode || restrictedTimelines.federated))" :to="{ name: 'public-external-timeline' }" class="nav-icon" > diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js index df15030b..25bd30d9 100644 --- a/src/components/timeline_menu/timeline_menu_content.js +++ b/src/components/timeline_menu/timeline_menu_content.js @@ -24,7 +24,8 @@ const TimelineMenuContent = { currentUser: state => state.users.currentUser, privateMode: state => state.instance.private, federating: state => state.instance.federating, - showBubbleTimeline: state => (state.instance.localBubbleInstances.length > 0) + showBubbleTimeline: state => (state.instance.localBubbleInstances.length > 0), + restrictedTimelines: state => state.instance.restrict_unauthenticated.timelines }) } } diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue index 27aece22..220b0278 100644 --- a/src/components/timeline_menu/timeline_menu_content.vue +++ b/src/components/timeline_menu/timeline_menu_content.vue @@ -32,7 +32,7 @@ >{{ $t("nav.bubble_timeline") }}</span> </router-link> </li> - <li v-if="currentUser || !privateMode"> + <li v-if="currentUser || !(privateMode || restrictedTimelines.public)"> <router-link class="menu-item" :to="{ name: 'public-timeline' }" @@ -48,7 +48,7 @@ >{{ $t("nav.public_tl") }}</span> </router-link> </li> - <li v-if="federating && (currentUser || !privateMode)"> + <li v-if="federating && (currentUser || !(privateMode || restrictedTimelines.federated))"> <router-link class="menu-item" :to="{ name: 'public-external-timeline' }" From 413acbc7dd9a544ea94faa328180c7d86d39a47a Mon Sep 17 00:00:00 2001 From: fef <owo@fef.moe> Date: Mon, 12 Dec 2022 18:59:57 +0100 Subject: [PATCH 03/29] fix 404 when reacting with Keycap Number Sign The Unicode sequence for the Keycap Number Sign emoji starts with an ASCII "#" character, which the browser's URL parser will interpret as a URI fragment and truncate it before sending the request to the backend. --- src/services/api/api.service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 9e6f39f2..3edda6e1 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1335,7 +1335,7 @@ const fetchEmojiReactions = ({ id, credentials }) => { const reactWithEmoji = ({ id, emoji, credentials }) => { return promisedRequest({ - url: PLEROMA_EMOJI_REACT_URL(id, emoji), + url: PLEROMA_EMOJI_REACT_URL(id, encodeURIComponent(emoji)), method: 'PUT', credentials }).then(parseStatus) @@ -1343,7 +1343,7 @@ const reactWithEmoji = ({ id, emoji, credentials }) => { const unreactWithEmoji = ({ id, emoji, credentials }) => { return promisedRequest({ - url: PLEROMA_EMOJI_UNREACT_URL(id, emoji), + url: PLEROMA_EMOJI_UNREACT_URL(id, encodeURIComponent(emoji)), method: 'DELETE', credentials }).then(parseStatus) From 909271c764f07861b0e77a7909d2d9b7e524e6ad Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Wed, 14 Dec 2022 09:38:07 +0000 Subject: [PATCH 04/29] use v1 urls --- src/modules/instance.js | 2 +- src/services/api/api.service.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/instance.js b/src/modules/instance.js index c8c718d0..02cbe1f8 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -178,7 +178,7 @@ const instance = { async getCustomEmoji ({ commit, state }) { try { - const res = await window.fetch('/api/pleroma/emoji.json') + const res = await window.fetch('/api/v1/pleroma/emoji') if (res.ok) { const result = await res.json() const values = Array.isArray(result) ? Object.assign({}, ...result) : result diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 9e6f39f2..afa443f5 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -11,11 +11,11 @@ const CHANGE_EMAIL_URL = '/api/pleroma/change_email' const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' const MOVE_ACCOUNT_URL = '/api/pleroma/move_account' const ALIASES_URL = '/api/pleroma/aliases' -const TAG_USER_URL = '/api/pleroma/admin/users/tag' -const PERMISSION_GROUP_URL = (screenName, right) => `/api/pleroma/admin/users/${screenName}/permission_group/${right}` -const ACTIVATE_USER_URL = '/api/pleroma/admin/users/activate' -const DEACTIVATE_USER_URL = '/api/pleroma/admin/users/deactivate' -const ADMIN_USERS_URL = '/api/pleroma/admin/users' +const TAG_USER_URL = '/api/v1/pleroma/admin/users/tag' +const PERMISSION_GROUP_URL = (screenName, right) => `/api/v1/pleroma/admin/users/${screenName}/permission_group/${right}` +const ACTIVATE_USER_URL = '/api/v1/pleroma/admin/users/activate' +const DEACTIVATE_USER_URL = '/api/v1/pleroma/admin/users/deactivate' +const ADMIN_USERS_URL = '/api/v1/pleroma/admin/users' const SUGGESTIONS_URL = '/api/v1/suggestions' const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings' const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read' @@ -246,7 +246,7 @@ const register = ({ params, credentials }) => { }) } -const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json()) +const getCaptcha = () => fetch('/api/v1/pleroma/captcha').then(resp => resp.json()) const authHeaders = (accessToken) => { if (accessToken) { From 8c6cf86de3b4314bbed4b7ad9147cbf96584cb76 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Wed, 14 Dec 2022 09:38:46 +0000 Subject: [PATCH 05/29] Revert "use v1 urls" This reverts commit 909271c764f07861b0e77a7909d2d9b7e524e6ad. --- src/modules/instance.js | 2 +- src/services/api/api.service.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/instance.js b/src/modules/instance.js index 02cbe1f8..c8c718d0 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -178,7 +178,7 @@ const instance = { async getCustomEmoji ({ commit, state }) { try { - const res = await window.fetch('/api/v1/pleroma/emoji') + const res = await window.fetch('/api/pleroma/emoji.json') if (res.ok) { const result = await res.json() const values = Array.isArray(result) ? Object.assign({}, ...result) : result diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index afa443f5..9e6f39f2 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -11,11 +11,11 @@ const CHANGE_EMAIL_URL = '/api/pleroma/change_email' const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' const MOVE_ACCOUNT_URL = '/api/pleroma/move_account' const ALIASES_URL = '/api/pleroma/aliases' -const TAG_USER_URL = '/api/v1/pleroma/admin/users/tag' -const PERMISSION_GROUP_URL = (screenName, right) => `/api/v1/pleroma/admin/users/${screenName}/permission_group/${right}` -const ACTIVATE_USER_URL = '/api/v1/pleroma/admin/users/activate' -const DEACTIVATE_USER_URL = '/api/v1/pleroma/admin/users/deactivate' -const ADMIN_USERS_URL = '/api/v1/pleroma/admin/users' +const TAG_USER_URL = '/api/pleroma/admin/users/tag' +const PERMISSION_GROUP_URL = (screenName, right) => `/api/pleroma/admin/users/${screenName}/permission_group/${right}` +const ACTIVATE_USER_URL = '/api/pleroma/admin/users/activate' +const DEACTIVATE_USER_URL = '/api/pleroma/admin/users/deactivate' +const ADMIN_USERS_URL = '/api/pleroma/admin/users' const SUGGESTIONS_URL = '/api/v1/suggestions' const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings' const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read' @@ -246,7 +246,7 @@ const register = ({ params, credentials }) => { }) } -const getCaptcha = () => fetch('/api/v1/pleroma/captcha').then(resp => resp.json()) +const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json()) const authHeaders = (accessToken) => { if (accessToken) { From c39332c1bfb410a5f20907d75e739dc5cda15ce8 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Wed, 14 Dec 2022 09:39:01 +0000 Subject: [PATCH 06/29] Revert "Revert "use v1 urls"" This reverts commit 8c6cf86de3b4314bbed4b7ad9147cbf96584cb76. --- src/modules/instance.js | 2 +- src/services/api/api.service.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/instance.js b/src/modules/instance.js index c8c718d0..02cbe1f8 100644 --- a/src/modules/instance.js +++ b/src/modules/instance.js @@ -178,7 +178,7 @@ const instance = { async getCustomEmoji ({ commit, state }) { try { - const res = await window.fetch('/api/pleroma/emoji.json') + const res = await window.fetch('/api/v1/pleroma/emoji') if (res.ok) { const result = await res.json() const values = Array.isArray(result) ? Object.assign({}, ...result) : result diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 9e6f39f2..afa443f5 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -11,11 +11,11 @@ const CHANGE_EMAIL_URL = '/api/pleroma/change_email' const CHANGE_PASSWORD_URL = '/api/pleroma/change_password' const MOVE_ACCOUNT_URL = '/api/pleroma/move_account' const ALIASES_URL = '/api/pleroma/aliases' -const TAG_USER_URL = '/api/pleroma/admin/users/tag' -const PERMISSION_GROUP_URL = (screenName, right) => `/api/pleroma/admin/users/${screenName}/permission_group/${right}` -const ACTIVATE_USER_URL = '/api/pleroma/admin/users/activate' -const DEACTIVATE_USER_URL = '/api/pleroma/admin/users/deactivate' -const ADMIN_USERS_URL = '/api/pleroma/admin/users' +const TAG_USER_URL = '/api/v1/pleroma/admin/users/tag' +const PERMISSION_GROUP_URL = (screenName, right) => `/api/v1/pleroma/admin/users/${screenName}/permission_group/${right}` +const ACTIVATE_USER_URL = '/api/v1/pleroma/admin/users/activate' +const DEACTIVATE_USER_URL = '/api/v1/pleroma/admin/users/deactivate' +const ADMIN_USERS_URL = '/api/v1/pleroma/admin/users' const SUGGESTIONS_URL = '/api/v1/suggestions' const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings' const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read' @@ -246,7 +246,7 @@ const register = ({ params, credentials }) => { }) } -const getCaptcha = () => fetch('/api/pleroma/captcha').then(resp => resp.json()) +const getCaptcha = () => fetch('/api/v1/pleroma/captcha').then(resp => resp.json()) const authHeaders = (accessToken) => { if (accessToken) { From 7ff17ab72238ed66a45c512e7cd77ac46adc0b7d Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Tue, 20 Dec 2022 13:20:13 +0000 Subject: [PATCH 07/29] don't crash out if notification status is null --- src/services/entity_normalizer/entity_normalizer.service.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 465d6fad..df6c03b5 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -408,8 +408,10 @@ export const parseNotification = (data) => { if (masto) { output.type = mastoDict[data.type] || data.type output.seen = data.pleroma.is_seen - output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null - output.action = output.status // TODO: Refactor, this is unneeded + if (data.status) { + output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null + output.action = output.status // TODO: Refactor, this is unneeded + } output.target = output.type !== 'move' ? null : parseUser(data.target) From d00e28d5e9b8a51d2f36c7d0238f8cd5a7ef4934 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Thu, 22 Dec 2022 05:43:01 +0000 Subject: [PATCH 08/29] fix emoji picker in replies in notifications --- src/components/emoji_picker/emoji_picker.scss | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index ac7b8b5d..119da7c4 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -1,5 +1,16 @@ @import '../../_variables.scss'; +// The worst query selector ever +// selects ONLY emojis pickers in replies in notifications +// who thought this was a good idea? +.notification > .Status > .status-container > .post-status-form > form > .form-group > .emoji-input > .emoji-picker { + max-width: 100%; + left: 0; + @media (min-width: 1300px) { + left: -30px; + } +} + .Notification { .emoji-picker { min-width: 160%; @@ -18,6 +29,10 @@ min-width: 50%; max-width: 130%; } + + .Status > .emoji-picker { + z-index: 1000; + } } } .emoji-picker { From da491f3278214fd56e3485d0ac162e92bf1b89ae Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Thu, 29 Dec 2022 15:17:35 +0000 Subject: [PATCH 09/29] add verification of links --- src/components/user_profile/user_profile.js | 6 ++++-- src/components/user_profile/user_profile.vue | 14 ++++++++++++++ src/i18n/en.json | 3 ++- .../entity_normalizer/entity_normalizer.service.js | 7 +++++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 42c5ec9e..8fba1a28 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -10,11 +10,13 @@ import withLoadMore from '../../hocs/with_load_more/with_load_more' import { debounce } from 'lodash' import { library } from '@fortawesome/fontawesome-svg-core' import { - faCircleNotch + faCircleNotch, + faCircleCheck } from '@fortawesome/free-solid-svg-icons' library.add( - faCircleNotch + faCircleNotch, + faCircleCheck ) const FollowerList = withLoadMore({ diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index d16483e2..4e0d0b0f 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -37,6 +37,15 @@ :html="field.value" :emoji="user.emoji" /> + <span + v-if="field.verified_at" + class="user-profile-field-validated" + > + <FAIcon + icon="check-circle" + :title="$t('user_profile.field_validated')" + /> + </span> </dd> </dl> </div> @@ -225,6 +234,11 @@ padding: 0.5em 1.5em; box-sizing: border-box; } + + .user-profile-field-validated { + margin-left: 1rem; + color: green; + } } } diff --git a/src/i18n/en.json b/src/i18n/en.json index 32785561..6fbac399 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1183,7 +1183,8 @@ "user_profile": { "profile_does_not_exist": "Sorry, this profile does not exist.", "profile_loading_error": "Sorry, there was an error loading this profile.", - "timeline_title": "User timeline" + "timeline_title": "User timeline", + "field_validated": "Link Verified" }, "user_reporting": { "add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:", diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index df6c03b5..eb85418e 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -68,13 +68,16 @@ export const parseUser = (data) => { output.fields_html = data.fields.map(field => { return { name: escape(field.name), - value: field.value + value: field.value, + verified_at: field.verified_at } }) + console.log(output.fields_html) output.fields_text = data.fields.map(field => { return { name: unescape(field.name.replace(/<[^>]*>/g, '')), - value: unescape(field.value.replace(/<[^>]*>/g, '')) + value: unescape(field.value.replace(/<[^>]*>/g, '')), + verified_at: field.verified_at } }) From bb243168b378a931be78c642030c9e89a0b8197b Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Thu, 29 Dec 2022 15:18:13 +0000 Subject: [PATCH 10/29] Revert "Merge pull request 'Don't show timeline links if disabled and logged out' (#250) from sfr/pleroma-fe:fix/hide-timelines into develop" This reverts commit 0b5793c1e0fc3d311ffb54784299442f2fa45967, reversing changes made to 72ef2e7454d654ceb34fc736c3f8080536b80b40. --- src/components/desktop_nav/desktop_nav.js | 4 ---- src/components/desktop_nav/desktop_nav.vue | 2 -- src/components/timeline_menu/timeline_menu_content.js | 3 +-- src/components/timeline_menu/timeline_menu_content.vue | 4 ++-- 4 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js index 2fb8a5ac..f4900c38 100644 --- a/src/components/desktop_nav/desktop_nav.js +++ b/src/components/desktop_nav/desktop_nav.js @@ -98,15 +98,11 @@ export default { logoLeft () { return this.$store.state.instance.logoLeft }, currentUser () { return this.$store.state.users.currentUser }, privateMode () { return this.$store.state.instance.private }, - federating () { return this.$store.state.instance.federating }, shouldConfirmLogout () { return this.$store.getters.mergedConfig.modalOnLogout }, showBubbleTimeline () { return this.$store.state.instance.localBubbleInstances.length > 0 - }, - restrictedTimelines () { - return this.$store.state.instance.restrict_unauthenticated.timelines } }, methods: { diff --git a/src/components/desktop_nav/desktop_nav.vue b/src/components/desktop_nav/desktop_nav.vue index a52989a5..92d3fa5b 100644 --- a/src/components/desktop_nav/desktop_nav.vue +++ b/src/components/desktop_nav/desktop_nav.vue @@ -44,7 +44,6 @@ /> </router-link> <router-link - v-if="currentUser || !(privateMode || restrictedTimelines.public)" :to="{ name: 'public-timeline' }" class="nav-icon" > @@ -68,7 +67,6 @@ /> </router-link> <router-link - v-if="federating && (currentUser || !(privateMode || restrictedTimelines.federated))" :to="{ name: 'public-external-timeline' }" class="nav-icon" > diff --git a/src/components/timeline_menu/timeline_menu_content.js b/src/components/timeline_menu/timeline_menu_content.js index 25bd30d9..df15030b 100644 --- a/src/components/timeline_menu/timeline_menu_content.js +++ b/src/components/timeline_menu/timeline_menu_content.js @@ -24,8 +24,7 @@ const TimelineMenuContent = { currentUser: state => state.users.currentUser, privateMode: state => state.instance.private, federating: state => state.instance.federating, - showBubbleTimeline: state => (state.instance.localBubbleInstances.length > 0), - restrictedTimelines: state => state.instance.restrict_unauthenticated.timelines + showBubbleTimeline: state => (state.instance.localBubbleInstances.length > 0) }) } } diff --git a/src/components/timeline_menu/timeline_menu_content.vue b/src/components/timeline_menu/timeline_menu_content.vue index 220b0278..27aece22 100644 --- a/src/components/timeline_menu/timeline_menu_content.vue +++ b/src/components/timeline_menu/timeline_menu_content.vue @@ -32,7 +32,7 @@ >{{ $t("nav.bubble_timeline") }}</span> </router-link> </li> - <li v-if="currentUser || !(privateMode || restrictedTimelines.public)"> + <li v-if="currentUser || !privateMode"> <router-link class="menu-item" :to="{ name: 'public-timeline' }" @@ -48,7 +48,7 @@ >{{ $t("nav.public_tl") }}</span> </router-link> </li> - <li v-if="federating && (currentUser || !(privateMode || restrictedTimelines.federated))"> + <li v-if="federating && (currentUser || !privateMode)"> <router-link class="menu-item" :to="{ name: 'public-external-timeline' }" From 401dfa8fa6ac908b2909f1a3c281e85361c8b038 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Thu, 29 Dec 2022 15:22:06 +0000 Subject: [PATCH 11/29] update readme --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9be1c3bd..6c2c9376 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ -# Pleroma-FE +# Akkoma-FE   -This is a fork of Pleroma-FE from the Pleroma project, with support for new Akkoma features such as: +This is a fork of Akkoma-FE from the Pleroma project, with support for new Akkoma features such as: - MFM support via [marked-mfm](https://akkoma.dev/sfr/marked-mfm) - Custom emoji reactions # For Translators -The [Weblate UI](https://translate.akkoma.dev/projects/akkoma/pleroma-fe/) is recommended for adding or modifying translations for Pleroma-FE. +The [Weblate UI](https://translate.akkoma.dev/projects/akkoma/pleroma-fe/) is recommended for adding or modifying translations for Akkoma-FE. Alternatively, edit/create `src/i18n/$LANGUAGE_CODE.json` (where `$LANGUAGE_CODE` is the [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language), then add your language to [src/i18n/messages.js](https://akkoma.dev/AkkomaGang/pleroma-fe/src/branch/develop/src/i18n/messages.js) if it doesn't already exist there. -Pleroma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js. +Akkoma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js. # FOR ADMINS -To use Pleroma-FE in Akkoma, use the [frontend](https://docs.akkoma.dev/stable/administration/CLI_tasks/frontend/) CLI task to install Pleroma-FE, then modify your configuration as described in the [Frontend Management](https://docs.akkoma.dev/stable/configuration/frontend_management/) doc. +To use Akkoma-FE in Akkoma, use the [frontend](https://docs.akkoma.dev/stable/administration/CLI_tasks/frontend/) CLI task to install Akkoma-FE, then modify your configuration as described in the [Frontend Management](https://docs.akkoma.dev/stable/configuration/frontend_management/) doc. ## Build Setup From 5a4315384ee84ec5cdaffc2010648b410268eaf5 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Thu, 29 Dec 2022 15:25:03 +0000 Subject: [PATCH 12/29] force CI build --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6c2c9376..43e177b4 100644 --- a/README.md +++ b/README.md @@ -52,4 +52,4 @@ 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. +```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. From 9cd62fe08ddce8759620a1d15a6d934a0305c447 Mon Sep 17 00:00:00 2001 From: Sol Fisher Romanoff <sol@solfisher.com> Date: Fri, 30 Dec 2022 01:03:31 +0200 Subject: [PATCH 13/29] Remove stray debug log --- src/services/entity_normalizer/entity_normalizer.service.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index eb85418e..5d129556 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -72,7 +72,6 @@ export const parseUser = (data) => { verified_at: field.verified_at } }) - console.log(output.fields_html) output.fields_text = data.fields.map(field => { return { name: unescape(field.name.replace(/<[^>]*>/g, '')), From 014f8b0dd274cd4fc185335679f3aab0c3acb17d Mon Sep 17 00:00:00 2001 From: Norm <normandy@biribiri.dev> Date: Fri, 30 Dec 2022 03:01:17 +0000 Subject: [PATCH 14/29] Make minimum width for 3-column layout 1280px (#255) (#256) 1280px is a pretty common screen width for several resolutions (1280x720, 1280x800, 1280x1024, etc.). Since it is only 20px less than the current 1300px minimum, this shouldn't be a big issue to lower the minimum screen width for the 3-column layout to 1280px. Closes: https://akkoma.dev/AkkomaGang/pleroma-fe/issues/255 Co-authored-by: Francis Dinh <normandy@biribiri.dev> Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma-fe/pulls/256 Co-authored-by: Norm <normandy@biribiri.dev> Co-committed-by: Norm <normandy@biribiri.dev> --- src/components/emoji_picker/emoji_picker.scss | 2 +- src/modules/interface.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index 119da7c4..82fc831c 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -18,7 +18,7 @@ overflow: hidden; left: -70%; max-width: 100%; - @media (min-width: 800px) and (max-width: 1300px) { + @media (min-width: 800px) and (max-width: 1280px) { left: -50%; min-width: 50%; max-width: 130%; diff --git a/src/modules/interface.js b/src/modules/interface.js index ae1a31c3..33528c0d 100644 --- a/src/modules/interface.js +++ b/src/modules/interface.js @@ -186,7 +186,7 @@ const interfaceMod = { if (thirdColumnMode === 'none' || !rootState.users.currentUser) { commit('setLayoutType', normalOrMobile) } else { - const wideLayout = width >= 1300 + const wideLayout = width >= 1280 commit('setLayoutType', wideLayout ? 'wide' : normalOrMobile) } }, From 2e5001e5de164be825fc065e0425725e4fe81219 Mon Sep 17 00:00:00 2001 From: Beefox <bee@beefox.xyz> Date: Fri, 30 Dec 2022 03:04:15 +0000 Subject: [PATCH 15/29] Allow follow(er) lists to be acessible by account owner even if follower counts are disabled (#246) Currently, if a user has their follower/follow counts hidden, they cannot access their own list of followers/follows. This makes no real sense and means that they cannot modify those lists without disabling their privacy options. This fix simply allows those tabs to be accessed no matter if the counts are hidden or not. Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma-fe/pulls/246 Co-authored-by: Beefox <bee@beefox.xyz> Co-committed-by: Beefox <bee@beefox.xyz> --- src/components/user_profile/user_profile.vue | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 4e0d0b0f..87bbf679 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -104,10 +104,9 @@ v-if="followsTabVisible" key="followees" :label="$t('user_card.followees')" - :disabled="!user.friends_count" > <FriendList :user-id="userId"> - <template v-slot:item="{item}"> + <template #item="{item}"> <FollowCard :user="item" /> </template> </FriendList> @@ -116,10 +115,9 @@ v-if="followersTabVisible" key="followers" :label="$t('user_card.followers')" - :disabled="!user.followers_count" > <FollowerList :user-id="userId"> - <template v-slot:item="{item}"> + <template #item="{item}"> <FollowCard :user="item" :no-follows-you="isUs" From ea941d7cfa69258949df7342e43b0ecbff68dd66 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Fri, 30 Dec 2022 03:20:12 +0000 Subject: [PATCH 16/29] remove IHBA assets --- static/.tos | 45 --------------------------------------------- static/logo.png | Bin 5976 -> 0 bytes 2 files changed, 45 deletions(-) delete mode 100644 static/.tos delete mode 100644 static/logo.png diff --git a/static/.tos b/static/.tos deleted file mode 100644 index 4d91f9b0..00000000 --- a/static/.tos +++ /dev/null @@ -1,45 +0,0 @@ -<h4>Terms of Service</h4> - -<p>It's mainly "be nice"</p> - -<ol> - <li> - <h3>Don't be a big meanie</h4> - <p>Arguments are cool and all but don't make them into flamewars. Try to act in good faith - we want to be at least on good terms with people. Please act with understanding towards others on this instance. Most people here are probably struggling with a lot, be mindful of that.</p> - </li> - <li> - <h3>Mark your lewds!</h3> - <p>Reminder that lewd is bad and nobody wants to be forced to see that. Just mark it sensitive, and post unlisted. That is to say, anything suggestive/ecchi upwards should be marked. If you wouldn't look at it with your parents/boss in the room, mark it. It goes without saying that if you're <em>going</em> to post lewd stuff, keep it sensible. Obviously nothing underaged or otherwise questionable. Or you could just not post lewd stuff. Either/or.</p> - </li> - <li> - <h3>This is a <b>Kink Shame Zone</b></h3> - <p>Being a lewdie will be met with many anime girl reaction images shaming you for your lewdness. Go think about icky things on someone else's webzone™</p> - </li> - <li> - <h3>Keep it legal!</h3> - <p>Server is hosted in france, keep content legal for there (+ wherever you're browsing from)</p> - </li> - <li> - <h3>No ads/spambots</h3> - <p>I didn't think I'd have to specify this, but please do not set up bots solely for trying to advertise.</h3> - </li> - <li> - <h3>Non-TOS recommendations</h3> - <p>This is stuff that'd I'd <em>like</em> you to do, but I won't outright ban you if you don't follow them</p> - <ul> - <li>If someone is sadposting, don't antagonise them - they probably just want to vent</li> - <li>Put walls of text behind a subject (CW) - helps the timeline not get flooded with text</li> - </ul> - </li> - - <li> - <h3>Other</h3> - <p>If you're here and you happen to play minecraft, feel free to message me with your username and come play with us sometime!</p> - </li> - -</ol> - -<p>So I guess yeah, that's about it. Try to be nice, eh? We're probably all sad here.</p> - -<br> -<img src="/static/logo.png" style="display: block; margin: auto; max-width: 100%; height: 50px; object-fit: contain;" /> diff --git a/static/logo.png b/static/logo.png deleted file mode 100644 index 14aaf4a3f180ca6809611f1e65279f263f3ff92e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5976 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%0DDrN|2 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 T#mwR&9jDQHsHa|vu#5OV`@ECO From 313ddcebcb9524a0496050dc2b0bea4bedab78e0 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Fri, 30 Dec 2022 04:57:23 +0000 Subject: [PATCH 17/29] Add blurhash support --- package.json | 1 + src/components/attachment/attachment.js | 7 +- src/components/attachment/attachment.vue | 8 +++ src/components/blurhash/Blurhash.vue | 66 ++++++++++++++++++ .../settings_modal/tabs/general_tab.vue | 9 +++ src/i18n/en.json | 1 + src/modules/config.js | 3 +- .../entity_normalizer.service.js | 3 +- static/blurhash-overlay.png | Bin 0 -> 27640 bytes yarn.lock | 5 ++ 10 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 src/components/blurhash/Blurhash.vue create mode 100755 static/blurhash-overlay.png diff --git a/package.json b/package.json index e712cffd..297a9379 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@kazvmoe-infra/pinch-zoom-element": "1.2.0", "@vuelidate/core": "^2.0.0", "@vuelidate/validators": "^2.0.0", + "blurhash": "^2.0.4", "body-scroll-lock": "2.7.1", "chromatism": "3.0.0", "click-outside-vue3": "4.0.1", diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index 4dcacc7e..3155abf0 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -18,6 +18,7 @@ import { faPencilAlt, faAlignRight } from '@fortawesome/free-solid-svg-icons' +import Blurhash from '../blurhash/Blurhash.vue' library.add( faFile, @@ -63,7 +64,8 @@ const Attachment = { components: { Flash, StillImage, - VideoAttachment + VideoAttachment, + Blurhash }, computed: { classNames () { @@ -84,6 +86,9 @@ const Attachment = { useContainFit () { return this.$store.getters.mergedConfig.useContainFit }, + useBlurhash () { + return this.$store.getters.mergedConfig.useBlurhash + }, placeholderName () { if (this.attachment.description === '' || !this.attachment.description) { return this.type.toUpperCase() diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index 947b1bfc..f9c4f1c8 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -64,7 +64,15 @@ :title="attachment.description" @click.prevent.stop="toggleHidden" > + <Blurhash + v-if="useBlurhash" + :height="512" + :width="1024" + :hash="attachment.blurhash" + :punch="1" + /> <img + v-else :key="nsfwImage" class="nsfw" :src="nsfwImage" diff --git a/src/components/blurhash/Blurhash.vue b/src/components/blurhash/Blurhash.vue new file mode 100644 index 00000000..c2e7f5b9 --- /dev/null +++ b/src/components/blurhash/Blurhash.vue @@ -0,0 +1,66 @@ +<template> + <canvas + ref="canvas" + class="blurhash" + /> +</template> + +<script> +import { decode } from "blurhash"; + +export default { + name: 'Blurhash', + props: { + hash: { + type: String, + required: true, + }, + width: { + type: Number, + required: true, + }, + height: { + type: Number, + required: true, + }, + punch: { + type: Number, + default: null, + }, + }, + data() { + return { + canvas: null, + ctx: null, + }; + }, + mounted() { + this.canvas = this.$refs.canvas; + this.ctx = this.canvas.getContext('2d'); + this.canvas.width = 1024; + this.canvas.height = 512; + this.draw(); + }, + methods: { + draw() { + const pixels = decode(this.hash, this.width, this.height, this.punch); + const imageData = this.ctx.createImageData(this.width, this.height); + imageData.data.set(pixels); + this.ctx.putImageData(imageData, 0, 0); + fetch("/static/blurhash-overlay.png") + .then((response) => response.blob()) + .then((blob) => { + const img = new Image(); + img.src = URL.createObjectURL(blob); + img.onload = () => { + this.ctx.drawImage(img, 0, 0, this.width, this.height); + }; + }); + }, + } +} +</script> + +<style scoped> + +</style> diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue index 2fa20742..5d135711 100644 --- a/src/components/settings_modal/tabs/general_tab.vue +++ b/src/components/settings_modal/tabs/general_tab.vue @@ -407,6 +407,15 @@ {{ $t('settings.preload_images') }} </BooleanSetting> </li> + <li> + <BooleanSetting + path="useBlurhash" + expert="1" + :disabled="!hideNsfw" + > + {{ $t('settings.use_blurhash') }} + </BooleanSetting> + </li> <li> <BooleanSetting path="useOneClickNsfw" diff --git a/src/i18n/en.json b/src/i18n/en.json index 6fbac399..46b5bad3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -939,6 +939,7 @@ "title": "Version" }, "virtual_scrolling": "Optimize timeline rendering", + "use_blurhash": "Use blurhashes for NSFW thumbnails", "word_filter": "Word filter", "wordfilter": "Wordfilter" }, diff --git a/src/modules/config.js b/src/modules/config.js index 854dae50..ebb27929 100644 --- a/src/modules/config.js +++ b/src/modules/config.js @@ -117,7 +117,8 @@ export const defaultState = { maxDepthInThread: undefined, // instance default translationLanguage: undefined, // instance default, supportedTranslationLanguages: {}, // instance default - userProfileDefaultTab: 'statuses' + userProfileDefaultTab: 'statuses', + useBlurhash: true, } // caching the instance default properties diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index 5d129556..c54ce3e2 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -234,13 +234,14 @@ export const parseAttachment = (data) => { if (masto) { // Not exactly same... output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type - output.meta = data.meta // not present in BE yet + output.meta = data.meta output.id = data.id } else { output.mimetype = data.mimetype // output.meta = ??? missing } + output.blurhash = data.blurhash output.url = data.url output.large_thumb_url = data.preview_url output.description = data.description diff --git a/static/blurhash-overlay.png b/static/blurhash-overlay.png new file mode 100755 index 0000000000000000000000000000000000000000..209a9438ffad2f53209b202de7da5c25153782f2 GIT binary patch literal 27640 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 Z$+kaX?lR29sGW6{;g^js6<>6W_%CA}ZrK0; literal 0 HcmV?d00001 diff --git a/yarn.lock b/yarn.lock index bbceba0b..10533a18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2494,6 +2494,11 @@ binary-extensions@^2.0.0: resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +blurhash@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-2.0.4.tgz#60642a823b50acaaf3732ddb6c7dfd721bdfef2a" + integrity sha512-r/As72u2FbucLoK5NTegM/GucxJc3d8GvHc4ngo13IO/nt2HU4gONxNLq1XPN6EM/V8Y9URIa7PcSz2RZu553A== + body-parser@1.19.2: version "1.19.2" resolved "https://registry.npmjs.org/body-parser/-/body-parser-1.19.2.tgz" From dfba8be1349a633160313a619da167323c2c57f4 Mon Sep 17 00:00:00 2001 From: floatingghost <hannah@coffee-and-dreams.uk> Date: Fri, 30 Dec 2022 05:03:25 +0000 Subject: [PATCH 18/29] Fall back to nsfw image if no blurhash --- src/components/attachment/attachment.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index f9c4f1c8..0ccdb776 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -65,7 +65,7 @@ @click.prevent.stop="toggleHidden" > <Blurhash - v-if="useBlurhash" + v-if="useBlurhash && attachment.blurhash" :height="512" :width="1024" :hash="attachment.blurhash" From e9f16af82d04d6f800053c98baf3fca0df453f4e Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Sun, 1 Jan 2023 20:11:07 +0000 Subject: [PATCH 19/29] Add list of followed hashtags to profile --- .../followed_tag_card/FollowedTagCard.vue | 27 ++++++++++ src/components/user_profile/user_profile.js | 13 ++++- src/components/user_profile/user_profile.vue | 35 ++++++++++-- src/hocs/with_load_more/with_load_more.jsx | 3 +- src/i18n/en.json | 2 + src/modules/users.js | 53 +++++++++++++++++-- src/services/api/api.service.js | 27 +++++++++- 7 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 src/components/followed_tag_card/FollowedTagCard.vue diff --git a/src/components/followed_tag_card/FollowedTagCard.vue b/src/components/followed_tag_card/FollowedTagCard.vue new file mode 100644 index 00000000..3ce5d8e9 --- /dev/null +++ b/src/components/followed_tag_card/FollowedTagCard.vue @@ -0,0 +1,27 @@ +<template> + <div class="followed-tag-card"> + <h3> + <router-link :to="{ name: 'tag-timeline', params: {tag: tag.name}}"> + #{{ tag.name }} + </router-link> + </h3> + </div> +</template> + +<script> +export default { + name: 'FollowedTagCard', + props: { + tag: { + type: Object, + required: true + } + }, +} +</script> + +<style scoped> +.followed-tag-card { + margin-left: 1rem; +} +</style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 8fba1a28..702148c1 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -13,6 +13,7 @@ import { faCircleNotch, faCircleCheck } from '@fortawesome/free-solid-svg-icons' +import FollowedTagCard from '../followed_tag_card/FollowedTagCard.vue' library.add( faCircleNotch, @@ -35,6 +36,14 @@ const FriendList = withLoadMore({ additionalPropNames: ['userId'] })(List) +const FollowedTagList = withLoadMore({ + fetch: (props, $store) => $store.dispatch('fetchFollowedTags', props.userId), + select: (props, $store) => get($store.getters.findUser(props.userId), 'followedTagIds', []).map(id => $store.getters.findTag(id)), + destroy: (props, $store) => $store.dispatch('clearFollowedTags', props.userId), + childPropName: 'items', + additionalPropNames: ['userId'] +})(List) + const isUserPage = ({ name }) => name === 'user-profile' || name === 'external-user-profile' const UserProfile = { @@ -202,6 +211,7 @@ const UserProfile = { } }, components: { + FollowedTagCard, UserCard, Timeline, FollowerList, @@ -209,7 +219,8 @@ const UserProfile = { FollowCard, TabSwitcher, Conversation, - RichContent + RichContent, + FollowedTagList, } } diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index 87bbf679..e657b8a4 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -105,11 +105,36 @@ key="followees" :label="$t('user_card.followees')" > - <FriendList :user-id="userId"> - <template #item="{item}"> - <FollowCard :user="item" /> - </template> - </FriendList> + <tab-switcher + :active-tab="users" + :render-only-focused="true" + > + <div + key="users" + :label="$t('user_card.followed_users')" + > + <FriendList :user-id="userId"> + <template #item="{item}"> + <FollowCard :user="item" /> + </template> + </FriendList> + </div> + <div + key="tags" + v-if="isUs" + :label="$t('user_card.followed_tags')" + > + <FollowedTagList + :user-id="userId" + :following="false" + :get-key="(item) => item.name" + > + <template #item="{item}"> + <FollowedTagCard :tag="item" /> + </template> + </FollowedTagList> + </div> + </tab-switcher> </div> <div v-if="followersTabVisible" diff --git a/src/hocs/with_load_more/with_load_more.jsx b/src/hocs/with_load_more/with_load_more.jsx index c0ae1856..7960663b 100644 --- a/src/hocs/with_load_more/with_load_more.jsx +++ b/src/hocs/with_load_more/with_load_more.jsx @@ -59,7 +59,8 @@ const withLoadMore = ({ this.loading = false this.bottomedOut = isEmpty(newEntries) }) - .catch(() => { + .catch((e) => { + console.error(e) this.loading = false this.error = true }) diff --git a/src/i18n/en.json b/src/i18n/en.json index 46b5bad3..8d9c6444 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1139,6 +1139,8 @@ "follow_unfollow": "Unfollow", "followees": "Following", "followers": "Followers", + "followed_tags": "Followed hashtags", + "followed_users": "Followed users", "following": "Following!", "follows_you": "Follows you!", "hidden": "Hidden", diff --git a/src/modules/users.js b/src/modules/users.js index 022cc1dc..456aa746 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -5,9 +5,9 @@ import { compact, map, each, mergeWith, last, concat, uniq, isArray } from 'loda import { registerPushNotifications, unregisterPushNotifications } from '../services/push/push.js' // TODO: Unify with mergeOrAdd in statuses.js -export const mergeOrAdd = (arr, obj, item) => { +export const mergeOrAdd = (arr, obj, item, key = 'id') => { if (!item) { return false } - const oldItem = obj[item.id] + const oldItem = obj[item[key]] if (oldItem) { // We already have this, so only merge the new info. mergeWith(oldItem, item, mergeArrayLength) @@ -15,7 +15,7 @@ export const mergeOrAdd = (arr, obj, item) => { } else { // This is a new item, prepare it arr.push(item) - obj[item.id] = item + obj[item[key]] = item if (item.screen_name && !item.screen_name.includes('@')) { obj[item.screen_name.toLowerCase()] = item } @@ -157,6 +157,14 @@ export const mutations = { const user = state.usersObject[id] user.followerIds = uniq(concat(user.followerIds || [], followerIds)) }, + saveFollowedTagIds (state, { id, followedTagIds }) { + const user = state.usersObject[id] + user.followedTagIds = uniq(concat(user.followedTagIds || [], followedTagIds)) + }, + saveFollowedTagPagination (state, { id, pagination }) { + const user = state.usersObject[id] + user.followedTagPagination = pagination + }, // Because frontend doesn't have a reason to keep these stuff in memory // outside of viewing someones user profile. clearFriends (state, userId) { @@ -171,6 +179,12 @@ export const mutations = { user['followerIds'] = [] } }, + clearFollowedTags (state, userId) { + const user = state.usersObject[userId] + if (user) { + user['followedTagIds'] = [] + } + }, addNewUsers (state, users) { each(users, (user) => { if (user.relationship) { @@ -179,6 +193,11 @@ export const mutations = { mergeOrAdd(state.users, state.usersObject, user) }) }, + addNewTags (state, tags) { + each(tags, (tag) => { + mergeOrAdd(state.tags, state.tagsObject, tag, 'name') + }) + }, updateUserRelationship (state, relationships) { relationships.forEach((relationship) => { state.relationships[relationship.id] = relationship @@ -271,7 +290,11 @@ export const getters = { relationship: state => id => { const rel = id && state.relationships[id] return rel || { id, loading: true } - } + }, + findTag: state => query => { + const result = state.tagsObject[query] + return result + }, } export const defaultState = { @@ -282,7 +305,9 @@ export const defaultState = { usersObject: {}, signUpPending: false, signUpErrors: [], - relationships: {} + relationships: {}, + tags: [], + tagsObject: {} } const users = { @@ -402,12 +427,27 @@ const users = { return followers }) }, + fetchFollowedTags ({ rootState, commit }, id) { + const user = rootState.users.usersObject[id] + const pagination = user.followedTagPagination + + return rootState.api.backendInteractor.getFollowedHashtags({ pagination }) + .then(({ data: tags, pagination }) => { + commit('addNewTags', tags) + commit('saveFollowedTagIds', { id, followedTagIds: tags.map(tag => tag.name) }) + commit('saveFollowedTagPagination', { id, pagination }) + return tags + }) + }, clearFriends ({ commit }, userId) { commit('clearFriends', userId) }, clearFollowers ({ commit }, userId) { commit('clearFollowers', userId) }, + clearFollowedTags ({ commit }, userId) { + commit('clearFollowedTags', userId) + }, subscribeUser ({ rootState, commit }, id) { return rootState.api.backendInteractor.subscribeUser({ id }) .then((relationship) => commit('updateUserRelationship', [relationship])) @@ -437,6 +477,9 @@ const users = { addNewUsers ({ commit }, users) { commit('addNewUsers', users) }, + addNewTags ({ commit }, tags) { + commit('addNewTags', tags) + }, addNewStatuses (store, { statuses }) { const users = map(statuses, 'user') const retweetedUsers = compact(map(statuses, 'retweeted_status.user')) diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 21c4b25b..b7174b68 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -1,6 +1,7 @@ import { each, map, concat, last, get } from 'lodash' import { parseStatus, parseSource, parseUser, parseNotification, parseReport, parseAttachment, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js' import { RegistrationError, StatusCodeError } from '../errors/errors' +import { Url } from 'url' /* eslint-env browser */ const MUTES_IMPORT_URL = '/api/pleroma/mutes_import' @@ -111,6 +112,7 @@ const AKKOMA_SETTING_PROFILE_LIST = `/api/v1/akkoma/frontend_settings/pleroma-fe const MASTODON_TAG_URL = (name) => `/api/v1/tags/${name}` const MASTODON_FOLLOW_TAG_URL = (name) => `/api/v1/tags/${name}/follow` const MASTODON_UNFOLLOW_TAG_URL = (name) => `/api/v1/tags/${name}/unfollow` +const MASTODON_FOLLOWED_TAGS_URL = '/api/v1/followed_tags' const oldfetch = window.fetch @@ -1575,6 +1577,28 @@ const unfollowHashtag = ({ tag, credentials }) => { }) } +const getFollowedHashtags = ({ credentials, pagination: savedPagination }) => { + const queryParams = new URLSearchParams() + if (savedPagination?.maxId) { + queryParams.append('max_id', savedPagination.maxId) + } + const url = `${MASTODON_FOLLOWED_TAGS_URL}?${queryParams.toString()}` + let pagination = {}; + return fetch(url, { + credentials + }).then((data) => { + pagination = parseLinkHeaderPagination(data.headers.get('Link'), { + flakeId: false + }); + return data.json() + }).then((data) => { + return { + pagination, + data + } + }); +} + export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => { return Object.entries({ ...(credentials @@ -1813,7 +1837,8 @@ const apiService = { deleteNoteFromReport, getHashtag, followHashtag, - unfollowHashtag + unfollowHashtag, + getFollowedHashtags, } export default apiService From 62287fffae456164f52c3ebcaf2062445e549745 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Sun, 1 Jan 2023 21:05:25 +0000 Subject: [PATCH 20/29] add follow/unfollow to followed tags list --- .../followed_tag_card/FollowedTagCard.vue | 64 +++++++++++++++++-- src/components/user_profile/user_profile.js | 4 ++ src/components/user_profile/user_profile.vue | 7 +- src/hocs/with_load_more/with_load_more.jsx | 2 +- src/i18n/en.json | 4 ++ src/modules/tags.js | 21 ++++-- src/modules/users.js | 14 +--- 7 files changed, 86 insertions(+), 30 deletions(-) diff --git a/src/components/followed_tag_card/FollowedTagCard.vue b/src/components/followed_tag_card/FollowedTagCard.vue index 3ce5d8e9..d9394ddc 100644 --- a/src/components/followed_tag_card/FollowedTagCard.vue +++ b/src/components/followed_tag_card/FollowedTagCard.vue @@ -1,10 +1,28 @@ <template> <div class="followed-tag-card"> - <h3> + <span> <router-link :to="{ name: 'tag-timeline', params: {tag: tag.name}}"> - #{{ tag.name }} + <span class="tag-link">#{{ tag.name }}</span> </router-link> - </h3> + <span class="unfollow-tag"> + <button + v-if="isFollowing" + class="button-default unfollow-tag-button" + :title="$t('user_card.unfollow_tag')" + @click="unfollowTag(tag.name)" + > + {{ $t('user_card.unfollow_tag') }} + </button> + <button + v-else + class="button-default follow-tag-button" + :title="$t('user_card.follow_tag')" + @click="followTag(tag.name)" + > + {{ $t('user_card.follow_tag') }} + </button> + </span> + </span> </div> </template> @@ -15,13 +33,45 @@ export default { tag: { type: Object, required: true - } + }, }, + // this is a hack to update the state of the button + // for some reason, List does not update on changes to the tag object + data: () => ({ + isFollowing: true + }), + mounted () { + this.isFollowing = this.tag.following + }, + methods: { + unfollowTag (tag) { + this.$store.dispatch('unfollowTag', tag) + this.isFollowing = false + }, + followTag (tag) { + this.$store.dispatch('followTag', tag) + this.isFollowing = true + } + } } </script> <style scoped> -.followed-tag-card { - margin-left: 1rem; -} + .followed-tag-card { + margin-left: 1rem; + margin-top: 1rem; + margin-bottom: 1rem; + } + .unfollow-tag { + position: absolute; + right: 1rem; + } + + .tag-link { + font-size: large; + } + + .unfollow-tag-button, .follow-tag-button { + font-size: medium; + } </style> diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 702148c1..1cadddda 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -52,6 +52,7 @@ const UserProfile = { error: false, userId: null, tab: 'statuses', + followsTab: 'users', footerRef: null, note: null, noteLoading: false @@ -176,6 +177,9 @@ const UserProfile = { this.tab = tab this.$router.replace({ hash: `#${tab}` }) }, + onFollowsTabSwitch (tab) { + this.followsTab = tab + }, linkClicked ({ target }) { if (target.tagName === 'SPAN') { target = target.parentNode diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue index e657b8a4..5465778a 100644 --- a/src/components/user_profile/user_profile.vue +++ b/src/components/user_profile/user_profile.vue @@ -106,8 +106,9 @@ :label="$t('user_card.followees')" > <tab-switcher - :active-tab="users" + :active-tab="followsTab" :render-only-focused="true" + :on-switch="onFollowsTabSwitch" > <div key="users" @@ -126,12 +127,14 @@ > <FollowedTagList :user-id="userId" - :following="false" :get-key="(item) => item.name" > <template #item="{item}"> <FollowedTagCard :tag="item" /> </template> + <template #empty> + {{ $t('user_card.not_following_any_hashtags')}} + </template> </FollowedTagList> </div> </tab-switcher> diff --git a/src/hocs/with_load_more/with_load_more.jsx b/src/hocs/with_load_more/with_load_more.jsx index 7960663b..c55eccf5 100644 --- a/src/hocs/with_load_more/with_load_more.jsx +++ b/src/hocs/with_load_more/with_load_more.jsx @@ -89,7 +89,7 @@ const withLoadMore = ({ const children = this.$slots return ( <div class="with-load-more"> - <WrappedComponent {...props}> + <WrappedComponent {...props} > {children} </WrappedComponent> <div class="with-load-more-footer"> diff --git a/src/i18n/en.json b/src/i18n/en.json index 8d9c6444..534f1c1c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1057,6 +1057,7 @@ "show_new": "Show new", "socket_broke": "Realtime connection lost: CloseEvent code {0}", "socket_reconnected": "Realtime connection established", + "follow_tag": "Follow hashtag", "unfollow_tag": "Unfollow hashtag", "up_to_date": "Up-to-date" }, @@ -1179,6 +1180,9 @@ "unfollow_confirm_accept_button": "Yes, unfollow", "unfollow_confirm_cancel_button": "No, don't unfollow", "unfollow_confirm_title": "Unfollow user", + "not_following_any_hashtags": "You are not following any hashtags", + "follow_tag": "Follow hashtag", + "unfollow_tag": "Unfollow hashtag", "unmute": "Unmute", "unmute_progress": "Unmuting…", "unsubscribe": "Unsubscribe" diff --git a/src/modules/tags.js b/src/modules/tags.js index cff54b7d..312f7d93 100644 --- a/src/modules/tags.js +++ b/src/modules/tags.js @@ -2,11 +2,18 @@ import { merge } from 'lodash' const tags = { state: { - // Contains key = id, value = number of trackers for this poll + // Contains key = name, value = tag json tags: {} }, + getters: { + findTag: state => query => { + const result = state.tags[query] + return result + }, + }, mutations: { setTag (state, { name, data }) { + console.log("Setting", name, {...data}) state.tags[name] = data } }, @@ -17,17 +24,17 @@ const tags = { return tag }) }, - followTag (store, tagName) { - return store.rootState.api.backendInteractor.followHashtag({ tag: tagName }) + followTag ({ rootState, commit }, tagName) { + return rootState.api.backendInteractor.followHashtag({ tag: tagName }) .then((resp) => { - store.commit('setTag', { name: tagName, data: resp }) + commit('setTag', { name: tagName, data: resp }) return resp }) }, - unfollowTag ({ rootState, commit }, tag) { - return rootState.api.backendInteractor.unfollowHashtag({ tag }) + unfollowTag ({ rootState, commit }, tagName) { + return rootState.api.backendInteractor.unfollowHashtag({ tag: tagName }) .then((resp) => { - commit('setTag', { name: tag, data: resp }) + commit('setTag', { name: tagName, data: resp }) return resp }) } diff --git a/src/modules/users.js b/src/modules/users.js index 456aa746..c63b93de 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -193,11 +193,6 @@ export const mutations = { mergeOrAdd(state.users, state.usersObject, user) }) }, - addNewTags (state, tags) { - each(tags, (tag) => { - mergeOrAdd(state.tags, state.tagsObject, tag, 'name') - }) - }, updateUserRelationship (state, relationships) { relationships.forEach((relationship) => { state.relationships[relationship.id] = relationship @@ -291,10 +286,6 @@ export const getters = { const rel = id && state.relationships[id] return rel || { id, loading: true } }, - findTag: state => query => { - const result = state.tagsObject[query] - return result - }, } export const defaultState = { @@ -433,7 +424,7 @@ const users = { return rootState.api.backendInteractor.getFollowedHashtags({ pagination }) .then(({ data: tags, pagination }) => { - commit('addNewTags', tags) + each(tags, tag => commit('setTag', { name: tag.name, data: tag })) commit('saveFollowedTagIds', { id, followedTagIds: tags.map(tag => tag.name) }) commit('saveFollowedTagPagination', { id, pagination }) return tags @@ -477,9 +468,6 @@ const users = { addNewUsers ({ commit }, users) { commit('addNewUsers', users) }, - addNewTags ({ commit }, tags) { - commit('addNewTags', tags) - }, addNewStatuses (store, { statuses }) { const users = map(statuses, 'user') const retweetedUsers = compact(map(statuses, 'retweeted_status.user')) From d973396c9672fa27beac31bf921073363d800228 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Sun, 1 Jan 2023 21:06:02 +0000 Subject: [PATCH 21/29] Remove console.log --- src/modules/tags.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/modules/tags.js b/src/modules/tags.js index 312f7d93..63f6888d 100644 --- a/src/modules/tags.js +++ b/src/modules/tags.js @@ -13,7 +13,6 @@ const tags = { }, mutations: { setTag (state, { name, data }) { - console.log("Setting", name, {...data}) state.tags[name] = data } }, From f288d0c219beafcf98146056ea3cdc22adeeb15a Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Mon, 2 Jan 2023 15:16:42 +0000 Subject: [PATCH 22/29] Make everything work with a strict CSP --- index.html | 1 + package.json | 2 +- src/App.scss | 3 ++- src/boot/after_store.js | 2 ++ src/components/pinch_zoom/pinch_zoom.js | 2 +- src/services/style_setter/style_setter.js | 4 +--- static/theme-holder.css | 1 + yarn.lock | 14 +++++++------- 8 files changed, 16 insertions(+), 13 deletions(-) create mode 100644 static/theme-holder.css diff --git a/index.html b/index.html index 79613dd2..fda91b0f 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,7 @@ <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--> <link rel="icon" type="image/png" href="/favicon.png"> <link rel="manifest" href="/manifest.json"> diff --git a/package.json b/package.json index 297a9379..efafb67b 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.2", "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/vue-fontawesome": "3.0.1", - "@kazvmoe-infra/pinch-zoom-element": "1.2.0", + "@floatingghost/pinch-zoom-element": "^1.3.1", "@vuelidate/core": "^2.0.0", "@vuelidate/validators": "^2.0.0", "blurhash": "^2.0.4", diff --git a/src/App.scss b/src/App.scss index 7e6d0dfc..38574cab 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,6 +1,7 @@ // stylelint-disable rscss/class-format @import './_variables.scss'; - +@import '@fortawesome/fontawesome-svg-core/styles.css'; +@import '@floatingghost/pinch-zoom-element/dist/pinch-zoom.css'; :root { --navbar-height: 3.5rem; --post-line-height: 1.4; diff --git a/src/boot/after_store.js b/src/boot/after_store.js index 4bafca1d..36b087a5 100644 --- a/src/boot/after_store.js +++ b/src/boot/after_store.js @@ -4,6 +4,8 @@ import { createRouter, createWebHistory } from 'vue-router' import vClickOutside from 'click-outside-vue3' import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome' +import { config } from '@fortawesome/fontawesome-svg-core'; +config.autoAddCss = false import App from '../App.vue' import routes from './routes' diff --git a/src/components/pinch_zoom/pinch_zoom.js b/src/components/pinch_zoom/pinch_zoom.js index 82670ddf..b7e8f673 100644 --- a/src/components/pinch_zoom/pinch_zoom.js +++ b/src/components/pinch_zoom/pinch_zoom.js @@ -1,4 +1,4 @@ -import PinchZoom from '@kazvmoe-infra/pinch-zoom-element' +import PinchZoom from '@floatingghost/pinch-zoom-element' export default { methods: { diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js index d5bf8749..9e691261 100644 --- a/src/services/style_setter/style_setter.js +++ b/src/services/style_setter/style_setter.js @@ -4,12 +4,10 @@ import { getColors, computeDynamicColor, getOpacitySlot } from '../theme_data/th export const applyTheme = (input) => { const { rules } = generatePreset(input) - const head = document.head const body = document.body body.classList.add('hidden') - const styleEl = document.createElement('style') - head.appendChild(styleEl) + const styleEl = document.getElementById('theme-holder') const styleSheet = styleEl.sheet styleSheet.toString() diff --git a/static/theme-holder.css b/static/theme-holder.css new file mode 100644 index 00000000..3e884036 --- /dev/null +++ b/static/theme-holder.css @@ -0,0 +1 @@ +// This file intentionally left blank diff --git a/yarn.lock b/yarn.lock index 10533a18..ebe66732 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1350,6 +1350,13 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@floatingghost/pinch-zoom-element@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@floatingghost/pinch-zoom-element/-/pinch-zoom-element-1.3.1.tgz#5f327ad17ddf1f56777098aca088fdbf99cbd049" + integrity sha512-KnE7aBQdd/Fj1TzU5uzgwD9YAQ58DTMUks/PoTEBFW4zi0lBM9cN/j45wzcnzsT2VXG1S6qM7NMmq7NGm2//Fg== + dependencies: + pointer-tracker "^2.0.3" + "@fortawesome/fontawesome-common-types@6.2.0": version "6.2.0" resolved "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz" @@ -1516,13 +1523,6 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@kazvmoe-infra/pinch-zoom-element@1.2.0": - version "1.2.0" - resolved "https://registry.npmjs.org/@kazvmoe-infra/pinch-zoom-element/-/pinch-zoom-element-1.2.0.tgz" - integrity sha512-HBrhH5O/Fsp2bB7EGTXzCsBAVcMjknSagKC5pBdGpKsF8meHISR0kjDIdw4YoE0S+0oNMwJ6ZUZyIBrdywxPPw== - dependencies: - pointer-tracker "^2.0.3" - "@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1": version "5.1.1-v1" resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz#dbf733a965ca47b1973177dc0bb6c889edcfb129" From e9f47509ae99b14a0392c906d186b5a45feaadbf Mon Sep 17 00:00:00 2001 From: Sol Fisher Romanoff <sol@solfisher.com> Date: Tue, 3 Jan 2023 16:04:26 +0200 Subject: [PATCH 23/29] Only show "keep open" emoji checkbox on post form --- src/components/emoji_input/emoji_input.vue | 1 + src/components/emoji_picker/emoji_picker.js | 5 +++++ src/components/emoji_picker/emoji_picker.vue | 5 ++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index 078253c2..d4760edc 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -18,6 +18,7 @@ <EmojiPicker v-if="enableEmojiPicker" ref="picker" + show-keep-open :class="{ hide: !showPicker }" :enable-sticker-picker="enableStickerPicker" class="emoji-picker-panel" diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index 6617a937..fbd59946 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -27,6 +27,11 @@ const EmojiPicker = { required: false, type: Boolean, default: false + }, + showKeepOpen: { + required: false, + type: Boolean, + default: false } }, data () { diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index 00ffb9d2..3ec694f0 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -84,7 +84,10 @@ <span :ref="'group-end-' + group.id" /> </div> </div> - <div class="keep-open"> + <div + v-if="showKeepOpen" + class="keep-open" + > <Checkbox v-model="keepOpen"> {{ $t('emoji.keep_open') }} </Checkbox> From 42dc1a027ad826a32b0e79dcceba7196d5b3acc6 Mon Sep 17 00:00:00 2001 From: FloatingGhost <hannah@coffee-and-dreams.uk> Date: Sun, 15 Jan 2023 17:59:32 +0000 Subject: [PATCH 24/29] add language input --- package.json | 4 ++-- src/components/extra_buttons/extra_buttons.js | 1 + .../post_status_form/post_status_form.js | 14 ++++++++++++-- .../post_status_form/post_status_form.vue | 17 +++++++++++++++++ src/services/api/api.service.js | 4 +++- .../status_poster/status_poster.service.js | 6 ++++-- yarn.lock | 4 ++-- 7 files changed, 41 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index efafb67b..4bb94fcd 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,11 @@ "dependencies": { "@babel/runtime": "7.17.8", "@chenfengyuan/vue-qrcode": "2.0.0", + "@floatingghost/pinch-zoom-element": "^1.3.1", "@fortawesome/fontawesome-svg-core": "1.3.0", "@fortawesome/free-regular-svg-icons": "^6.1.2", "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/vue-fontawesome": "3.0.1", - "@floatingghost/pinch-zoom-element": "^1.3.1", "@vuelidate/core": "^2.0.0", "@vuelidate/validators": "^2.0.0", "blurhash": "^2.0.4", @@ -32,6 +32,7 @@ "cropperjs": "1.5.12", "diff": "3.5.0", "escape-html": "1.0.3", + "iso-639-1": "^2.1.15", "js-cookie": "^3.0.1", "localforage": "1.10.0", "parse-link-header": "^2.0.0", @@ -83,7 +84,6 @@ "html-webpack-plugin": "^5.5.0", "http-proxy-middleware": "0.21.0", "inject-loader": "2.0.1", - "iso-639-1": "2.1.15", "isparta-loader": "2.0.0", "json-loader": "0.5.7", "karma": "6.3.17", diff --git a/src/components/extra_buttons/extra_buttons.js b/src/components/extra_buttons/extra_buttons.js index 4bc6144c..5eb98264 100644 --- a/src/components/extra_buttons/extra_buttons.js +++ b/src/components/extra_buttons/extra_buttons.js @@ -144,6 +144,7 @@ const ExtraButtons = { statusPoll: this.status.poll, statusFiles: [...this.status.attachments], statusScope: this.status.visibility, + statusLanguage: this.status.language, statusContentType: data.content_type })) this.doDeleteStatus() diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js index c9f492c3..b7c66fc7 100644 --- a/src/components/post_status_form/post_status_form.js +++ b/src/components/post_status_form/post_status_form.js @@ -13,6 +13,7 @@ import suggestor from '../emoji_input/suggestor.js' import { mapGetters, mapState } from 'vuex' import Checkbox from '../checkbox/checkbox.vue' import Select from '../select/select.vue' +import iso6391 from 'iso-639-1' import { library } from '@fortawesome/fontawesome-svg-core' import { @@ -63,6 +64,7 @@ const PostStatusForm = { 'statusMediaDescriptions', 'statusScope', 'statusContentType', + 'statusLanguage', 'replyTo', 'quoteId', 'repliedUser', @@ -128,7 +130,7 @@ const PostStatusForm = { statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser) } - const { postContentType: contentType, sensitiveByDefault, sensitiveIfSubject } = this.$store.getters.mergedConfig + const { postContentType: contentType, sensitiveByDefault, sensitiveIfSubject, interfaceLanguage } = this.$store.getters.mergedConfig let statusParams = { spoilerText: this.subject || '', @@ -139,6 +141,7 @@ const PostStatusForm = { poll: {}, mediaDescriptions: {}, visibility: this.suggestedVisibility(), + language: interfaceLanguage, contentType } @@ -153,6 +156,7 @@ const PostStatusForm = { poll: this.statusPoll || {}, mediaDescriptions: this.statusMediaDescriptions || {}, visibility: this.statusScope || this.suggestedVisibility(), + language: this.statusLanguage || interfaceLanguage, contentType: statusContentType } } @@ -259,7 +263,10 @@ const PostStatusForm = { ...mapGetters(['mergedConfig']), ...mapState({ mobileLayout: state => state.interface.mobileLayout - }) + }), + isoLanguages () { + return iso6391.getAllCodes(); + } }, watch: { 'newStatus': { @@ -282,6 +289,7 @@ const PostStatusForm = { files: [], visibility: newStatus.visibility, contentType: newStatus.contentType, + language: newStatus.language, poll: {}, mediaDescriptions: {} } @@ -341,6 +349,7 @@ const PostStatusForm = { inReplyToStatusId: this.replyTo, quoteId: this.quoteId, contentType: newStatus.contentType, + language: newStatus.language, poll, idempotencyKey: this.idempotencyKey } @@ -375,6 +384,7 @@ const PostStatusForm = { inReplyToStatusId: this.replyTo, quoteId: this.quoteId, contentType: newStatus.contentType, + language: newStatus.language, poll: {}, preview: true }).then((data) => { diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue index b6516585..02468f17 100644 --- a/src/components/post_status_form/post_status_form.vue +++ b/src/components/post_status_form/post_status_form.vue @@ -194,6 +194,23 @@ :on-scope-change="changeVis" /> + <div + class="language-selector" + > + <Select + id="post-language" + v-model="newStatus.language" + class="form-control" + > + <option + v-for="language in isoLanguages" + :key="language" + :value="language" + > + {{ language }} + </option> + </Select> + </div> <div v-if="postFormats.length > 1" class="text-format" diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index b7174b68..0f8b75a4 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -880,7 +880,8 @@ const postStatus = ({ quoteId, contentType, preview, - idempotencyKey + idempotencyKey, + language }) => { const form = new FormData() const pollOptions = poll.options || [] @@ -891,6 +892,7 @@ const postStatus = ({ if (visibility) form.append('visibility', visibility) if (sensitive) form.append('sensitive', sensitive) if (contentType) form.append('content_type', contentType) + if (language) form.append('language', language) mediaIds.forEach(val => { form.append('media_ids[]', val) }) diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js index aaef5a7a..0dbedf07 100644 --- a/src/services/status_poster/status_poster.service.js +++ b/src/services/status_poster/status_poster.service.js @@ -13,7 +13,8 @@ const postStatus = ({ quoteId = undefined, contentType = 'text/plain', preview = false, - idempotencyKey = '' + idempotencyKey = '', + language }) => { const mediaIds = map(media, 'id') @@ -29,7 +30,8 @@ const postStatus = ({ contentType, poll, preview, - idempotencyKey + idempotencyKey, + language }) .then((data) => { if (!data.error && !preview) { diff --git a/yarn.lock b/yarn.lock index ebe66732..7027b7d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5095,9 +5095,9 @@ isexe@^2.0.0: resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== -iso-639-1@2.1.15: +iso-639-1@^2.1.15: version "2.1.15" - resolved "https://registry.npmjs.org/iso-639-1/-/iso-639-1-2.1.15.tgz" + resolved "https://registry.yarnpkg.com/iso-639-1/-/iso-639-1-2.1.15.tgz#20cf78a4f691aeb802c16f17a6bad7d99271e85d" integrity sha512-7c7mBznZu2ktfvyT582E2msM+Udc1EjOyhVRE/0ZsjD9LBtWSm23h3PtiRh2a35XoUsTQQjJXaJzuLjXsOdFDg== isobject@^3.0.1: From a9a95e9120292f428a2e6abbff2cfffd0f427fb8 Mon Sep 17 00:00:00 2001 From: eris <femmediscord@gmail.com> Date: Thu, 26 Jan 2023 20:49:07 +0000 Subject: [PATCH 25/29] Add indicator if user blocks you --- src/components/user_card/user_card.scss | 2 +- src/components/user_card/user_card.vue | 6 ++++++ src/i18n/en.json | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss index 2cae1c35..0a5e744e 100644 --- a/src/components/user_card/user_card.scss +++ b/src/components/user_card/user_card.scss @@ -235,7 +235,7 @@ line-height: 22px; flex-wrap: wrap; - .following, .requested_by { + .following, .requested_by, .blocking { flex: 1 0 auto; margin: 0; margin-bottom: .25em; diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 2eefbad8..6a9d093d 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -127,6 +127,12 @@ </div> </div> <div class="user-meta"> + <div + v-if="relationship.blocked_by && loggedIn && isOtherUser" + class="blocking" + > + {{ $t('user_card.blocks_you') }} + </div> <div v-if="relationship.followed_by && loggedIn && isOtherUser" class="following" diff --git a/src/i18n/en.json b/src/i18n/en.json index 534f1c1c..46890345 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1123,6 +1123,7 @@ "block_confirm_title": "Block user", "block_progress": "Blocking…", "blocked": "Blocked!", + "blocks_you": "Blocks you!", "bot": "Bot", "deactivated": "Deactivated", "deny": "Deny", From 7f4dd9ff037a98192c546426e05492a98c536d51 Mon Sep 17 00:00:00 2001 From: eris <femmediscord@gmail.com> Date: Fri, 27 Jan 2023 00:26:50 +0000 Subject: [PATCH 26/29] Disable follow button if blocked by user --- src/components/user_card/user_card.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue index 6a9d093d..289db15b 100644 --- a/src/components/user_card/user_card.vue +++ b/src/components/user_card/user_card.vue @@ -193,6 +193,7 @@ <FollowButton :relationship="relationship" :user="user" + :disabled="relationship.blocked_by" /> <template v-if="relationship.following"> <ProgressButton From 88d5149db5948e19dbe66406d00e0e5167237a4f Mon Sep 17 00:00:00 2001 From: floatingghost <hannah@coffee-and-dreams.uk> Date: Sat, 4 Feb 2023 21:09:09 +0000 Subject: [PATCH 27/29] paginate-follow-requests (#277) Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk> Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma-fe/pulls/277 --- .../follow_request_card.js | 7 ++++ .../follow_request_card.vue | 2 +- .../follow_requests/follow_requests.js | 18 +++++++- .../follow_requests/follow_requests.vue | 11 +++-- src/components/nav_panel/nav_panel.js | 11 ++--- src/components/user_profile/user_profile.js | 2 +- src/modules/api.js | 41 +++++++++++-------- src/modules/users.js | 13 +++++- src/services/api/api.service.js | 30 ++++++++++---- .../backend_interactor_service.js | 5 --- .../entity_normalizer.service.js | 1 + .../follow_request_fetcher.service.js | 23 ----------- 12 files changed, 92 insertions(+), 72 deletions(-) delete mode 100644 src/services/follow_request_fetcher/follow_request_fetcher.service.js diff --git a/src/components/follow_request_card/follow_request_card.js b/src/components/follow_request_card/follow_request_card.js index b0873bb1..47c86e15 100644 --- a/src/components/follow_request_card/follow_request_card.js +++ b/src/components/follow_request_card/follow_request_card.js @@ -43,6 +43,7 @@ const FollowRequestCard = { doApprove () { this.$store.state.api.backendInteractor.approveUser({ id: this.user.id }) this.$store.dispatch('removeFollowRequest', this.user) + this.$store.dispatch('decrementFollowRequestsCount') const notifId = this.findFollowRequestNotificationId() this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId }) @@ -66,6 +67,7 @@ const FollowRequestCard = { this.$store.state.api.backendInteractor.denyUser({ id: this.user.id }) .then(() => { this.$store.dispatch('dismissNotificationLocal', { id: notifId }) + this.$store.dispatch('decrementFollowRequestsCount') this.$store.dispatch('removeFollowRequest', this.user) }) this.hideDenyConfirmDialog() @@ -80,6 +82,11 @@ const FollowRequestCard = { }, shouldConfirmDeny () { return this.mergedConfig.modalOnDenyFollow + }, + show () { + const notifId = this.$store.state.api.followRequests.find(req => req.id === this.user.id) + + return notifId !== undefined } } } diff --git a/src/components/follow_request_card/follow_request_card.vue b/src/components/follow_request_card/follow_request_card.vue index 835471e7..80445021 100644 --- a/src/components/follow_request_card/follow_request_card.vue +++ b/src/components/follow_request_card/follow_request_card.vue @@ -1,5 +1,5 @@ <template> - <basic-user-card :user="user"> + <basic-user-card :user="user" v-if="show"> <div class="follow-request-card-content-container"> <button class="btn button-default" diff --git a/src/components/follow_requests/follow_requests.js b/src/components/follow_requests/follow_requests.js index 704a76c6..e5f05643 100644 --- a/src/components/follow_requests/follow_requests.js +++ b/src/components/follow_requests/follow_requests.js @@ -1,10 +1,26 @@ import FollowRequestCard from '../follow_request_card/follow_request_card.vue' +import withLoadMore from '../../hocs/with_load_more/with_load_more' +import List from '../list/list.vue' +import get from 'lodash/get' + +const FollowRequestList = withLoadMore({ + fetch: (props, $store) => $store.dispatch('fetchFollowRequests'), + select: (props, $store) => get($store.state.api, 'followRequests', []).map(req => $store.getters.findUser(req.id)), + destroy: (props, $store) => $store.dispatch('clearFollowRequests'), + childPropName: 'items', + additionalPropNames: ['userId'] +})(List); + const FollowRequests = { components: { - FollowRequestCard + FollowRequestCard, + FollowRequestList }, computed: { + userId () { + return this.$store.state.users.currentUser.id + }, requests () { return this.$store.state.api.followRequests } diff --git a/src/components/follow_requests/follow_requests.vue b/src/components/follow_requests/follow_requests.vue index 41f19db8..c3098292 100644 --- a/src/components/follow_requests/follow_requests.vue +++ b/src/components/follow_requests/follow_requests.vue @@ -6,12 +6,11 @@ </div> </div> <div class="panel-body"> - <FollowRequestCard - v-for="request in requests" - :key="request.id" - :user="request" - class="list-item" - /> + <FollowRequestList :user-id="userId"> + <template #item="{item}"> + <FollowRequestCard :user="item" /> + </template> + </FollowRequestList> </div> </div> </template> diff --git a/src/components/nav_panel/nav_panel.js b/src/components/nav_panel/nav_panel.js index af165d47..2eda912e 100644 --- a/src/components/nav_panel/nav_panel.js +++ b/src/components/nav_panel/nav_panel.js @@ -33,11 +33,6 @@ library.add( ) const NavPanel = { - created () { - if (this.currentUser && this.currentUser.locked) { - this.$store.dispatch('startFetchingFollowRequests') - } - }, components: { TimelineMenuContent }, @@ -54,11 +49,13 @@ const NavPanel = { computed: { ...mapState({ currentUser: state => state.users.currentUser, - followRequestCount: state => state.api.followRequests.length, privateMode: state => state.instance.private, federating: state => state.instance.federating }), - ...mapGetters(['unreadAnnouncementCount']) + ...mapGetters(['unreadAnnouncementCount']), + followRequestCount () { + return this.$store.state.users.currentUser.follow_requests_count + } } } diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 1cadddda..9ea8c2a7 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -224,7 +224,7 @@ const UserProfile = { TabSwitcher, Conversation, RichContent, - FollowedTagList, + FollowedTagList } } diff --git a/src/modules/api.js b/src/modules/api.js index c54aa4fb..8de1449b 100644 --- a/src/modules/api.js +++ b/src/modules/api.js @@ -1,5 +1,6 @@ import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import { WSConnectionStatus } from '../services/api/api.service.js' +import { map } from 'lodash' const retryTimeout = (multiplier) => 1000 * multiplier @@ -40,9 +41,6 @@ const api = { setSocket (state, socket) { state.socket = socket }, - setFollowRequests (state, value) { - state.followRequests = value - }, setMastoUserSocketStatus (state, value) { state.mastoUserSocketStatus = value }, @@ -51,6 +49,15 @@ const api = { }, resetRetryMultiplier (state) { state.retryMultiplier = 1 + }, + setFollowRequests (state, value) { + state.followRequests = [...value] + }, + saveFollowRequests (state, requests) { + state.followRequests = [...state.followRequests, ...requests] + }, + saveFollowRequestPagination (state, pagination) { + state.followRequestsPagination = pagination } }, actions: { @@ -240,24 +247,22 @@ const api = { ...rest }) }, - - // Follow requests - startFetchingFollowRequests (store) { - if (store.state.fetchers['followRequests']) return - const fetcher = store.state.backendInteractor.startFetchingFollowRequests({ store }) - - store.commit('addFetcher', { fetcherName: 'followRequests', fetcher }) - }, - stopFetchingFollowRequests (store) { - const fetcher = store.state.fetchers.followRequests - if (!fetcher) return - store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher }) - }, removeFollowRequest (store, request) { - let requests = store.state.followRequests.filter((it) => it !== request) + let requests = [...store.state.followRequests].filter((it) => it.id !== request.id) store.commit('setFollowRequests', requests) }, - + fetchFollowRequests ({ rootState, commit }) { + const pagination = rootState.api.followRequestsPagination + return rootState.api.backendInteractor.getFollowRequests({ pagination }) + .then((requests) => { + if (requests.data.length > 0) { + commit('addNewUsers', requests.data) + commit('saveFollowRequests', requests.data) + commit('saveFollowRequestPagination', requests.pagination) + } + return requests + }) + }, // Lists startFetchingLists (store) { if (store.state.fetchers['lists']) return diff --git a/src/modules/users.js b/src/modules/users.js index c63b93de..bc1943c8 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -265,6 +265,12 @@ export const mutations = { signUpFailure (state, errors) { state.signUpPending = false state.signUpErrors = errors + }, + decrementFollowRequestsCount (store) { + store.currentUser.follow_requests_count-- + }, + incrementFollowRequestsCount (store) { + store.currentUser.follow_requests_count++ } } @@ -504,6 +510,12 @@ const users = { store.commit('setUserForNotification', notification) }) }, + decrementFollowRequestsCount (store) { + store.commit('decrementFollowRequestsCount') + }, + incrementFollowRequestsCount (store) { + store.commit('incrementFollowRequestsCount') + }, searchUsers ({ rootState, commit }, { query }) { return rootState.api.backendInteractor.searchUsers({ query }) .then((users) => { @@ -567,7 +579,6 @@ const users = { store.dispatch('stopFetchingTimeline', 'friends') store.commit('setBackendInteractor', backendInteractorService(store.getters.getToken())) store.dispatch('stopFetchingNotifications') - store.dispatch('stopFetchingFollowRequests') store.dispatch('stopFetchingConfig') store.commit('clearNotifications') store.commit('resetStatuses') diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js index 0f8b75a4..947e9da9 100644 --- a/src/services/api/api.service.js +++ b/src/services/api/api.service.js @@ -406,14 +406,6 @@ const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => { .then((data) => data.json()) .then((data) => data.map(parseUser)) } - -const fetchFollowRequests = ({ credentials }) => { - const url = MASTODON_FOLLOW_REQUESTS_URL - return fetch(url, { headers: authHeaders(credentials) }) - .then((data) => data.json()) - .then((data) => data.map(parseUser)) -} - const fetchLists = ({ credentials }) => { const url = MASTODON_LISTS_URL return fetch(url, { headers: authHeaders(credentials) }) @@ -1601,6 +1593,26 @@ const getFollowedHashtags = ({ credentials, pagination: savedPagination }) => { }); } +const getFollowRequests = ({ credentials, pagination: savedPagination }) => { + const queryParams = new URLSearchParams() + if (savedPagination?.maxId) { + queryParams.append('max_id', savedPagination.maxId) + } + const url = `${MASTODON_FOLLOW_REQUESTS_URL}?${queryParams.toString()}` + let pagination = {}; + return fetch(url, { + credentials + }).then((data) => { + pagination = parseLinkHeaderPagination(data.headers.get('Link'), { flakeId: true }); + return data.json() + }).then((data) => { + return { + pagination, + data: data.map(parseUser) + } + }); +} + export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => { return Object.entries({ ...(credentials @@ -1790,7 +1802,6 @@ const apiService = { mfaConfirmOTP, addBackup, listBackups, - fetchFollowRequests, fetchLists, createList, getList, @@ -1841,6 +1852,7 @@ const apiService = { followHashtag, unfollowHashtag, getFollowedHashtags, + getFollowRequests } export default apiService diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js index 4d6f80c2..58515387 100644 --- a/src/services/backend_interactor_service/backend_interactor_service.js +++ b/src/services/backend_interactor_service/backend_interactor_service.js @@ -1,7 +1,6 @@ import apiService, { getMastodonSocketURI, ProcessedWS } from '../api/api.service.js' import timelineFetcher from '../timeline_fetcher/timeline_fetcher.service.js' import notificationsFetcher from '../notifications_fetcher/notifications_fetcher.service.js' -import followRequestFetcher from '../../services/follow_request_fetcher/follow_request_fetcher.service' import listsFetcher from '../../services/lists_fetcher/lists_fetcher.service.js' import announcementsFetcher from '../../services/announcements_fetcher/announcements_fetcher.service.js' import configFetcher from '../config_fetcher/config_fetcher.service.js' @@ -28,10 +27,6 @@ const backendInteractorService = credentials => ({ return notificationsFetcher.fetchAndUpdate({ ...args, credentials }) }, - startFetchingFollowRequests ({ store }) { - return followRequestFetcher.startFetching({ store, credentials }) - }, - startFetchingLists ({ store }) { return listsFetcher.startFetching({ store, credentials }) }, diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js index c54ce3e2..e330ca8c 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -90,6 +90,7 @@ export const parseUser = (data) => { output.friends_count = data.following_count output.bot = data.bot + output.follow_requests_count = data.follow_requests_count if (data.akkoma) { output.instance = data.akkoma.instance output.status_ttl_days = data.akkoma.status_ttl_days diff --git a/src/services/follow_request_fetcher/follow_request_fetcher.service.js b/src/services/follow_request_fetcher/follow_request_fetcher.service.js deleted file mode 100644 index 5c0ab85e..00000000 --- a/src/services/follow_request_fetcher/follow_request_fetcher.service.js +++ /dev/null @@ -1,23 +0,0 @@ -import apiService from '../api/api.service.js' -import { promiseInterval } from '../promise_interval/promise_interval.js' - -const fetchAndUpdate = ({ store, credentials }) => { - return apiService.fetchFollowRequests({ credentials }) - .then((requests) => { - store.commit('setFollowRequests', requests) - store.commit('addNewUsers', requests) - }, () => {}) - .catch(() => {}) -} - -const startFetching = ({ credentials, store }) => { - const boundFetchAndUpdate = () => fetchAndUpdate({ credentials, store }) - boundFetchAndUpdate() - return promiseInterval(boundFetchAndUpdate, 240000) -} - -const followRequestFetcher = { - startFetching -} - -export default followRequestFetcher From 9e04e4fd80c9396897df26e50261e41b25a15ffc Mon Sep 17 00:00:00 2001 From: yanchan09 <yan@omg.lol> Date: Sat, 4 Feb 2023 21:10:06 +0000 Subject: [PATCH 28/29] Improve emoji picker performance (#275) A simple virtual scroller is now used for the emoji grid. This avoids loading all emoji images at once, saving network bandwidth and reducing load on the server, while also putting less work on the browser's DOM and layout engine. Co-authored-by: yan <yan@omg.lol> Reviewed-on: https://akkoma.dev/AkkomaGang/akkoma-fe/pulls/275 Co-authored-by: yanchan09 <yan@omg.lol> Co-committed-by: yanchan09 <yan@omg.lol> --- src/components/emoji_grid/emoji_grid.js | 133 ++++++++++++++++++ src/components/emoji_grid/emoji_grid.scss | 60 ++++++++ src/components/emoji_grid/emoji_grid.vue | 48 +++++++ src/components/emoji_input/emoji_input.js | 2 - src/components/emoji_picker/emoji_picker.js | 97 ++----------- src/components/emoji_picker/emoji_picker.scss | 78 +--------- src/components/emoji_picker/emoji_picker.vue | 41 +----- 7 files changed, 261 insertions(+), 198 deletions(-) create mode 100644 src/components/emoji_grid/emoji_grid.js create mode 100644 src/components/emoji_grid/emoji_grid.scss create mode 100644 src/components/emoji_grid/emoji_grid.vue diff --git a/src/components/emoji_grid/emoji_grid.js b/src/components/emoji_grid/emoji_grid.js new file mode 100644 index 00000000..f73b0913 --- /dev/null +++ b/src/components/emoji_grid/emoji_grid.js @@ -0,0 +1,133 @@ +const EMOJI_SIZE = 32 + 8 +const GROUP_TITLE_HEIGHT = 24 +const BUFFER_SIZE = 3 * EMOJI_SIZE + +const EmojiGrid = { + props: { + groups: { + required: true, + type: Array + } + }, + data () { + return { + containerWidth: 0, + containerHeight: 0, + scrollPos: 0, + resizeObserver: null + } + }, + mounted () { + const rect = this.$refs.container.getBoundingClientRect() + this.containerWidth = rect.width + this.containerHeight = rect.height + this.resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + this.containerWidth = entry.contentRect.width + this.containerHeight = entry.contentRect.height + } + }) + this.resizeObserver.observe(this.$refs.container) + }, + beforeUnmount () { + this.resizeObserver.disconnect() + this.resizeObserver = null + }, + watch: { + groups () { + // Scroll to top when grid content changes + if (this.$refs.container) { + this.$refs.container.scrollTo(0, 0) + } + }, + activeGroup (group) { + this.$emit('activeGroup', group) + } + }, + methods: { + onScroll () { + this.scrollPos = this.$refs.container.scrollTop + }, + onEmoji (emoji) { + this.$emit('emoji', emoji) + }, + scrollToItem (itemId) { + const container = this.$refs.container + if (!container) return + + for (const item of this.itemList) { + if (item.id === itemId) { + container.scrollTo(0, item.position.y) + return + } + } + } + }, + computed: { + // Total height of scroller content + gridHeight () { + if (this.itemList.length === 0) return 0 + const lastItem = this.itemList[this.itemList.length - 1] + return ( + lastItem.position.y + + ('title' in lastItem ? GROUP_TITLE_HEIGHT : EMOJI_SIZE) + ) + }, + activeGroup () { + const items = this.itemList + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i] + if ('title' in item && item.position.y <= this.scrollPos) { + return item.id + } + } + return null + }, + itemList () { + const items = [] + let x = 0 + let y = 0 + for (const group of this.groups) { + items.push({ position: { x, y }, id: group.id, title: group.text }) + if (group.text.length) { + y += GROUP_TITLE_HEIGHT + } + for (const emoji of group.emojis) { + items.push({ + position: { x, y }, + id: `${group.id}-${emoji.displayText}`, + emoji + }) + x += EMOJI_SIZE + if (x + EMOJI_SIZE > this.containerWidth) { + y += EMOJI_SIZE + x = 0 + } + } + if (x > 0) { + y += EMOJI_SIZE + x = 0 + } + } + return items + }, + visibleItems () { + const startPos = this.scrollPos - BUFFER_SIZE + const endPos = this.scrollPos + this.containerHeight + BUFFER_SIZE + return this.itemList.filter((i) => { + return i.position.y >= startPos && i.position.y < endPos + }) + }, + scrolledClass () { + if (this.scrollPos <= 5) { + return 'scrolled-top' + } else if (this.scrollPos >= this.gridHeight - this.containerHeight - 5) { + return 'scrolled-bottom' + } else { + return 'scrolled-middle' + } + } + } +} + +export default EmojiGrid diff --git a/src/components/emoji_grid/emoji_grid.scss b/src/components/emoji_grid/emoji_grid.scss new file mode 100644 index 00000000..5d5b153f --- /dev/null +++ b/src/components/emoji_grid/emoji_grid.scss @@ -0,0 +1,60 @@ +.emoji { + &-grid { + flex: 1 1 1px; + position: relative; + overflow: auto; + user-select: none; + mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, + linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, + linear-gradient(to top, white, white); + transition: mask-size 150ms; + mask-size: 100% 20px, 100% 20px, auto; + // Autoprefixed seem to ignore this one, and also syntax is different + -webkit-mask-composite: xor; + mask-composite: exclude; + &.scrolled { + &-top { + mask-size: 100% 20px, 100% 0, auto; + } + &-bottom { + mask-size: 100% 0, 100% 20px, auto; + } + } + margin-left: 5px; + min-height: 200px; + } + + &-group-title { + position: absolute; + font-size: 0.85em; + width: 100%; + margin: 0; + height: 24px; + display: flex; + align-items: end; + + &.disabled { + display: none; + } + } + + &-item { + position: absolute; + width: 32px; + height: 32px; + box-sizing: border-box; + display: flex; + font-size: 32px; + align-items: center; + justify-content: center; + margin: 4px; + + cursor: pointer; + + img { + object-fit: contain; + max-width: 100%; + max-height: 100%; + } + } +} \ No newline at end of file diff --git a/src/components/emoji_grid/emoji_grid.vue b/src/components/emoji_grid/emoji_grid.vue new file mode 100644 index 00000000..94732319 --- /dev/null +++ b/src/components/emoji_grid/emoji_grid.vue @@ -0,0 +1,48 @@ +<template> + <div + ref="container" + class="emoji-grid" + :class="scrolledClass" + @scroll.passive="onScroll" + > + <div + :style="{ + height: `${gridHeight}px`, + }" + > + <template v-for="item in visibleItems"> + <h6 + v-if="'title' in item && item.title.length" + :key="'title-' + item.id" + class="emoji-group-title" + :style="{ + top: item.position.y + 'px', + left: item.position.x + 'px' + }" + > + {{ item.title }} + </h6> + <span + v-else-if="'emoji' in item" + :key="'emoji-' + item.id" + class="emoji-item" + :title="item.emoji.displayText" + :style="{ + top: item.position.y + 'px', + left: item.position.x + 'px' + }" + @click.stop.prevent="onEmoji(item.emoji)" + > + <span v-if="!item.emoji.imageUrl">{{ item.emoji.replacement }}</span> + <img + v-else + :src="item.emoji.imageUrl" + > + </span> + </template> + </div> + </div> +</template> + +<script src="./emoji_grid.js"></script> +<style lang="scss" src="./emoji_grid.scss"></style> diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index 846274b8..138a5b51 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -205,7 +205,6 @@ const EmojiInput = { }, triggerShowPicker () { this.showPicker = true - this.$refs.picker.startEmojiLoad() this.$nextTick(() => { this.scrollIntoView() this.focusPickerInput() @@ -223,7 +222,6 @@ const EmojiInput = { this.showPicker = !this.showPicker if (this.showPicker) { this.scrollIntoView() - this.$refs.picker.startEmojiLoad() this.$nextTick(this.focusPickerInput) } }, diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index fbd59946..76934e53 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -1,5 +1,6 @@ import { defineAsyncComponent } from 'vue' import Checkbox from '../checkbox/checkbox.vue' +import EmojiGrid from '../emoji_grid/emoji_grid.vue' import { library } from '@fortawesome/fontawesome-svg-core' import { faBoxOpen, @@ -14,13 +15,6 @@ library.add( faSmileBeam ) -// At widest, approximately 20 emoji are visible in a row, -// loading 3 rows, could be overkill for narrow picker -const LOAD_EMOJI_BY = 60 - -// When to start loading new batch emoji, in pixels -const LOAD_EMOJI_MARGIN = 64 - const EmojiPicker = { props: { enableStickerPicker: { @@ -39,16 +33,13 @@ const EmojiPicker = { keyword: '', activeGroup: 'standard', showingStickers: false, - groupsScrolledClass: 'scrolled-top', - keepOpen: false, - customEmojiBufferSlice: LOAD_EMOJI_BY, - customEmojiTimeout: null, - customEmojiLoadAllConfirmed: false + keepOpen: false } }, components: { StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), - Checkbox + Checkbox, + EmojiGrid }, methods: { onStickerUploaded (e) { @@ -61,12 +52,6 @@ const EmojiPicker = { const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen }) }, - onScroll (e) { - const target = (e && e.target) || this.$refs['emoji-groups'] - this.updateScrolledClass(target) - this.scrolledGroup(target) - this.triggerLoadMore(target) - }, onWheel (e) { e.preventDefault() this.$refs['emoji-tabs'].scrollBy(e.deltaY, 0) @@ -74,68 +59,12 @@ const EmojiPicker = { highlight (key) { this.setShowStickers(false) this.activeGroup = key - }, - updateScrolledClass (target) { - if (target.scrollTop <= 5) { - this.groupsScrolledClass = 'scrolled-top' - } else if (target.scrollTop >= target.scrollTopMax - 5) { - this.groupsScrolledClass = 'scrolled-bottom' - } else { - this.groupsScrolledClass = 'scrolled-middle' + if (this.keyword.length) { + this.$refs.emojiGrid.scrollToItem(key) } }, - triggerLoadMore (target) { - const ref = this.$refs['group-end-custom'] - if (!ref) return - const bottom = ref.offsetTop + ref.offsetHeight - - const scrollerBottom = target.scrollTop + target.clientHeight - const scrollerTop = target.scrollTop - const scrollerMax = target.scrollHeight - - // Loads more emoji when they come into view - const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN - // Always load when at the very top in case there's no scroll space yet - const atTop = scrollerTop < 5 - // Don't load when looking at unicode category or at the very bottom - const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax - if (!bottomAboveViewport && (approachingBottom || atTop)) { - this.loadEmoji() - } - }, - scrolledGroup (target) { - const top = target.scrollTop + 5 - this.$nextTick(() => { - this.emojisView.forEach(group => { - const ref = this.$refs['group-' + group.id] - if (ref.offsetTop <= top) { - this.activeGroup = group.id - } - }) - }) - }, - loadEmoji () { - const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length - - if (allLoaded) { - return - } - - this.customEmojiBufferSlice += LOAD_EMOJI_BY - }, - startEmojiLoad (forceUpdate = false) { - if (!forceUpdate) { - this.keyword = '' - } - this.$nextTick(() => { - this.$refs['emoji-groups'].scrollTop = 0 - }) - const bufferSize = this.customEmojiBuffer.length - const bufferPrefilledAll = bufferSize === this.filteredEmoji.length - if (bufferPrefilledAll && !forceUpdate) { - return - } - this.customEmojiBufferSlice = LOAD_EMOJI_BY + onActiveGroup (group) { + this.activeGroup = group }, toggleStickers () { this.showingStickers = !this.showingStickers @@ -151,13 +80,6 @@ const EmojiPicker = { }) } }, - watch: { - keyword () { - this.customEmojiLoadAllConfirmed = false - this.onScroll() - this.startEmojiLoad(true) - } - }, computed: { activeGroupView () { return this.showingStickers ? '' : this.activeGroup @@ -173,9 +95,6 @@ const EmojiPicker = { this.$store.state.instance.customEmoji || [] ) }, - customEmojiBuffer () { - return this.filteredEmoji.slice(0, this.customEmojiBufferSlice) - }, emojis () { const standardEmojis = this.$store.state.instance.emoji || [] const customEmojis = this.sortedEmoji diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index 82fc831c..6ce8cbd8 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -85,10 +85,6 @@ flex-grow: 1; } - .emoji-groups { - min-height: 200px; - } - .additional-tabs { border-left: 1px solid; border-left-color: $fallback--icon; @@ -167,76 +163,12 @@ } } - .emoji { - &-search { - padding: 5px; - flex: 0 0 auto; + .emoji-search { + padding: 5px; + flex: 0 0 auto; - input { - width: 100%; - } + input { + width: 100%; } - - &-groups { - flex: 1 1 1px; - position: relative; - overflow: auto; - user-select: none; - mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat, - linear-gradient(to bottom, white 0, transparent 100%) top no-repeat, - linear-gradient(to top, white, white); - transition: mask-size 150ms; - mask-size: 100% 20px, 100% 20px, auto; - // Autoprefixed seem to ignore this one, and also syntax is different - -webkit-mask-composite: xor; - mask-composite: exclude; - &.scrolled { - &-top { - mask-size: 100% 20px, 100% 0, auto; - } - &-bottom { - mask-size: 100% 0, 100% 20px, auto; - } - } - } - - &-group { - display: flex; - align-items: center; - flex-wrap: wrap; - padding-left: 5px; - justify-content: left; - - &-title { - font-size: 0.85em; - width: 100%; - margin: 0; - - &.disabled { - display: none; - } - } - } - - &-item { - width: 32px; - height: 32px; - box-sizing: border-box; - display: flex; - font-size: 32px; - align-items: center; - justify-content: center; - margin: 4px; - - cursor: pointer; - - img { - object-fit: contain; - max-width: 100%; - max-height: 100%; - } - } - } - } diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue index 3ec694f0..fe2e39b2 100644 --- a/src/components/emoji_picker/emoji_picker.vue +++ b/src/components/emoji_picker/emoji_picker.vue @@ -2,9 +2,9 @@ <div class="emoji-picker panel panel-default panel-body"> <div class="heading"> <span + ref="emoji-tabs" class="emoji-tabs" @wheel="onWheel" - ref="emoji-tabs" > <span v-for="group in emojis" @@ -51,39 +51,12 @@ @input="$event.target.composing = false" > </div> - <div - ref="emoji-groups" - class="emoji-groups" - :class="groupsScrolledClass" - @scroll="onScroll" - > - <div - v-for="group in emojisView" - :key="group.id" - class="emoji-group" - > - <h6 - :ref="'group-' + group.id" - class="emoji-group-title" - > - {{ group.text }} - </h6> - <span - v-for="emoji in group.emojis" - :key="group.id + emoji.displayText" - :title="emoji.displayText" - class="emoji-item" - @click.stop.prevent="onEmoji(emoji)" - > - <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span> - <img - v-else - :src="emoji.imageUrl" - > - </span> - <span :ref="'group-end-' + group.id" /> - </div> - </div> + <EmojiGrid + ref="emojiGrid" + :groups="emojisView" + @emoji="onEmoji" + @active-group="onActiveGroup" + /> <div v-if="showKeepOpen" class="keep-open" From 581c53a15ea16334b3668d83b24e2fce54b8b321 Mon Sep 17 00:00:00 2001 From: a1batross <a1ba.omarov@gmail.com> Date: Fri, 10 Feb 2023 23:28:46 +0000 Subject: [PATCH 29/29] components: emoji_reactions: force custom emoji reaction height Prevents the usage of too long emoji reactions --- src/components/emoji_reactions/emoji_reactions.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index d9c568f6..3fe81f77 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -19,6 +19,7 @@ :title="reaction.name" class="reaction-emoji" width="2.55em" + height="2.55em" > {{ reaction.count }} </span> @@ -65,6 +66,7 @@ box-sizing: border-box; .reaction-emoji { width: 2.55em !important; + height: 2.55em !important; margin-right: 0.25em; } &:focus {