Compare commits

...

34 commits

Author SHA1 Message Date
3c36845f2a Add DM settings 2023-07-09 05:27:08 +00:00
9519cfc298 fix unfinished post being sent when scrolling 2023-07-09 05:26:50 +00:00
02a53d5662 fix apply theme button without page refresh 2023-07-09 05:26:35 +00:00
ceb8fc7f16 fix dropdown-item-icon and form controls using missing variables 2023-07-09 05:26:22 +00:00
e154aca3fa ensure we only fetch reports when we're an admin
Ref #288
2023-07-09 05:23:46 +00:00
3bfc10f43a Remove unused bits and bobs 2023-07-09 05:19:46 +00:00
ac6459aca9 add recently used emojis panel to emoji picker (#283)
~~(not intended for merging yet, just submitting this for preliminary review and discussion)~~

this patch adds a tab with recently used emojis to the emoji picker: https://akko.lain.gay/notice/ASoGCtyoiXbYPJjqpk

there's a couple of things i'm ~~still trying to work out~~ not totally happy with and i'd appreciate any feedback on them:

* the recentEmojis getter is called very frequently and has to do a possibly somewhat expensive lookup of emoji objects by their `displayName` each time, which i'm not sure is ideal
* ~~emoji reactions on posts added through the picker are picked up by the recentEmojis module, but clicks on existing emoji reactions are not, because `addReaction` in `react_button.js` only currently receives the replacement and not the full emoji object (if there even is one wherever that method is called from)~~ this works now and does the same stupid full search of all emojis by their name which i guess is less bad because this only happens when you hit a reaction emoji button that already existed

Reviewed-on: AkkomaGang/akkoma-fe#283
Co-authored-by: flisk <akkomadev.mvch71fq@flisk.xyz>
Co-committed-by: flisk <akkomadev.mvch71fq@flisk.xyz>
2023-04-30 07:06:49 +00:00
solidsanek
9a8136e31c Drafts: Fix drafts erasing edits and redrafts 2023-04-30 07:06:24 +00:00
solidsanek
282a37ad6a Post: remove debug logs 2023-04-30 07:06:07 +00:00
solidsanek
055b04ee8f Post: Add drafting feature 2023-04-30 07:05:55 +00:00
b60bcbd06d Revert "add language input"
This reverts commit 68f2b0cf8e.
2023-02-28 07:38:25 +00:00
75ace34da1 Revert "Add blurhash support"
This reverts commit 1edc1a2ec7.
2023-02-28 07:38:13 +00:00
8b91ccc830 Revert "Fall back to nsfw image if no blurhash"
This reverts commit 3e19a6091f.
2023-02-28 07:38:01 +00:00
1619c11812 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: AkkomaGang/akkoma-fe#275
Co-authored-by: yanchan09 <yan@omg.lol>
Co-committed-by: yanchan09 <yan@omg.lol>
2023-02-28 07:04:00 +00:00
ed94118499 paginate-follow-requests (#277)
Co-authored-by: FloatingGhost <hannah@coffee-and-dreams.uk>
Reviewed-on: AkkomaGang/akkoma-fe#277
2023-02-28 07:03:49 +00:00
68f2b0cf8e add language input 2023-02-28 07:03:28 +00:00
Sol Fisher Romanoff
e2c0fd26d6 Only show "keep open" emoji checkbox on post form 2023-02-28 07:03:15 +00:00
544c536d82 Remove console.log 2023-02-28 07:03:01 +00:00
5df921a537 add follow/unfollow to followed tags list 2023-02-28 07:02:52 +00:00
4ae1297319 Add list of followed hashtags to profile 2023-02-28 07:02:40 +00:00
3e19a6091f Fall back to nsfw image if no blurhash 2023-02-28 06:52:42 +00:00
1edc1a2ec7 Add blurhash support 2023-02-28 06:52:26 +00:00
2ea987b766 remove IHBA assets 2023-02-28 06:51:54 +00:00
ad018f4b72 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: AkkomaGang/akkoma-fe#246
Co-authored-by: Beefox <bee@beefox.xyz>
Co-committed-by: Beefox <bee@beefox.xyz>
2023-02-28 06:51:40 +00:00
Sol Fisher Romanoff
5838112c40 Remove stray debug log 2023-02-28 06:50:54 +00:00
0b6ba5ea6b force CI build 2023-02-28 06:50:32 +00:00
e0b180f34e update readme 2023-02-28 06:50:19 +00:00
cfebd058b3 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 0b5793c1e0, reversing
changes made to 72ef2e7454.
2023-02-28 06:49:56 +00:00
643c6943cb add verification of links 2023-02-28 06:46:18 +00:00
13a792cb5a fix emoji picker in replies in notifications 2023-02-28 06:46:03 +00:00
131a7967f1 don't crash out if notification status is null 2023-02-28 06:45:40 +00:00
638ce80113 Make everything work with a strict CSP 2023-02-28 06:36:51 +00:00
7ffa39784b Disable follow button if blocked by user 2023-01-27 00:26:50 +00:00
092c94301f Add indicator if user blocks you 2023-01-26 20:50:52 +00:00
54 changed files with 846 additions and 397 deletions

View file

@ -1,22 +1,22 @@
# Pleroma-FE
# Akkoma-FE
![English OK](https://img.shields.io/badge/English-OK-blueviolet) ![日本語OK](https://img.shields.io/badge/%E6%97%A5%E6%9C%AC%E8%AA%9E-OK-blueviolet)
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

View file

@ -4,12 +4,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
<title>Akkoma</title>
<link rel="stylesheet" href="/static/font/css/fontello.css">
<link rel="stylesheet" href="/static/font/css/animation.css">
<link rel="stylesheet" href="/static/font/tiresias.css">
<link rel="stylesheet" href="/static/font/css/lato.css">
<link rel="stylesheet" href="/static/mfm.css">
<link rel="stylesheet" href="/static/custom.css">
<link rel="stylesheet" href="/static/theme-holder.css" id="theme-holder">
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="manifest" href="/manifest.json">

View file

@ -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",
"body-scroll-lock": "2.7.1",

View file

@ -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;
@ -468,7 +469,7 @@ textarea,
color: $fallback--lightText;
color: var(--inputText, $fallback--lightText);
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
font-family: var(--interfaceFont, sans-serif);
font-size: 1em;
margin: 0;
box-sizing: border-box;

View file

@ -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'
@ -394,9 +396,6 @@ const afterStoreSetup = async ({ store, i18n }) => {
])
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
store.dispatch('startFetchingAnnouncements')
store.dispatch('startFetchingReports')
getTOS({ store })
getStickers({ store })

View file

@ -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: {

View file

@ -44,7 +44,6 @@
/>
</router-link>
<router-link
v-if="currentUser || !(privateMode || restrictedTimelines.public)"
:to="{ name: 'public-timeline' }"
class="nav-icon"
>
@ -56,7 +55,6 @@
/>
</router-link>
<router-link
v-if="federating && (currentUser || !(privateMode || restrictedTimelines.federated))"
:to="{ name: 'public-external-timeline' }"
class="nav-icon"
>

View file

@ -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

View file

@ -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%;
}
}
}

View file

@ -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>

View file

@ -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)
}
},

View file

@ -18,6 +18,7 @@
<EmojiPicker
v-if="enableEmojiPicker"
ref="picker"
show-keep-open
:class="{ hide: !showPicker }"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"

View file

@ -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) {
@ -55,12 +51,7 @@ const EmojiPicker = {
onEmoji (emoji) {
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)
this.$store.commit('emojiUsed', emoji)
},
onWheel (e) {
e.preventDefault()
@ -69,68 +60,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 +81,6 @@ const EmojiPicker = {
})
}
},
watch: {
keyword () {
this.customEmojiLoadAllConfirmed = false
this.onScroll()
this.startEmojiLoad(true)
}
},
computed: {
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
@ -168,10 +96,8 @@ const EmojiPicker = {
this.$store.state.instance.customEmoji || []
)
},
customEmojiBuffer () {
return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
},
emojis () {
const recentEmojis = this.$store.getters.recentEmojis
const standardEmojis = this.$store.state.instance.emoji || []
const customEmojis = this.sortedEmoji
const emojiPacks = []
@ -184,6 +110,15 @@ const EmojiPicker = {
})
})
return [
{
id: 'recent',
text: this.$t('emoji.recent'),
first: {
imageUrl: '',
replacement: '🕒',
},
emojis: this.filterByKeyword(recentEmojis)
},
{
id: 'standard',
text: this.$t('emoji.unicode'),

View file

@ -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 {
@ -74,10 +89,6 @@
flex-grow: 1;
}
.emoji-groups {
min-height: 200px;
}
.additional-tabs {
border-left: 1px solid;
border-left-color: $fallback--icon;
@ -156,8 +167,7 @@
}
}
.emoji {
&-search {
.emoji-search {
padding: 5px;
flex: 0 0 auto;
@ -165,67 +175,4 @@
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%;
}
}
}
}

View file

@ -3,9 +3,9 @@
<div class="heading">
<span class="emoji-header">Emoji Packs</span>
<span
ref="emoji-tabs"
class="emoji-tabs"
@wheel="onWheel"
ref="emoji-tabs"
>
<span
v-for="group in emojis"
@ -52,40 +52,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>

View file

@ -3,6 +3,11 @@ import UserListPopover from '../user_list_popover/user_list_popover.vue'
const EMOJI_REACTION_COUNT_CUTOFF = 12
const findEmojiByReplacement = (state, replacement) => {
const allEmojis = state.instance.emoji.concat(state.instance.customEmoji)
return allEmojis.find(emoji => emoji.replacement === replacement)
}
const EmojiReactions = {
name: 'EmojiReactions',
components: {
@ -54,6 +59,8 @@ const EmojiReactions = {
},
reactWith (emoji) {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
const emojiObject = findEmojiByReplacement(this.$store.state, emoji)
this.$store.commit('emojiUsed', emojiObject)
},
unreact (emoji) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })

View file

@ -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
}
}
}

View file

@ -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"

View file

@ -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
}

View file

@ -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>

View file

@ -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>

View file

@ -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
}
}
}

View file

@ -1,4 +1,4 @@
import PinchZoom from '@kazvmoe-infra/pinch-zoom-element'
import PinchZoom from '@floatingghost/pinch-zoom-element'
export default {
methods: {

View file

@ -114,7 +114,7 @@
svg {
width: 22px;
margin-right: 0.75rem;
color: var(--menuPopoverIcon, $fallback--icon)
color: var(--popoverIcon, $fallback--icon)
}
}

View file

@ -53,6 +53,14 @@ const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const deleteDraft = (draftKey) => {
const draftData = JSON.parse(localStorage.getItem('drafts') || '{}');
delete draftData[draftKey];
localStorage.setItem('drafts', JSON.stringify(draftData));
}
const PostStatusForm = {
props: [
'statusId',
@ -157,6 +165,36 @@ const PostStatusForm = {
}
}
if (!this.statusId) {
let draftKey = 'status';
if (this.replyTo) {
draftKey = 'reply:' + this.replyTo;
} else if (this.quoteId) {
draftKey = 'quote:' + this.quoteId;
}
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[draftKey];
if (draft) {
statusParams = {
spoilerText: draft.data.spoilerText,
status: draft.data.status,
sensitiveIfSubject,
nsfw: draft.data.nsfw,
files: draft.data.files,
poll: draft.data.poll,
mediaDescriptions: draft.data.mediaDescriptions,
visibility: draft.data.visibility,
language: draft.data.language,
contentType: draft.data.contentType
}
if (draft.data.poll) {
this.togglePollForm();
}
}
}
return {
dropFiles: [],
uploadingFiles: false,
@ -273,6 +311,7 @@ const PostStatusForm = {
statusChanged () {
this.autoPreview()
this.updateIdempotencyKey()
this.saveDraft()
},
clearStatus () {
const newStatus = this.newStatus
@ -391,8 +430,38 @@ const PostStatusForm = {
}).finally(() => {
this.previewLoading = false
})
let draftKey = 'status';
if (this.replyTo) {
draftKey = 'reply:' + this.replyTo;
} else if (this.quoteId) {
draftKey = 'quote:' + this.quoteId;
}
deleteDraft(draftKey)
},
debouncePreviewStatus: debounce(function () { this.previewStatus() }, 500),
saveDraft() {
const draftData = JSON.parse(localStorage.getItem('drafts') || '{}');
let draftKey = 'status';
if (this.replyTo) {
draftKey = 'reply:' + this.replyTo;
} else if (this.quoteId) {
draftKey = 'quote:' + this.quoteId;
}
if (this.newStatus.status || this.newStatus.spoilerText || this.newStatus.files.length > 0 || this.newStatus.poll.length > 0) {
draftData[draftKey] = {
updatedAt: new Date(),
data: this.newStatus,
};
localStorage.setItem('drafts', JSON.stringify(draftData));
} else {
deleteDraft(draftKey);
}
},
autoPreview () {
if (!this.preview) return
this.previewLoading = true

View file

@ -274,12 +274,14 @@
>
{{ $t('post_status.post') }}
</button>
<!-- touchstart is used to keep the OSK at the same position after a message send -->
<!-- To keep the OSK at the same position after a message send, -->
<!-- @touchstart.stop.prevent was used. But while OSK position is -->
<!-- quirky, accidental mobile posts caused by the workaround -->
<!-- when people tried to scroll were a more serious bug. -->
<button
v-else
:disabled="uploadingFiles || disableSubmit"
class="btn button-default"
@touchstart.stop.prevent="postStatus($event, newStatus)"
@click.stop.prevent="postStatus($event, newStatus)"
>
{{ $t('post_status.post') }}

View file

@ -38,7 +38,7 @@ label.Select {
margin: 0;
padding: 0 2em 0 .2em;
font-family: sans-serif;
font-family: var(--inputFont, sans-serif);
font-family: var(--interfaceFont, sans-serif);
font-size: 1em;
width: 100%;
z-index: 1;

View file

@ -21,7 +21,6 @@
>
{{ $t('settings.settings_profile_force_sync') }}
</button>
</p>
<div
@click="toggleExpandedSettings"

View file

@ -12,6 +12,7 @@ import InterfaceLanguageSwitcher from 'src/components/interface_language_switche
import BooleanSetting from '../helpers/boolean_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import localeService from 'src/services/locale/locale.service.js'
import ChoiceSetting from '../helpers/choice_setting.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
@ -46,9 +47,16 @@ const ProfileTab = {
emailLanguage: this.$store.state.users.currentUser.language || '',
newPostTTLDays: this.$store.state.users.currentUser.status_ttl_days,
expirePosts: this.$store.state.users.currentUser.status_ttl_days !== null,
userAcceptsDirectMessagesFrom: this.$store.state.users.currentUser.accepts_direct_messages_from,
userAcceptsDirectMessagesFromOptions: ["everybody", "nobody", "people_i_follow"].map(mode => ({
key: mode,
value: mode,
label: this.$t(`settings.user_accepts_direct_messages_from_${mode}`)
}))
}
},
components: {
ChoiceSetting,
ScopeSelector,
ImageCropper,
EmojiInput,
@ -126,7 +134,8 @@ const ProfileTab = {
fields_attributes: this.newFields.filter(el => el != null),
bot: this.bot,
show_role: this.showRole,
status_ttl_days: this.expirePosts ? this.newPostTTLDays : -1
status_ttl_days: this.expirePosts ? this.newPostTTLDays : -1,
accepts_direct_messages_from: this.userAcceptsDirectMessagesFrom
/* eslint-enable camelcase */
}

View file

@ -89,6 +89,15 @@
{{ $t('settings.bot') }}
</Checkbox>
</p>
<p>
<ChoiceSetting
id="userAcceptsDirectMessagesFrom"
path="userAcceptsDirectMessagesFrom"
:options="userAcceptsDirectMessagesFromOptions"
>
{{ $t('settings.user_accepts_direct_messages_from') }}
</ChoiceSetting>
</p>
<p>
<Checkbox v-model="expirePosts">
{{ $t('settings.expire_posts_enabled') }}
@ -102,6 +111,9 @@
class="expire-posts-days"
:placeholder="$t('settings.expire_posts_input_placeholder')"
/>
</p>
<p>
</p>
<p>
<interface-language-switcher

View file

@ -89,6 +89,10 @@
margin: 1em 1em 0;
}
.presets {
text-align: center;
}
.tab-header {
display: flex;
justify-content: space-between;

View file

@ -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)
})
}
}

