diff --git a/README.md b/README.md index 9be1c3bd..43e177b4 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 @@ -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. 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 e712cffd..4bb94fcd 100644 --- a/package.json +++ b/package.json @@ -18,19 +18,21 @@ "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", - "@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", "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", @@ -82,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/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/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..0ccdb776 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 && attachment.blurhash" + :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/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_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..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,19 +15,17 @@ 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: { required: false, type: Boolean, default: false + }, + showKeepOpen: { + required: false, + type: Boolean, + default: false } }, data () { @@ -34,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) { @@ -56,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) @@ -69,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 @@ -146,13 +80,6 @@ const EmojiPicker = { }) } }, - watch: { - keyword () { - this.customEmojiLoadAllConfirmed = false - this.onScroll() - this.startEmojiLoad(true) - } - }, computed: { activeGroupView () { return this.showingStickers ? '' : this.activeGroup @@ -168,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 ac7b8b5d..6ce8cbd8 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%; @@ -7,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%; @@ -18,6 +29,10 @@ min-width: 50%; max-width: 130%; } + + .Status > .emoji-picker { + z-index: 1000; + } } } .emoji-picker { @@ -70,10 +85,6 @@ flex-grow: 1; } - .emoji-groups { - min-height: 200px; - } - .additional-tabs { border-left: 1px solid; border-left-color: $fallback--icon; @@ -152,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 00ffb9d2..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,40 +51,16 @@ @input="$event.target.composing = false" > </div> + <EmojiGrid + ref="emojiGrid" + :groups="emojisView" + @emoji="onEmoji" + @active-group="onActiveGroup" + /> <div - ref="emoji-groups" - class="emoji-groups" - :class="groupsScrolledClass" - @scroll="onScroll" + v-if="showKeepOpen" + class="keep-open" > - <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> - <div class="keep-open"> <Checkbox v-model="keepOpen"> {{ $t('emoji.keep_open') }} </Checkbox> 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 { 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/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/followed_tag_card/FollowedTagCard.vue b/src/components/followed_tag_card/FollowedTagCard.vue new file mode 100644 index 00000000..d9394ddc --- /dev/null +++ b/src/components/followed_tag_card/FollowedTagCard.vue @@ -0,0 +1,77 @@ +<template> + <div class="followed-tag-card"> + <span> + <router-link :to="{ name: 'tag-timeline', params: {tag: tag.name}}"> + <span class="tag-link">#{{ tag.name }}</span> + </router-link> + <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> + +<script> +export default { + name: 'FollowedTagCard', + props: { + 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; + 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/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/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/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/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/components/user_card/user_card.scss b/src/components/user_card/user_card.scss index 07ab7bec..0a5e744e 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; @@ -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..289db15b 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" @@ -187,6 +193,7 @@ <FollowButton :relationship="relationship" :user="user" + :disabled="relationship.blocked_by" /> <template v-if="relationship.following"> <ProgressButton diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js index 42c5ec9e..9ea8c2a7 100644 --- a/src/components/user_profile/user_profile.js +++ b/src/components/user_profile/user_profile.js @@ -10,11 +10,14 @@ 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' +import FollowedTagCard from '../followed_tag_card/FollowedTagCard.vue' library.add( - faCircleNotch + faCircleNotch, + faCircleCheck ) const FollowerList = withLoadMore({ @@ -33,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 = { @@ -41,6 +52,7 @@ const UserProfile = { error: false, userId: null, tab: 'statuses', + followsTab: 'users', footerRef: null, note: null, noteLoading: false @@ -165,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 @@ -200,6 +215,7 @@ const UserProfile = { } }, components: { + FollowedTagCard, UserCard, Timeline, FollowerList, @@ -207,7 +223,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 d16483e2..5465778a 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> @@ -95,22 +104,48 @@ v-if="followsTabVisible" key="followees" :label="$t('user_card.followees')" - :disabled="!user.friends_count" > - <FriendList :user-id="userId"> - <template v-slot:item="{item}"> - <FollowCard :user="item" /> - </template> - </FriendList> + <tab-switcher + :active-tab="followsTab" + :render-only-focused="true" + :on-switch="onFollowsTabSwitch" + > + <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" + :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> </div> <div 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" @@ -225,6 +260,11 @@ padding: 0.5em 1.5em; box-sizing: border-box; } + + .user-profile-field-validated { + margin-left: 1rem; + color: green; + } } } diff --git a/src/hocs/with_load_more/with_load_more.jsx b/src/hocs/with_load_more/with_load_more.jsx index c0ae1856..c55eccf5 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 }) @@ -88,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 32785561..46890345 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" }, @@ -1056,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" }, @@ -1121,6 +1123,7 @@ "block_confirm_title": "Block user", "block_progress": "Blocking…", "blocked": "Blocked!", + "blocks_you": "Blocks you!", "bot": "Bot", "deactivated": "Deactivated", "deny": "Deny", @@ -1138,6 +1141,8 @@ "follow_unfollow": "Unfollow", "followees": "Following", "followers": "Followers", + "followed_tags": "Followed hashtags", + "followed_users": "Followed users", "following": "Following!", "follows_you": "Follows you!", "hidden": "Hidden", @@ -1176,6 +1181,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" @@ -1183,7 +1191,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/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/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/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/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) } }, diff --git a/src/modules/tags.js b/src/modules/tags.js index cff54b7d..63f6888d 100644 --- a/src/modules/tags.js +++ b/src/modules/tags.js @@ -2,9 +2,15 @@ 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 }) { state.tags[name] = data @@ -17,17 +23,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 022cc1dc..bc1943c8 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) { @@ -251,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++ } } @@ -271,7 +291,7 @@ export const getters = { relationship: state => id => { const rel = id && state.relationships[id] return rel || { id, loading: true } - } + }, } export const defaultState = { @@ -282,7 +302,9 @@ export const defaultState = { usersObject: {}, signUpPending: false, signUpErrors: [], - relationships: {} + relationships: {}, + tags: [], + tagsObject: {} } const users = { @@ -402,12 +424,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 }) => { + 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 + }) + }, 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])) @@ -473,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) => { @@ -536,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 9e6f39f2..947e9da9 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' @@ -11,11 +12,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' @@ -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 @@ -246,7 +248,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) { @@ -404,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) }) @@ -878,7 +872,8 @@ const postStatus = ({ quoteId, contentType, preview, - idempotencyKey + idempotencyKey, + language }) => { const form = new FormData() const pollOptions = poll.options || [] @@ -889,6 +884,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) }) @@ -1335,7 +1331,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 +1339,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) @@ -1575,6 +1571,48 @@ 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 + } + }); +} + +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 @@ -1764,7 +1802,6 @@ const apiService = { mfaConfirmOTP, addBackup, listBackups, - fetchFollowRequests, fetchLists, createList, getList, @@ -1813,7 +1850,9 @@ const apiService = { deleteNoteFromReport, getHashtag, followHashtag, - unfollowHashtag + 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 465d6fad..e330ca8c 100644 --- a/src/services/entity_normalizer/entity_normalizer.service.js +++ b/src/services/entity_normalizer/entity_normalizer.service.js @@ -68,13 +68,15 @@ 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 } }) 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 } }) @@ -88,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 @@ -232,13 +235,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 @@ -408,8 +412,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) 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 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/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/.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/blurhash-overlay.png b/static/blurhash-overlay.png new file mode 100755 index 00000000..209a9438 Binary files /dev/null and b/static/blurhash-overlay.png differ diff --git a/static/logo.png b/static/logo.png deleted file mode 100644 index 14aaf4a3..00000000 Binary files a/static/logo.png and /dev/null differ 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 bbceba0b..7027b7d8 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" @@ -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" @@ -5090,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: