Merge branch 'rc/2.0.3' into 'master'

Update MASTER with 2.0.3 for real

See merge request pleroma/pleroma-fe!1099
This commit is contained in:
Shpuld Shpludson 2020-05-02 14:17:27 +00:00
commit a0f780c455
14 changed files with 242 additions and 57 deletions

View file

@ -2,8 +2,20 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Changed
- Removed the use of with_move parameters when fetching notifications
## [2.0.3] - 2020-05-02
### Fixed
- Show more/less works correctly with auto-collapsed subjects and long posts
- RTL characters won't look messed up in notifications
### Changed
- Emoji autocomplete will match any part of the word and not just start, for example :drool will now helpfully suggest :blobcatdrool: and :blobcatdroolreach:
### Add
- Follow request notification support
## [2.0.2] - 2020-04-08 ## [2.0.2] - 2020-04-08
### Fixed ### Fixed

View file

@ -29,17 +29,29 @@ export default data => input => {
export const suggestEmoji = emojis => input => { export const suggestEmoji = emojis => input => {
const noPrefix = input.toLowerCase().substr(1) const noPrefix = input.toLowerCase().substr(1)
return emojis return emojis
.filter(({ displayText }) => displayText.toLowerCase().startsWith(noPrefix)) .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
.sort((a, b) => { .sort((a, b) => {
let aScore = 0 let aScore = 0
let bScore = 0 let bScore = 0
// Make custom emojis a priority // An exact match always wins
aScore += a.imageUrl ? 10 : 0 aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
bScore += b.imageUrl ? 10 : 0 bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
// Sort alphabetically // Prioritize custom emoji a lot
const alphabetically = a.displayText > b.displayText ? 1 : -1 aScore += a.imageUrl ? 100 : 0
bScore += b.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
// Sort by length
aScore -= a.displayText.length
bScore -= b.displayText.length
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
return bScore - aScore + alphabetically return bScore - aScore + alphabetically
}) })

View file

@ -1,4 +1,5 @@
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import { notificationsFromStore } from '../../services/notification_utils/notification_utils.js'
const FollowRequestCard = { const FollowRequestCard = {
props: ['user'], props: ['user'],
@ -6,13 +7,32 @@ const FollowRequestCard = {
BasicUserCard BasicUserCard
}, },
methods: { methods: {
findFollowRequestNotificationId () {
const notif = notificationsFromStore(this.$store).find(
(notif) => notif.from_profile.id === this.user.id && notif.type === 'follow_request'
)
return notif && notif.id
},
approveUser () { approveUser () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id }) this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user) this.$store.dispatch('removeFollowRequest', this.user)
const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
this.$store.dispatch('updateNotification', {
id: notifId,
updater: notification => {
notification.type = 'follow'
}
})
}, },
denyUser () { denyUser () {
const notifId = this.findFollowRequestNotificationId()
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id }) this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user) .then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('removeFollowRequest', this.user)
})
} }
} }
} }

View file

@ -2,6 +2,7 @@ import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue' import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -32,6 +33,24 @@ const Notification = {
}, },
toggleMute () { toggleMute () {
this.unmuted = !this.unmuted this.unmuted = !this.unmuted
},
approveUser () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id })
this.$store.dispatch('updateNotification', {
id: this.notification.id,
updater: notification => {
notification.type = 'follow'
}
})
},
denyUser () {
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id })
this.$store.dispatch('removeFollowRequest', this.user)
})
} }
}, },
computed: { computed: {
@ -57,6 +76,9 @@ const Notification = {
}, },
needMute () { needMute () {
return this.user.muted return this.user.muted
},
isStatusNotification () {
return isStatusNotification(this.notification.type)
} }
} }
} }

View file

@ -47,7 +47,7 @@
<span class="notification-details"> <span class="notification-details">
<div class="name-and-action"> <div class="name-and-action">
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<span <bdi
v-if="!!notification.from_profile.name_html" v-if="!!notification.from_profile.name_html"
class="username" class="username"
:title="'@'+notification.from_profile.screen_name" :title="'@'+notification.from_profile.screen_name"
@ -74,6 +74,10 @@
<i class="fa icon-user-plus lit" /> <i class="fa icon-user-plus lit" />
<small>{{ $t('notifications.followed_you') }}</small> <small>{{ $t('notifications.followed_you') }}</small>
</span> </span>
<span v-if="notification.type === 'follow_request'">
<i class="fa icon-user lit" />
<small>{{ $t('notifications.follow_request') }}</small>
</span>
<span v-if="notification.type === 'move'"> <span v-if="notification.type === 'move'">
<i class="fa icon-arrow-curved lit" /> <i class="fa icon-arrow-curved lit" />
<small>{{ $t('notifications.migrated_to') }}</small> <small>{{ $t('notifications.migrated_to') }}</small>
@ -87,18 +91,7 @@
</span> </span>
</div> </div>
<div <div
v-if="notification.type === 'follow' || notification.type === 'move'" v-if="isStatusNotification"
class="timeago"
>
<span class="faint">
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</span>
</div>
<div
v-else
class="timeago" class="timeago"
> >
<router-link <router-link
@ -112,6 +105,17 @@
/> />
</router-link> </router-link>
</div> </div>
<div
v-else
class="timeago"
>
<span class="faint">
<Timeago
:time="notification.created_at"
:auto-update="240"
/>
</span>
</div>
<a <a
v-if="needMute" v-if="needMute"
href="#" href="#"
@ -119,12 +123,30 @@
><i class="button-icon icon-eye-off" /></a> ><i class="button-icon icon-eye-off" /></a>
</span> </span>
<div <div
v-if="notification.type === 'follow'" v-if="notification.type === 'follow' || notification.type === 'follow_request'"
class="follow-text" class="follow-text"
> >
<router-link :to="userProfileLink"> <router-link
:to="userProfileLink"
class="follow-name"
>
@{{ notification.from_profile.screen_name }} @{{ notification.from_profile.screen_name }}
</router-link> </router-link>
<div
v-if="notification.type === 'follow_request'"
style="white-space: nowrap;"
>
<i
class="icon-ok button-icon follow-request-accept"
:title="$t('tool_tip.accept_follow_request')"
@click="approveUser()"
/>
<i
class="icon-cancel button-icon follow-request-reject"
:title="$t('tool_tip.reject_follow_request')"
@click="denyUser()"
/>
</div>
</div> </div>
<div <div
v-else-if="notification.type === 'move'" v-else-if="notification.type === 'move'"

View file

@ -79,9 +79,38 @@
} }
} }
.follow-request-accept {
cursor: pointer;
&:hover {
color: $fallback--text;
color: var(--text, $fallback--text);
}
}
.follow-request-reject {
cursor: pointer;
&:hover {
color: $fallback--cRed;
color: var(--cRed, $fallback--cRed);
}
}
.follow-text, .move-text { .follow-text, .move-text {
padding: 0.5em 0; padding: 0.5em 0;
overflow-wrap: break-word; overflow-wrap: break-word;
display: flex;
justify-content: space-between;
.follow-name {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
} }
.status-el { .status-el {
@ -143,6 +172,11 @@
color: var(--cGreen, $fallback--cGreen); color: var(--cGreen, $fallback--cGreen);
} }
.icon-user.lit {
color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue);
}
.icon-user-plus.lit { .icon-user-plus.lit {
color: $fallback--cBlue; color: $fallback--cBlue;
color: var(--cBlue, $fallback--cBlue); color: var(--cBlue, $fallback--cBlue);

View file

@ -188,23 +188,22 @@ const Status = {
} }
return this.status.attentions.length > 0 return this.status.attentions.length > 0
}, },
// When a status has a subject and is also tall, we should only have one show more/less button. If the default is to collapse statuses with subjects, we just treat it like a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject () {
return this.status.summary && (!this.tallStatus || this.localCollapseSubjectDefault)
},
mightHideBecauseTall () {
return this.tallStatus && (!this.status.summary || !this.localCollapseSubjectDefault)
},
hideSubjectStatus () { hideSubjectStatus () {
if (this.tallStatus && !this.localCollapseSubjectDefault) { return this.mightHideBecauseSubject && !this.expandingSubject
return false
}
return !this.expandingSubject && this.status.summary
}, },
hideTallStatus () { hideTallStatus () {
if (this.status.summary && this.localCollapseSubjectDefault) { return this.mightHideBecauseTall && !this.showingTall
return false
}
if (this.showingTall) {
return false
}
return this.tallStatus
}, },
showingMore () { showingMore () {
return (this.tallStatus && this.showingTall) || (this.status.summary && this.expandingSubject) return (this.mightHideBecauseTall && this.showingTall) || (this.mightHideBecauseSubject && this.expandingSubject)
}, },
nsfwClickthrough () { nsfwClickthrough () {
if (!this.status.nsfw) { if (!this.status.nsfw) {
@ -408,14 +407,10 @@ const Status = {
this.userExpanded = !this.userExpanded this.userExpanded = !this.userExpanded
}, },
toggleShowMore () { toggleShowMore () {
if (this.showingTall) { if (this.mightHideBecauseTall) {
this.showingTall = false this.showingTall = !this.showingTall
} else if (this.expandingSubject && this.status.summary) { } else if (this.mightHideBecauseSubject) {
this.expandingSubject = false this.expandingSubject = !this.expandingSubject
} else if (this.hideTallStatus) {
this.showingTall = true
} else if (this.hideSubjectStatus && this.status.summary) {
this.expandingSubject = true
} }
}, },
generateUserProfileLink (id, name) { generateUserProfileLink (id, name) {

View file

@ -124,6 +124,7 @@
"broken_favorite": "Unknown status, searching for it...", "broken_favorite": "Unknown status, searching for it...",
"favorited_you": "favorited your status", "favorited_you": "favorited your status",
"followed_you": "followed you", "followed_you": "followed you",
"follow_request": "wants to follow you",
"load_older": "Load older notifications", "load_older": "Load older notifications",
"notifications": "Notifications", "notifications": "Notifications",
"read": "Read!", "read": "Read!",
@ -697,7 +698,9 @@
"reply": "Reply", "reply": "Reply",
"favorite": "Favorite", "favorite": "Favorite",
"add_reaction": "Add Reaction", "add_reaction": "Add Reaction",
"user_settings": "User Settings" "user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request"
}, },
"upload":{ "upload":{
"error": { "error": {

View file

@ -34,7 +34,8 @@ export const defaultState = {
likes: true, likes: true,
repeats: true, repeats: true,
moves: true, moves: true,
emojiReactions: false emojiReactions: false,
followRequest: true
}, },
webPushNotifications: false, webPushNotifications: false,
muteWords: [], muteWords: [],

View file

@ -13,6 +13,7 @@ import {
omitBy omitBy
} from 'lodash' } from 'lodash'
import { set } from 'vue' import { set } from 'vue'
import { isStatusNotification } from '../services/notification_utils/notification_utils.js'
import apiService from '../services/api/api.service.js' import apiService from '../services/api/api.service.js'
// import parse from '../services/status_parser/status_parser.js' // import parse from '../services/status_parser/status_parser.js'
@ -321,7 +322,7 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes, rootGetters }) => { const addNewNotifications = (state, { dispatch, notifications, older, visibleNotificationTypes, rootGetters }) => {
each(notifications, (notification) => { each(notifications, (notification) => {
if (notification.type !== 'follow' && notification.type !== 'move') { if (isStatusNotification(notification.type)) {
notification.action = addStatusToGlobalStorage(state, notification.action).item notification.action = addStatusToGlobalStorage(state, notification.action).item
notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item notification.status = notification.status && addStatusToGlobalStorage(state, notification.status).item
} }
@ -361,13 +362,16 @@ const addNewNotifications = (state, { dispatch, notifications, older, visibleNot
case 'move': case 'move':
i18nString = 'migrated_to' i18nString = 'migrated_to'
break break
case 'follow_request':
i18nString = 'follow_request'
break
} }
if (notification.type === 'pleroma:emoji_reaction') { if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = rootGetters.i18n.t('notifications.reacted_with', [notification.emoji]) notifObj.body = rootGetters.i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) { } else if (i18nString) {
notifObj.body = rootGetters.i18n.t('notifications.' + i18nString) notifObj.body = rootGetters.i18n.t('notifications.' + i18nString)
} else { } else if (isStatusNotification(notification.type)) {
notifObj.body = notification.status.text notifObj.body = notification.status.text
} }
@ -521,6 +525,17 @@ export const mutations = {
notification.seen = true notification.seen = true
}) })
}, },
markSingleNotificationAsSeen (state, { id }) {
const notification = find(state.notifications.data, n => n.id === id)
if (notification) notification.seen = true
},
dismissNotification (state, { id }) {
state.notifications.data = state.notifications.data.filter(n => n.id !== id)
},
updateNotification (state, { id, updater }) {
const notification = find(state.notifications.data, n => n.id === id)
notification && updater(notification)
},
queueFlush (state, { timeline, id }) { queueFlush (state, { timeline, id }) {
state.timelines[timeline].flushMarker = id state.timelines[timeline].flushMarker = id
}, },
@ -680,6 +695,24 @@ const statuses = {
credentials: rootState.users.currentUser.credentials credentials: rootState.users.currentUser.credentials
}) })
}, },
markSingleNotificationAsSeen ({ rootState, commit }, { id }) {
commit('markSingleNotificationAsSeen', { id })
apiService.markNotificationsAsSeen({
single: true,
id,
credentials: rootState.users.currentUser.credentials
})
},
dismissNotificationLocal ({ rootState, commit }, { id }) {
commit('dismissNotification', { id })
},
dismissNotification ({ rootState, commit }, { id }) {
commit('dismissNotification', { id })
rootState.api.backendInteractor.dismissNotification({ id })
},
updateNotification ({ rootState, commit }, { id, updater }) {
commit('updateNotification', { id, updater })
},
fetchFavsAndRepeats ({ rootState, commit }, id) { fetchFavsAndRepeats ({ rootState, commit }, id) {
Promise.all([ Promise.all([
rootState.api.backendInteractor.fetchFavoritedByUsers({ id }), rootState.api.backendInteractor.fetchFavoritedByUsers({ id }),

View file

@ -4,7 +4,6 @@ import 'whatwg-fetch'
import { RegistrationError, StatusCodeError } from '../errors/errors' import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */ /* eslint-env browser */
const QVITTER_USER_NOTIFICATIONS_READ_URL = '/api/qvitter/statuses/notifications/read.json'
const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import' const BLOCKS_IMPORT_URL = '/api/pleroma/blocks_import'
const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import' const FOLLOW_IMPORT_URL = '/api/pleroma/follow_import'
const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account' const DELETE_ACCOUNT_URL = '/api/pleroma/delete_account'
@ -17,6 +16,7 @@ const DEACTIVATE_USER_URL = '/api/pleroma/admin/users/deactivate'
const ADMIN_USERS_URL = '/api/pleroma/admin/users' const ADMIN_USERS_URL = '/api/pleroma/admin/users'
const SUGGESTIONS_URL = '/api/v1/suggestions' const SUGGESTIONS_URL = '/api/v1/suggestions'
const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings' const NOTIFICATION_SETTINGS_URL = '/api/pleroma/notification_settings'
const NOTIFICATION_READ_URL = '/api/v1/pleroma/notifications/read'
const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa' const MFA_SETTINGS_URL = '/api/pleroma/accounts/mfa'
const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes' const MFA_BACKUP_CODES_URL = '/api/pleroma/accounts/mfa/backup_codes'
@ -29,6 +29,7 @@ const MASTODON_LOGIN_URL = '/api/v1/accounts/verify_credentials'
const MASTODON_REGISTRATION_URL = '/api/v1/accounts' const MASTODON_REGISTRATION_URL = '/api/v1/accounts'
const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites' const MASTODON_USER_FAVORITES_TIMELINE_URL = '/api/v1/favourites'
const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications' const MASTODON_USER_NOTIFICATIONS_URL = '/api/v1/notifications'
const MASTODON_DISMISS_NOTIFICATION_URL = id => `/api/v1/notifications/${id}/dismiss`
const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite` const MASTODON_FAVORITE_URL = id => `/api/v1/statuses/${id}/favourite`
const MASTODON_UNFAVORITE_URL = id => `/api/v1/statuses/${id}/unfavourite` const MASTODON_UNFAVORITE_URL = id => `/api/v1/statuses/${id}/unfavourite`
const MASTODON_RETWEET_URL = id => `/api/v1/statuses/${id}/reblog` const MASTODON_RETWEET_URL = id => `/api/v1/statuses/${id}/reblog`
@ -540,7 +541,7 @@ const fetchTimeline = ({
params.push(['with_move', withMove]) params.push(['with_move', withMove])
} }
params.push(['count', 20]) params.push(['limit', 20])
params.push(['with_muted', withMuted]) params.push(['with_muted', withMuted])
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&') const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
@ -844,12 +845,16 @@ const suggestions = ({ credentials }) => {
}).then((data) => data.json()) }).then((data) => data.json())
} }
const markNotificationsAsSeen = ({ id, credentials }) => { const markNotificationsAsSeen = ({ id, credentials, single = false }) => {
const body = new FormData() const body = new FormData()
body.append('latest_id', id) if (single) {
body.append('id', id)
} else {
body.append('max_id', id)
}
return fetch(QVITTER_USER_NOTIFICATIONS_READ_URL, { return fetch(NOTIFICATION_READ_URL, {
body, body,
headers: authHeaders(credentials), headers: authHeaders(credentials),
method: 'POST' method: 'POST'
@ -1010,6 +1015,15 @@ const unmuteDomain = ({ domain, credentials }) => {
}) })
} }
const dismissNotification = ({ credentials, id }) => {
return promisedRequest({
url: MASTODON_DISMISS_NOTIFICATION_URL(id),
method: 'POST',
payload: { id },
credentials
})
}
export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => { export const getMastodonSocketURI = ({ credentials, stream, args = {} }) => {
return Object.entries({ return Object.entries({
...(credentials ...(credentials
@ -1165,6 +1179,7 @@ const apiService = {
denyUser, denyUser,
suggestions, suggestions,
markNotificationsAsSeen, markNotificationsAsSeen,
dismissNotification,
vote, vote,
fetchPoll, fetchPoll,
fetchFavoritedByUsers, fetchFavoritedByUsers,

View file

@ -1,4 +1,5 @@
import escape from 'escape-html' import escape from 'escape-html'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
const qvitterStatusType = (status) => { const qvitterStatusType = (status) => {
if (status.is_post_verb) { if (status.is_post_verb) {
@ -346,9 +347,7 @@ export const parseNotification = (data) => {
if (masto) { if (masto) {
output.type = mastoDict[data.type] || data.type output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen output.seen = data.pleroma.is_seen
output.status = output.type === 'follow' || output.type === 'move' output.status = isStatusNotification(output.type) ? parseStatus(data.status) : null
? null
: parseStatus(data.status)
output.action = output.status // TODO: Refactor, this is unneeded output.action = output.status // TODO: Refactor, this is unneeded
output.target = output.type !== 'move' output.target = output.type !== 'move'
? null ? null

View file

@ -1,4 +1,4 @@
import { filter, sortBy } from 'lodash' import { filter, sortBy, includes } from 'lodash'
export const notificationsFromStore = store => store.state.statuses.notifications.data export const notificationsFromStore = store => store.state.statuses.notifications.data
@ -7,10 +7,15 @@ export const visibleTypes = store => ([
store.state.config.notificationVisibility.mentions && 'mention', store.state.config.notificationVisibility.mentions && 'mention',
store.state.config.notificationVisibility.repeats && 'repeat', store.state.config.notificationVisibility.repeats && 'repeat',
store.state.config.notificationVisibility.follows && 'follow', store.state.config.notificationVisibility.follows && 'follow',
store.state.config.notificationVisibility.followRequest && 'follow_request',
store.state.config.notificationVisibility.moves && 'move', store.state.config.notificationVisibility.moves && 'move',
store.state.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction' store.state.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
].filter(_ => _)) ].filter(_ => _))
const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction']
export const isStatusNotification = (type) => includes(statusNotifications, type)
const sortById = (a, b) => { const sortById = (a, b) => {
const seqA = Number(a.id) const seqA = Number(a.id)
const seqB = Number(b.id) const seqB = Number(b.id)

View file

@ -345,6 +345,18 @@
"css": "link", "css": "link",
"code": 59427, "code": 59427,
"src": "fontawesome" "src": "fontawesome"
},
{
"uid": "8b80d36d4ef43889db10bc1f0dc9a862",
"css": "user",
"code": 59428,
"src": "fontawesome"
},
{
"uid": "12f4ece88e46abd864e40b35e05b11cd",
"css": "ok",
"code": 59431,
"src": "fontawesome"
} }
] ]
} }