View file

@ -16,7 +16,7 @@
>{{ $t("nav.home_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' }"
@ -32,7 +32,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' }"

View file

@ -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;

View file

@ -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

View file

@ -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
}
}

View file

@ -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>
@ -96,22 +105,48 @@
v-if="followsTabVisible"
key="followees"
:label="$t('user_card.followees')"
:disabled="!user.friends_count"
>
<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 v-slot:item="{item}">
<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"
@ -227,6 +262,11 @@
padding: 0.5em 1.5em;
box-sizing: border-box;
}
.user-profile-field-validated {
margin-left: 1rem;
color: green;
}
}
}

View file

@ -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">

View file

@ -86,7 +86,8 @@
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"search_emoji": "Search for an emoji",
"stickers": "Stickers",
"unicode": "Unicode emoji"
"unicode": "Unicode emoji",
"recent": "Recently used"
},
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
@ -928,6 +929,10 @@
"user_profile_default_tab": "Default Tab on User Profile",
"user_profiles": "User Profiles",
"user_settings": "User Settings",
"user_accepts_direct_messages_from": "Accept DMs From",
"user_accepts_direct_messages_from_everybody": "Everybody",
"user_accepts_direct_messages_from_nobody": "Nobody",
"user_accepts_direct_messages_from_people_i_follow": "People I follow",
"valid_until": "Valid until",
"values": {
"false": "no",
@ -1056,6 +1061,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 +1127,7 @@
"block_confirm_title": "Block user",
"block_progress": "Blocking…",
"blocked": "Blocked!",
"blocks_you": "Blocks you!",
"bot": "Bot",
"deactivated": "Deactivated",
"deny": "Deny",
@ -1138,6 +1145,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 +1185,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 +1195,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:",

View file

@ -19,7 +19,8 @@ const saveImmedeatelyActions = [
'setOption',
'setClientData',
'setToken',
'clearToken'
'clearToken',
'emojiUsed',
]
const defaultStorage = (() => {

View file

@ -22,6 +22,7 @@ import announcementsModule from './modules/announcements.js'
import editStatusModule from './modules/editStatus.js'
import statusHistoryModule from './modules/statusHistory.js'
import tagModule from './modules/tags.js'
import recentEmojisModule from './modules/recentEmojis.js'
import { createI18n } from 'vue-i18n'
@ -47,7 +48,8 @@ const persistedStateOptions = {
paths: [
'config',
'users.lastLoginName',
'oauth'
'oauth',
'recentEmojis.emojis',
]
};
@ -98,7 +100,8 @@ const persistedStateOptions = {
announcements: announcementsModule,
editStatus: editStatusModule,
statusHistory: statusHistoryModule,
tags: tagModule
tags: tagModule,
recentEmojis: recentEmojisModule,
},
plugins,
strict: false // Socket modifies itself, let's ignore this for now.

View file

@ -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

View file

@ -0,0 +1,50 @@
// each row is 7 emojis, 6 rows chosen arbitrarily. i don't think more than
// that are going to be useful.
const RECENT_MAX = 7 * 6
const defaultState = {
emojis: [],
}
const recentEmojis = {
state: defaultState,
mutations: {
emojiUsed ({ emojis }, emoji) {
if (emoji.displayText === undefined || emoji.displayText === null) {
console.error('emojiUsed was called with a bad emoji object: ', emoji)
return
} else if (emoji.displayText.includes('@')) {
console.error('emojiUsed was called with a remote emoji: ', emoji)
return
}
const i = emojis.indexOf(emoji.displayText)
if (i === -1) {
// not in `emojis` yet, insert and truncate if necessary
const newLength = emojis.unshift(emoji.displayText)
if (newLength > RECENT_MAX) {
emojis.pop()
}
} else if (i !== 0) {
// emoji is already in `emojis` but needs to be bumped to the top
emojis.splice(i, 1)
emojis.unshift(emoji.displayText)
}
},
},
getters: {
recentEmojis: (state, getters, rootState) => state.emojis.reduce((objects, displayText) => {
const allEmojis = rootState.instance.emoji.concat(rootState.instance.customEmoji)
let emojiObject = allEmojis.find(emoji => emoji.displayText === displayText)
if (emojiObject !== undefined) {
objects.push(emojiObject)
}
return objects
}, []),
},
}
export default recentEmojis

View file

@ -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
})
}

