integrate withLoadMore HOC into List

This commit is contained in:
Henry Jameson 2026-06-08 04:13:17 +03:00
commit 503309890f
16 changed files with 449 additions and 434 deletions

View file

@ -0,0 +1,58 @@
.List {
--__line-height: 1.5em;
--__horizontal-gap: 0.75em;
--__vertical-gap: 0.5em;
display: flex;
flex-direction: column;
.list {
flex: 1;
}
.list-item {
display: flex;
align-items: center;
&:not(:last-child) {
border-bottom: 1px dotted var(--border);
}
}
.header {
display: flex;
align-items: center;
padding: var(--__vertical-gap) var(--__horizontal-gap);
border-bottom: 1px solid;
border-bottom-color: var(--border);
.actions {
flex: 1;
}
}
.footer {
padding: 0.9em;
text-align: center;
a {
cursor: pointer;
}
}
.checkbox-wrapper {
padding-right: var(--__horizontal-gap);
flex: none;
}
&.-scrollable {
overflow-y: hidden;
display: flex;
flex-direction: column;
.list {
overflow-y: auto;
flex: 1 1 auto;
}
}
}

152
src/components/list/list.js Normal file
View file

@ -0,0 +1,152 @@
import { isEmpty } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
const List = {
props: {
boxOnly: {
type: Boolean,
default: false,
},
items: {
type: Array,
default: () => [],
},
fetchFunction: {
type: Function,
default: null,
},
itemsFunction: {
type: Function,
default: null,
},
getKey: {
type: Function,
default: (item) => item.id,
},
getClass: {
type: Function,
default: () => '',
},
nonInteractive: {
type: Boolean,
default: false,
},
scrollable: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
},
components: {
Checkbox,
},
data() {
return {
loading: false,
bottomedOut: false,
error: false,
dynamicItems: this.itemsFunction ? [] : null,
selected: [],
}
},
computed: {
allKeys() {
return this.actualItems.map(this.getKey)
},
filteredSelected() {
return this.allKeys.filter((key) => this.selected.indexOf(key) !== -1)
},
allSelected() {
return this.filteredSelected.length === this.actualItems.length
},
noneSelected() {
return this.filteredSelected.length === 0
},
someSelected() {
return !this.allSelected && !this.noneSelected
},
actualItems() {
return this.dynamicItems || this.actualItems
},
},
created() {
if (this.fetchFunction) {
window.addEventListener('scroll', this.scrollLoad)
if (this.dynamicItems.length === 0) {
this.fetchEntries()
}
}
},
unmounted() {
window.removeEventListener('scroll', this.scrollLoad)
},
methods: {
// Entries is not a computed because computed can't track the dynamic
// selector for changes and won't trigger after fetch.
updateEntries(newEntries) {
this.dynamicItems = this.itemsFunction(newEntries)
},
fetchEntries() {
if (!this.loading) {
this.loading = true
this.error = false
this.fetchFunction()
.then((newEntries) => {
this.loading = false
this.bottomedOut = isEmpty(newEntries)
return newEntries
})
.catch((error) => {
this.loading = false
this.error = error
})
.finally((newEntries) => {
this.updateEntries(newEntries)
})
}
},
scrollLoad(e) {
if (this.fetchFunction) {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
this.loading === false &&
this.bottomedOut === false &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchEntries()
}
}
},
isSelected(item) {
return this.filteredSelected.indexOf(this.getKey(item)) !== -1
},
toggle(checked, item) {
console.log('TOGGLE', checked, item)
const key = this.getKey(item)
const oldChecked = this.isSelected(key)
if (checked !== oldChecked) {
if (checked) {
this.selected.push(key)
} else {
this.selected.splice(this.selected.indexOf(key), 1)
}
}
},
toggleAll(value) {
if (value) {
this.selected = this.allKeys.slice(0)
} else {
this.selected = []
}
},
},
}
export default List

View file

@ -1,49 +1,91 @@
<template>
<div
class="list"
role="list"
class="List"
:class="{ '-scrollable': scrollable }"
>
<div
v-for="item in items"
:key="getKey(item)"
class="list-item"
:class="[getClass(item), nonInteractive ? '-non-interactive' : '']"
role="listitem"
v-if="selectable"
class="header"
>
<slot
name="item"
:item="item"
/>
<div class="checkbox-wrapper">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
@update:model-value="toggleAll"
>
{{ $t('selectable_list.select_all') }}
</Checkbox>
</div>
<div class="actions">
<slot
name="header"
:selected="filteredSelected"
/>
</div>
</div>
<div
v-if="items.length === 0 && !!$slots.empty"
class="list-empty-content faint"
class="list"
role="list"
>
<slot name="empty" />
<slot name="load" />
<div
v-for="item in actualItems"
:key="getKey(item)"
class="list-item"
:class="[getClass(item), nonInteractive ? '-non-interactive' : '']"
role="listitem"
>
<div
v-if="selectable"
class="checkbox-wrapper"
>
<Checkbox
:model-value="isSelected(item)"
@update:model-value="checked => toggle(checked, item)"
@click.stop
/>
</div>
<slot
name="item"
:item="item"
/>
</div>
<div
v-if="actualItems.length === 0 && !!$slots.empty"
class="list-empty-content faint"
>
<slot name="empty" />
<slot name="load" />
</div>
<div class="footer">
<button
v-if="error"
class="button-unstyled -link -fullwidth alert error"
@click="fetchEntries"
>
{{ $t('general.generic_error') }}
{{ error }}
</button>
<FAIcon
v-else-if="loading"
spin
icon="circle-notch"
/>
<a
v-else-if="!bottomedOut"
@click="fetchEntries"
role="button"
tabindex="0"
>
{{ $t('general.more') }}
</a>
<span v-else>
{{ $t('general.no_more') }}
</span>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => [],
},
getKey: {
type: Function,
default: (item) => item.id,
},
getClass: {
type: Function,
default: () => '',
},
nonInteractive: {
type: Boolean,
default: false,
},
},
}
</script>
<script src="./list.js"></script>
<style src="./list.css"></style>

View file

@ -87,7 +87,7 @@
flex-direction: column;
.list {
flex: 1 1 0;
flex: 1;
}
&-item-inner {

View file

@ -40,6 +40,7 @@
.tab-slot-wrapper {
flex: 1 1 auto;
position: relative;
height: 100%;
padding: 0 1em;
overflow-y: auto;
@ -56,10 +57,16 @@
}
&.-full-height {
height: 100%;
> * {
height: 100%;
}
}
&.-full-width.-full-height {
position: absolute;
inset: 0;
}
}
}

View file

@ -1,11 +1,11 @@
import { get, map, reject } from 'lodash'
import withLoadMore from 'src/components/../hocs/with_load_more/with_load_more'
import withSubscription from 'src/components/../hocs/with_subscription/with_subscription'
import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
import BlockCard from 'src/components/block_card/block_card.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'
import List from 'src/components/list/list.vue'
import MuteCard from 'src/components/mute_card/mute_card.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import SelectableList from 'src/components/selectable_list/selectable_list.vue'
@ -14,32 +14,6 @@ import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthTokensStore } from 'src/stores/oauth_tokens.js'
const BlockList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchBlocks'),
select: (props, $store) =>
get($store.state.users.currentUser, 'blockIds', []),
destroy: () => {
/* no-op */
},
childPropName: 'items',
})(SelectableList)
const MuteList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchMutes'),
select: (props, $store) => get($store.state.users.currentUser, 'muteIds', []),
destroy: () => {
/* no-op */
},
childPropName: 'items',
})(SelectableList)
const DomainMuteList = withSubscription({
fetch: (props, $store) => $store.dispatch('fetchDomainMutes'),
select: (props, $store) =>
get($store.state.users.currentUser, 'domainMutes', []),
childPropName: 'items',
})(SelectableList)
const MutesAndBlocks = {
data() {
return {
@ -52,12 +26,10 @@ const MutesAndBlocks = {
},
components: {
TabSwitcher,
BlockList,
MuteList,
DomainMuteList,
BlockCard,
MuteCard,
DomainMuteCard,
BlockCard,
List,
MuteCard,
ProgressButton,
Autosuggest,
Checkbox,
@ -102,6 +74,15 @@ const MutesAndBlocks = {
})
.join('\n')
},
getBlocks() {
return get(this.$store.state.users.currentUser, 'blockIds', [])
},
getMutes() {
return get(this.$store.state.users.currentUser, 'muteIds', [])
},
getDomainMutes() {
return get(this.$store.state.users.currentUser, 'domainMutes', [])
},
activateTab(tabName) {
this.activeTab = tabName
},

View file

@ -1,5 +1,5 @@
.mutes-and-blocks-tab {
height: 100%;
min-height: 100%;
.usersearch-wrapper {
padding: 1em;
@ -26,4 +26,12 @@
margin-top: 1em;
width: 10em;
}
.blocks, .mutes {
display: flex;
flex-direction: column;
min-height: 100%;
position: absolute;
inset: 0;
}
}

View file

@ -1,9 +1,12 @@
<template>
<tab-switcher
:scrollable-tabs="true"
class="mutes-and-blocks-tab"
>
<div :label="$t('settings.blocks_tab')">
:scrollable-tabs="true"
>
<div
class="blocks"
:label="$t('settings.user_blocks')"
>
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnblockedUsers"
@ -17,9 +20,12 @@
</template>
</Autosuggest>
</div>
<BlockList
:refresh="true"
<List
:get-key="i => i"
:items-function="getBlocks"
:fetch-function="() => $store.dispatch('fetchBlocks')"
scrollable
selectable
>
<template #header="{selected}">
<div class="bulk-actions">
@ -51,103 +57,106 @@
<template #empty>
{{ $t('settings.no_blocks') }}
</template>
</BlockList>
</List>
</div>
<div :label="$t('settings.mutes_tab')">
<tab-switcher>
<div :label="$t('settings.user_mutes')">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
<div class="mutes" :label="$t('settings.user_mutes2')">
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
<template #default="row">
<MuteCard
:user-id="row.item"
/>
</template>
</Autosuggest>
</div>
<List
:get-key="i => i"
:items-function="getMutes"
:fetch-function="() => $store.dispatch('fetchMutes')"
scrollable
selectable
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => muteUsers(selected)"
>
<template #default="row">
<MuteCard
:user-id="row.item"
/>
{{ $t('user_card.mute') }}
<template #progress>
{{ $t('user_card.mute_progress') }}
</template>
</Autosuggest>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template #progress>
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
<MuteList
:refresh="true"
:get-key="i => i"
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
<template #progress>
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template #progress>
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template #item="{item}">
<MuteCard :user-id="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</MuteList>
</div>
</template>
<template #item="{item}">
<MuteCard :user-id="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</List>
</div>
<div :label="$t('settings.domain_mutes')">
<div class="domain-mute-form">
<Autosuggest
:filter="filterUnMutedDomains"
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
<div :label="$t('settings.domain_mutes2')">
<div class="domain-mute-form">
<Autosuggest
:filter="filterUnMutedDomains"
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
>
<template #default="row">
<DomainMuteCard
:domain="row.item"
/>
</template>
</Autosuggest>
</div>
<List
:get-key="i => i"
:items-function="getDomainMutes"
:fetch-function="() => $store.dispatch('fetchDomainMutes')"
scrollable
selectable
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteDomains(selected)"
>
<template #default="row">
<DomainMuteCard
:domain="row.item"
/>
{{ $t('domain_mute_card.unmute') }}
<template #progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</Autosuggest>
</ProgressButton>
</div>
<DomainMuteList
:refresh="true"
:get-key="i => i"
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
<template #progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template #item="{item}">
<DomainMuteCard :domain="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</DomainMuteList>
</div>
</tab-switcher>
</template>
<template #item="{item}">
{{ item }}
<DomainMuteCard :domain="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</List>
</div>
</tab-switcher>
</template>

View file

@ -49,6 +49,7 @@
.contents.scrollable-tabs {
flex-basis: 0;
position: relative;
}
}

View file

@ -7,7 +7,6 @@ import List from 'src/components/list/list.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Timeline from 'src/components/timeline/timeline.vue'
import UserCard from 'src/components/user_card/user_card.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
@ -19,28 +18,6 @@ import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const FollowerList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchFollowers', props.userId),
select: (props, $store) =>
get($store.getters.findUser(props.userId), 'followerIds', []).map((id) =>
$store.getters.findUser(id),
),
destroy: (props, $store) => $store.dispatch('clearFollowers', props.userId),
childPropName: 'items',
additionalPropNames: ['userId'],
})(List)
const FriendList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchFriends', props.userId),
select: (props, $store) =>
get($store.getters.findUser(props.userId), 'friendIds', []).map((id) =>
$store.getters.findUser(id),
),
destroy: (props, $store) => $store.dispatch('clearFriends', props.userId),
childPropName: 'items',
additionalPropNames: ['userId'],
})(List)
const defaultTabKey = 'statuses'
const UserProfile = {
@ -64,6 +41,8 @@ const UserProfile = {
unmounted() {
this.stopFetching()
useInterfaceStore().setForeignProfileBackground(null)
this.$store.dispatch('clearFollowers', this.userId)
this.$store.dispatch('clearFriends', this.userId)
},
computed: {
timeline() {
@ -109,6 +88,18 @@ const UserProfile = {
setFooterRef(el) {
this.footerRef = el
},
getFriends() {
return get(
this.$store.getters.findUser(this.userId),
'friendIds', []
).map((id) => this.$store.getters.findUser(id))
},
getFollowers() {
return get(
this.$store.getters.findUser(this.userId),
'followerIds', []
).map((id) => this.$store.getters.findUser(id))
},
load(userNameOrId) {
const startFetchingTimeline = (timeline, userId) => {
// Clear timeline only if load another user's profile
@ -203,8 +194,7 @@ const UserProfile = {
components: {
UserCard,
Timeline,
FollowerList,
FriendList,
List,
FollowCard,
TabSwitcher,
Conversation,

View file

@ -39,14 +39,15 @@
:label="$t('user_card.followees')"
:disabled="!user.friends_count"
>
<FriendList
<List
:user-id="userId"
:non-interactive="true"
:items-function="getFriends"
:fetch-function="() => $store.dispatch('fetchFriends', userId)"
>
<template #item="{item}">
<FollowCard :user="item" />
</template>
</FriendList>
</List>
</div>
<div
v-if="followersTabVisible"
@ -55,9 +56,10 @@
:label="$t('user_card.followers')"
:disabled="!user.followers_count"
>
<FollowerList
<List
:user-id="userId"
:non-interactive="true"
:items-function="getFollowers"
:fetch-function="() => $store.dispatch('fetchFollowers', userId)"
>
<template #item="{item}">
<FollowCard
@ -65,7 +67,7 @@
:no-follows-you="isUs"
/>
</template>
</FollowerList>
</List>
</div>
<Timeline
key="media"

View file

@ -1,119 +0,0 @@
// eslint-disable-next-line no-unused
import { isEmpty } from 'lodash'
import { h } from 'vue'
import { getComponentProps } from '../../services/component_utils/component_utils'
import './with_load_more.scss'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
library.add(faCircleNotch)
const withLoadMore =
({
fetch, // function to fetch entries and return a promise
select, // function to select data from store
unmounted, // function called at "destroyed" lifecycle
childPropName = 'entries', // name of the prop to be passed into the wrapped component
additionalPropNames = [], // additional prop name list of the wrapper component
}) =>
(WrappedComponent) => {
const originalProps = Object.keys(getComponentProps(WrappedComponent))
const props = originalProps
.filter((v) => v !== childPropName)
.concat(additionalPropNames)
return {
props,
data() {
return {
loading: false,
bottomedOut: false,
error: false,
entries: [],
}
},
created() {
window.addEventListener('scroll', this.scrollLoad)
if (this.entries.length === 0) {
this.fetchEntries()
}
},
unmounted() {
window.removeEventListener('scroll', this.scrollLoad)
unmounted && unmounted(this.$props, this.$store)
},
methods: {
// Entries is not a computed because computed can't track the dynamic
// selector for changes and won't trigger after fetch.
updateEntries() {
this.entries = select(this.$props, this.$store) || []
},
fetchEntries() {
if (!this.loading) {
this.loading = true
this.error = false
fetch(this.$props, this.$store)
.then((newEntries) => {
this.loading = false
this.bottomedOut = isEmpty(newEntries)
})
.catch(() => {
this.loading = false
this.error = true
})
.finally(() => {
this.updateEntries()
})
}
},
scrollLoad(e) {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
this.loading === false &&
this.bottomedOut === false &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchEntries()
}
},
},
render() {
const props = {
...this.$props,
[childPropName]: this.entries,
}
const children = this.$slots
return (
<div class="with-load-more">
<WrappedComponent {...props}>{children}</WrappedComponent>
<div class="with-load-more-footer">
{this.error && (
<button
onClick={this.fetchEntries}
class="button-unstyled -link -fullwidth alert error"
>
{this.$t('general.generic_error')}
</button>
)}
{!this.error && this.loading && (
<FAIcon spin icon="circle-notch" />
)}
{!this.error && !this.loading && !this.bottomedOut && (
<a onClick={this.fetchEntries} role="button" tabindex="0">
{this.$t('general.more')}
</a>
)}
</div>
</div>
)
},
}
}
export default withLoadMore

View file

@ -1,16 +0,0 @@
.with-load-more {
&-footer {
padding: 0.9em;
text-align: center;
border-top: 1px solid;
border-top-color: var(--border);
.error {
font-size: 1rem;
}
a {
cursor: pointer;
}
}
}

View file

@ -1,94 +0,0 @@
// eslint-disable-next-line no-unused
import { isEmpty } from 'lodash'
import { h } from 'vue'
import { getComponentProps } from '../../services/component_utils/component_utils'
import './with_subscription.scss'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
library.add(faCircleNotch)
const withSubscription =
({
fetch, // function to fetch entries and return a promise
select, // function to select data from store
childPropName = 'content', // name of the prop to be passed into the wrapped component
additionalPropNames = [], // additional prop name list of the wrapper component
}) =>
(WrappedComponent) => {
const originalProps = Object.keys(getComponentProps(WrappedComponent))
const props = originalProps
.filter((v) => v !== childPropName)
.concat(additionalPropNames)
return {
props: [
...props,
'refresh', // boolean saying to force-fetch data whenever created
],
data() {
return {
loading: false,
error: false,
}
},
computed: {
fetchedData() {
return select(this.$props, this.$store)
},
},
created() {
if (this.refresh || isEmpty(this.fetchedData)) {
this.fetchData()
}
},
methods: {
fetchData() {
if (!this.loading) {
this.loading = true
this.error = false
fetch(this.$props, this.$store)
.then(() => {
this.loading = false
})
.catch(() => {
this.error = true
this.loading = false
})
}
},
},
render() {
if (!this.error && !this.loading) {
const props = {
...this.$props,
[childPropName]: this.fetchedData,
}
const children = this.$slots
return (
<div class="with-subscription">
<WrappedComponent {...props}>{children}</WrappedComponent>
</div>
)
} else {
return (
<div class="with-subscription-loading">
{this.error ? (
<a onClick={this.fetchData} class="alert error">
{this.$t('general.generic_error')}
</a>
) : (
<FAIcon spin icon="circle-notch" />
)}
</div>
)
}
},
}
}
export default withSubscription

View file

@ -1,10 +0,0 @@
.with-subscription {
&-loading {
padding: 0.7em;
text-align: center;
.error {
font-size: 1rem;
}
}
}

View file

@ -86,6 +86,7 @@
"apply": "Apply",
"submit": "Submit",
"more": "More",
"no_more": "No more items",
"loading": "Loading…",
"generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
@ -587,6 +588,9 @@
"move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"domain_mutes2": "Excluded domains",
"user_mutes2": "Muted users",
"user_blocks": "Blocked users",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels. Recommended aspect ratio is 1:1",
"banner_size_instruction": "The recommended minimum size for banner images is 450x150 pixels. Recommended aspect ratio is 3:1",
"pad_emoji": "Pad emoji with spaces when adding from picker",