View file

@ -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')
@ -595,13 +637,16 @@ const users = {
// Get user mutes
store.dispatch('fetchMutes')
store.dispatch('setLayoutWidth', windowWidth())
store.dispatch('setLayoutHeight', windowHeight())
store.dispatch('getSupportedTranslationlanguages')
store.dispatch('getSettingsProfile')
store.dispatch('listSettingsProfiles')
store.dispatch('startFetchingConfig')
store.dispatch('startFetchingAnnouncements')
if (user.role === 'admin' || user.role === 'moderator') {
store.dispatch('startFetchingReports')
}
// Fetch our friends
store.rootState.api.backendInteractor.fetchFriends({ id: user.id })

View file

@ -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
@ -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) })
@ -1575,6 +1569,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 +1800,6 @@ const apiService = {
mfaConfirmOTP,
addBackup,
listBackups,
fetchFollowRequests,
fetchLists,
createList,
getList,
@ -1813,7 +1848,9 @@ const apiService = {
deleteNoteFromReport,
getHashtag,
followHashtag,
unfollowHashtag
unfollowHashtag,
getFollowedHashtags,
getFollowRequests
}
export default apiService

View file

@ -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 })
},

View file

@ -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,8 @@ export const parseUser = (data) => {
output.friends_count = data.following_count
output.bot = data.bot
output.accepts_direct_messages_from = data.accepts_direct_messages_from
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
@ -408,8 +412,10 @@ export const parseNotification = (data) => {
if (masto) {
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
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)

View file

@ -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

View file

@ -4,19 +4,21 @@ 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 styleSheet = styleEl.sheet
/** @type {CSSStyleSheet} */
const styleSheet = document.getElementById('theme-holder').sheet
for (let i = styleSheet.cssRules.length; i--; ) {
styleSheet.deleteRule(0)
}
styleSheet.insertRule(
`:root { ${rules.radii}; ${rules.colors}; ${rules.shadows}; ${rules.fonts}; }`,
0
)
styleSheet.toString()
styleSheet.insertRule(`:root { ${rules.radii} }`, 'index-max')
styleSheet.insertRule(`:root { ${rules.colors} }`, 'index-max')
styleSheet.insertRule(`:root { ${rules.shadows} }`, 'index-max')
styleSheet.insertRule(`:root { ${rules.fonts} }`, 'index-max')
body.classList.remove('hidden')
}

View file

@ -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;" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

1
static/theme-holder.css Normal file
View file

@ -0,0 +1 @@
// This file intentionally left blank

View file

@ -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"