Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85805277
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
44 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index b641600ef0..c4e219724c 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -1,237 +1,237 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Report from 'src/components/report/report.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
-import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
+import { isStatusNotification } from '../../services/notification_utils/notification_utils_sw.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { useUsersStore } from 'src/stores/users.js'
import { approveUser, denyUser } from 'src/api/user.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faCheck,
faCompressAlt,
faExpandAlt,
faEyeSlash,
faRetweet,
faStar,
faSuitcaseRolling,
faTimes,
faUser,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faCheck,
faTimes,
faStar,
faRetweet,
faUserPlus,
faUser,
faEyeSlash,
faSuitcaseRolling,
faExpandAlt,
faCompressAlt,
)
const Notification = {
data() {
return {
selecting: false,
statusExpanded: false,
unmuted: false,
showingApproveConfirmDialog: false,
showingDenyConfirmDialog: false,
}
},
props: ['notification'],
emits: ['interacted'],
components: {
StatusContent,
UserAvatar,
Timeago,
Report,
UserPopover,
UserLink,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
mounted() {
document.addEventListener('selectionchange', this.onContentSelect)
},
unmounted() {
document.removeEventListener('selectionchange', this.onContentSelect)
},
methods: {
toggleStatusExpanded() {
if (!this.expandable) return
this.statusExpanded = !this.statusExpanded
},
onContentSelect() {
const { isCollapsed, anchorNode, offsetNode } = document.getSelection()
if (isCollapsed) {
this.selecting = false
return
}
const within =
this.$refs.root.contains(anchorNode) ||
this.$refs.root.contains(offsetNode)
if (within) {
this.selecting = true
} else {
this.selecting = false
}
},
onContentClick(e) {
if (
!this.selecting &&
!e.target.closest('a') &&
!e.target.closest('button')
) {
this.toggleStatusExpanded()
}
},
generateUserProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
getUser(notification) {
return this.$store.state.users.usersObject[notification.from_profile.id]
},
interacted() {
this.$emit('interacted')
},
toggleMute() {
this.unmuted = !this.unmuted
},
showApproveConfirmDialog() {
this.showingApproveConfirmDialog = true
},
hideApproveConfirmDialog() {
this.showingApproveConfirmDialog = false
},
showDenyConfirmDialog() {
this.showingDenyConfirmDialog = true
},
hideDenyConfirmDialog() {
this.showingDenyConfirmDialog = false
},
approveUser() {
if (this.shouldConfirmApprove) {
this.showApproveConfirmDialog()
} else {
this.doApprove()
}
},
doApprove() {
approveUser({
id: this.user.id,
credentials: useOAuthStore().token,
})
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'
},
})
this.hideApproveConfirmDialog()
},
denyUser() {
if (this.shouldConfirmDeny) {
this.showDenyConfirmDialog()
} else {
this.doDeny()
}
},
doDeny() {
denyUser({
id: this.user.id,
credentials: useOAuthStore().token,
}).then(() => {
this.$store.dispatch('dismissNotificationLocal', {
id: this.notification.id,
})
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
},
},
computed: {
userClass() {
return highlightClass(this.notification.from_profile)
},
userStyle() {
const user = this.notification.from_profile.screen_name
return highlightStyle(useUserHighlightStore().get(user))
},
expandable() {
return new Set(['like', 'pleroma:emoji_reaction', 'repeat', 'poll']).has(
this.notification.type,
)
},
user() {
return useUsersStore().findUser(this.notification.from_profile.id)
},
userProfileLink() {
return this.generateUserProfileLink(this.user)
},
targetUser() {
return useUsersStore().findUser(this.notification.target.id)
},
targetUserProfileLink() {
return this.generateUserProfileLink(this.targetUser)
},
needMute() {
return useUsersStore().relationship(this.user.id).muting
},
isStatusNotification() {
return isStatusNotification(this.notification.type)
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
shouldConfirmApprove() {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny() {
return this.mergedConfig.modalOnDenyFollow
},
...mapState(useUsersStore, ['currentUser']),
},
}
export default Notification
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index c893f135c2..0b5370a063 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,428 +1,428 @@
import { parseLinkHeader } from '@web3-storage/parse-link-header'
import escapeHtml from 'escape-html'
import { unescape as lodashUnescape } from 'lodash'
import punycode from 'punycode.js'
import { fileType } from '../file_type/file_type.service.js'
-import { isStatusNotification } from '../notification_utils/notification_utils.js'
+import { isStatusNotification } from '../notification_utils/notification_utils_sw.js'
/** NOTICE! **
* Do not initialize UI-generated data here.
* It will override existing data.
*
* i.e. user.pinnedStatusIds was set to [] here
* UI code would update it with data but upon next user fetch
* it would be reverted back to []
*/
export const parseUser = (data) => {
const output = {}
output._original = data // used for server-side settings
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = !Object.hasOwn(data, 'avatar')
output.inLists = null
output.id = String(data.id)
output.screen_name = data.acct
output.fqn = data.fqn
output.url = data.url
output.statusnet_profile_url = data.url
if (Object.hasOwn(data, 'mute_expires_at')) {
output.mute_expires_at =
data.mute_expires_at == null ? false : data.mute_expires_at
}
if (Object.hasOwn(data, 'block_expires_at')) {
output.block_expires_at =
data.block_expires_at == null ? false : data.block_expires_at
}
// There's nothing else to get
if (mastoShort) {
return output
}
output.emoji = data.emojis
output.name = escapeHtml(data.display_name)
output.name_html = output.name
output.name_unescaped = data.display_name
output.description = data.note
// TODO cleanup this shit, output.description is overriden with source data
output.description_html = data.note
output.fields = data.fields
output.fields_html = data.fields.map((field) => {
return {
name: escapeHtml(field.name),
value: field.value,
}
})
output.fields_text = data.fields.map((field) => {
return {
name: unescape(field.name.replaceAll(/<[^>]*>/g, '')),
value: unescape(field.value.replaceAll(/<[^>]*>/g, '')),
}
})
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
output.privileges = []
if (data.pleroma) {
if (data.pleroma.settings_store) {
output.storage = data.pleroma.settings_store['pleroma-fe']
output.user_highlight = data.pleroma.settings_store['user_highlight']
}
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.favicon = data.pleroma.favicon
output.token = data.pleroma.chat_token
if (relationship) {
output.relationship = relationship
}
output.allow_following_move = data.pleroma.allow_following_move
output.hide_favorites = data.pleroma.hide_favorites
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
output.hide_followers_count = data.pleroma.hide_followers_count
output.rights = {
moderator: data.pleroma.is_moderator,
admin: data.pleroma.is_admin,
}
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
if (output.rights.admin) {
output.role = 'admin'
} else if (output.rights.moderator) {
output.role = 'moderator'
} else {
output.role = 'member'
}
output.birthday = data.pleroma.birthday
if (data.pleroma.privileges) {
output.privileges = new Set(data.pleroma.privileges)
} else if (data.pleroma.is_admin) {
output.privileges = new Set([
'users_read',
'users_manage_invites',
'users_manage_activation_state',
'users_manage_tags',
'users_manage_credentials',
'users_delete',
'messages_read',
'messages_delete',
'instances_delete',
'reports_manage_reports',
'moderation_log_read',
'announcements_manage_announcements',
'emoji_manage_emoji',
'statistics_read',
])
} else if (data.pleroma.is_moderator) {
output.privileges = new Set(['messages_delete', 'reports_manage_reports'])
} else {
output.privileges = new Set()
}
}
if (data.source) {
output.description = data.source.note
output.default_scope = data.source.privacy
output.fields = data.source.fields
if (data.source.pleroma) {
output.no_rich_text = data.source.pleroma.no_rich_text
output.show_role =
typeof data.source.pleroma.show_role === 'boolean'
? data.source.pleroma.show_role
: true
output.discoverable = data.source.pleroma.discoverable
output.show_birthday = data.pleroma.show_birthday
output.actor_type = data.source.pleroma.actor_type
}
}
// TODO: handle is_local
output.is_local = !output.screen_name.includes('@')
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.last_status_at = new Date(data.last_status_at)
output.followers_count = data.followers_count
output.statuses_count = data.statuses_count
if (data.pleroma) {
output.follow_request_count = data.pleroma.follow_request_count
output.tags = new Set(data.pleroma.tags)
// deactivated was changed to is_active in Pleroma 2.3.0
// so check if is_active is present
output.deactivated =
data.pleroma.is_active !== undefined
? !data.pleroma.is_active // new backend
: data.pleroma.deactivated // old backend
output.notification_settings = data.pleroma.notification_settings
output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || new Set()
output.rights = output.rights || {}
output.notification_settings = output.notification_settings || {}
// Convert punycode to unicode for UI
output.screen_name_ui = output.screen_name
if (output.screen_name?.includes('@')) {
const parts = output.screen_name.split('@')
const unicodeDomain = punycode.toUnicode(parts[1])
if (unicodeDomain !== parts[1]) {
// Add some identifier so users can potentially spot spoofing attempts:
// lain.com and xn--lin-6cd.com would appear identical otherwise.
output.screen_name_ui_contains_non_ascii = true
output.screen_name_ui = [parts[0], unicodeDomain].join('@')
} else {
output.screen_name_ui_contains_non_ascii = false
}
}
return output
}
export const parseAttachment = (data) => {
const output = {}
// Not exactly same...
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet
output.id = data.id
if (data.type !== 'unknown') {
// treat gifv like it is "video"
output.type = data.type === 'gifv' ? 'video' : data.type
} else {
output.type = fileType(output.mimetype)
}
output.url = data.url
output.large_thumb_url = data.preview_url
output.description = lodashUnescape(data.description)
return output
}
export const parseSource = (data) => {
const output = {}
output.text = data.text
output.spoiler_text = data.spoiler_text
output.content_type = data.content_type
return output
}
export const parseStatus = (data) => {
const output = {}
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
output.raw_html = data.content
output.emojis = data.emojis
output.tags = data.tags
output.edited_at = data.edited_at
const { pleroma } = data
if (data.pleroma) {
output.text = pleroma.content
? data.pleroma.content['text/plain']
: data.content
output.summary = pleroma.spoiler_text
? data.pleroma.spoiler_text['text/plain']
: data.spoiler_text
output.statusnet_conversation_id = data.pleroma.conversation_id
output.is_local = pleroma.local
output.in_reply_to_screen_name = pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible =
pleroma.parent_visible === undefined ? true : pleroma.parent_visible
output.quote_visible = pleroma.quote_visible || true
output.quotes_count = pleroma.quotes_count
output.bookmark_folder_id = pleroma.bookmark_folder
} else {
output.text = data.content
output.summary = data.spoiler_text
}
const quoteRaw = pleroma?.quote || data.quote
const quoteData = quoteRaw ? parseStatus(quoteRaw) : undefined
output.quote = quoteData
output.quote_id =
data.quote?.id ?? data.quote_id ?? quoteData?.id ?? pleroma?.quote_id
output.quote_url = data.quote?.url ?? quoteData?.url ?? pleroma?.quote_url
output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id
output.replies_count = data.replies_count
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
output.summary_raw_html = escapeHtml(data.spoiler_text)
output.external_url = data.uri || data.url
output.poll = data.poll
if (output.poll) {
output.poll.options = (output.poll.options || []).map((field) => ({
...field,
title_html: escapeHtml(field.title),
}))
}
output.pinned = data.pinned
output.muted = data.muted
output.id = String(data.id)
output.visibility = data.visibility
output.card = data.card
output.created_at = new Date(data.created_at)
// Converting to string, the right way.
output.in_reply_to_status_id = output.in_reply_to_status_id
? String(output.in_reply_to_status_id)
: null
output.in_reply_to_user_id = output.in_reply_to_user_id
? String(output.in_reply_to_user_id)
: null
output.user = parseUser(data.account)
output.attentions = (data.mentions || []).map(parseUser)
output.attachments = (data.media_attachments || []).map(parseAttachment)
output.deleted = false
const retweetedStatus = data.reblog
if (retweetedStatus) {
output.retweeted_status = parseStatus(retweetedStatus)
}
output.favoritedBy = []
output.rebloggedBy = []
if (Object.hasOwn(data, 'originalStatus')) {
Object.assign(output, data.originalStatus)
}
return output
}
export const parseNotification = (data) => {
const mastoDict = {
favourite: 'like',
reblog: 'repeat',
}
const output = {}
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
// TODO: null check should be a temporary fix, I guess.
// Investigate why backend does this.
output.status =
isStatusNotification(output.type) && data.status !== null
? parseStatus(data.status)
: null
output.target = output.type !== 'move' ? null : parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
output.emoji_url = data.emoji_url
if (data.report) {
output.report = data.report
output.report.content = data.report.content
output.report.acct = parseUser(data.report.account)
output.report.actor = parseUser(data.report.actor)
output.report.statuses = data.report.statuses.map(parseStatus)
}
output.created_at = new Date(data.created_at)
output.id = Number.parseInt(data.id)
return output
}
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
const flakeId = opts.flakeId
const parsedLinkHeader = parseLinkHeader(linkHeader)
if (!parsedLinkHeader) return
const maxId = parsedLinkHeader.next?.max_id
const minId = parsedLinkHeader.prev?.min_id
return {
maxId: flakeId ? maxId : Number.parseInt(maxId, 10),
minId: flakeId ? minId : Number.parseInt(minId, 10),
}
}
export const parseChat = (chat) => {
const output = {}
output.id = chat.id
output.account = parseUser(chat.account)
output.unread = chat.unread
output.lastMessage = parseChatMessage(chat.last_message)
output.updated_at = new Date(chat.updated_at)
return output
}
export const parseChatMessage = (message) => {
if (!message) {
return
}
if (message.isNormalized) {
return message
}
const output = message
output.id = message.id
output.created_at = new Date(message.created_at)
output.chat_id = message.chat_id
output.emojis = message.emojis
output.content = message.content
if (message.attachment) {
output.attachments = [parseAttachment(message.attachment)]
} else {
output.attachments = []
}
output.pending = !!message.pending
output.error = false
output.idempotency_key = message.idempotency_key
output.isNormalized = true
return output
}
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 07433497a7..5157504b26 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -1,196 +1,118 @@
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
import { muteFilterHits } from '../status_parser/status_parser.js'
+import { prepareNotificationObject, isStatusNotification } from './notification_utils_sw.js'
import { useNotificationsStore } from 'src/stores/notifications.js'
-import FaviconService from 'src/services/favicon_service/favicon_service.js'
-
export const ACTIONABLE_NOTIFICATION_TYPES = new Set([
'mention',
'pleroma:report',
'follow_request',
])
-let cachedBadgeUrl = null
-
const visibleTypes = (notificationVisibility) => {
return [
notificationVisibility.likes && 'like',
notificationVisibility.mentions && 'mention',
notificationVisibility.statuses && 'status',
notificationVisibility.repeats && 'repeat',
notificationVisibility.follows && 'follow',
notificationVisibility.followRequest && 'follow_request',
notificationVisibility.moves && 'move',
notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
notificationVisibility.reports && 'pleroma:report',
notificationVisibility.polls && 'poll',
].filter(Boolean)
}
-const statusNotifications = new Set([
- 'like',
- 'mention',
- 'status',
- 'repeat',
- 'pleroma:emoji_reaction',
- 'poll',
-])
-
-export const isStatusNotification = (type) => statusNotifications.has(type)
-
export const isValidNotification = (notification) => {
if (isStatusNotification(notification.type) && !notification.status) {
return false
}
return true
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const isMutedNotification = (muteFilters, notification) => {
if (!notification.status) return false
if (notification.status.muted) return true
return muteFilterHits(muteFilters, notification.status).length > 0
}
export const maybeShowNotification = (
notificationVisibility,
muteFilters,
notification,
i18n,
) => {
if (notification.seen) return
if (!visibleTypes(notificationVisibility).includes(notification.type)) return
if (
notification.type === 'mention' &&
isMutedNotification(muteFilters, notification)
)
return
const notificationObject = prepareNotificationObject(notification, i18n)
showDesktopNotification(notificationObject)
}
export const filteredNotifications = (notificationVisibility, types) => {
// map is just to clone the array since sort mutates it and it causes some issues
const sortedNotifications = useNotificationsStore().data.sort(sortById)
// TODO implement sorting elsewhere and make it optional
return sortedNotifications.filter((notification) =>
(types || visibleTypes(notificationVisibility)).includes(notification.type),
)
}
export const unseenNotifications = (
notificationVisibility,
ignoreInactionableSeen,
) => {
return filteredNotifications(notificationVisibility).filter(
({ seen, type }) => {
if (!ignoreInactionableSeen) return !seen
if (seen) return false
return ACTIONABLE_NOTIFICATION_TYPES.has(type)
},
)
}
-export const prepareNotificationObject = (notification, i18n) => {
- if (cachedBadgeUrl === null) {
- const favicons = FaviconService.getOriginalFavicons()
- const favicon = favicons[favicons.length - 1]
- if (!favicon) {
- cachedBadgeUrl = 'about:blank'
- } else {
- cachedBadgeUrl = favicon.favimg.src
- }
- }
-
- const notifObj = {
- tag: notification.id,
- type: notification.type,
- badge: cachedBadgeUrl,
- }
- const status = notification.status
- const title = notification.from_profile.name
- notifObj.title = title
- notifObj.icon = notification.from_profile.profile_image_url
- let i18nString
- switch (notification.type) {
- case 'like':
- i18nString = 'favorited_you'
- break
- case 'status':
- i18nString = 'subscribed_status'
- break
- case 'repeat':
- i18nString = 'repeated_you'
- break
- case 'follow':
- i18nString = 'followed_you'
- break
- case 'move':
- i18nString = 'migrated_to'
- break
- case 'follow_request':
- i18nString = 'follow_request'
- break
- case 'pleroma:report':
- i18nString = 'submitted_report'
- break
- case 'poll':
- i18nString = 'poll_ended'
- break
- }
-
- if (notification.type === 'pleroma:emoji_reaction') {
- notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
- } else if (i18nString) {
- notifObj.body = i18n.t('notifications.' + i18nString)
- } else if (isStatusNotification(notification.type)) {
- notifObj.body = notification.status.text
- }
-
- // Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
- if (!status.nsfw && status?.attachments?.[0]?.mimetype.startsWith('image/')) {
- notifObj.image = status.attachments[0].url
- }
-
- return notifObj
-}
-
export const countExtraNotifications = (
store,
mergedConfig,
unreadChatsCount,
unreadAnnouncementCount,
) => {
const rootGetters = store.rootGetters || store.getters
if (!mergedConfig.showExtraNotifications) {
return 0
}
return [
mergedConfig.showChatsInExtraNotifications ? unreadChatsCount : 0,
mergedConfig.showAnnouncementsInExtraNotifications
? unreadAnnouncementCount
: 0,
mergedConfig.showFollowRequestsInExtraNotifications
? rootGetters.followRequestCount
: 0,
].reduce((a, c) => a + c, 0)
}
diff --git a/src/services/notification_utils/notification_utils_sw.js b/src/services/notification_utils/notification_utils_sw.js
new file mode 100644
index 0000000000..ca66df2811
--- /dev/null
+++ b/src/services/notification_utils/notification_utils_sw.js
@@ -0,0 +1,78 @@
+import FaviconService from 'src/services/favicon_service/favicon_service.js'
+
+const statusNotifications = new Set([
+ 'like',
+ 'mention',
+ 'status',
+ 'repeat',
+ 'pleroma:emoji_reaction',
+ 'poll',
+])
+
+let cachedBadgeUrl = null
+
+export const isStatusNotification = (type) => statusNotifications.has(type)
+
+export const prepareNotificationObject = (notification, i18n) => {
+ if (cachedBadgeUrl === null) {
+ const favicons = FaviconService.getOriginalFavicons()
+ const favicon = favicons[favicons.length - 1]
+ if (!favicon) {
+ cachedBadgeUrl = 'about:blank'
+ } else {
+ cachedBadgeUrl = favicon.favimg.src
+ }
+ }
+
+ const notifObj = {
+ tag: notification.id,
+ type: notification.type,
+ badge: cachedBadgeUrl,
+ }
+ const status = notification.status
+ const title = notification.from_profile.name
+ notifObj.title = title
+ notifObj.icon = notification.from_profile.profile_image_url
+ let i18nString
+ switch (notification.type) {
+ case 'like':
+ i18nString = 'favorited_you'
+ break
+ case 'status':
+ i18nString = 'subscribed_status'
+ break
+ case 'repeat':
+ i18nString = 'repeated_you'
+ break
+ case 'follow':
+ i18nString = 'followed_you'
+ break
+ case 'move':
+ i18nString = 'migrated_to'
+ break
+ case 'follow_request':
+ i18nString = 'follow_request'
+ break
+ case 'pleroma:report':
+ i18nString = 'submitted_report'
+ break
+ case 'poll':
+ i18nString = 'poll_ended'
+ break
+ }
+
+ if (notification.type === 'pleroma:emoji_reaction') {
+ notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
+ } else if (i18nString) {
+ notifObj.body = i18n.t('notifications.' + i18nString)
+ } else if (isStatusNotification(notification.type)) {
+ notifObj.body = notification.status.text
+ }
+
+ // Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
+ if (!status.nsfw && status?.attachments?.[0]?.mimetype.startsWith('image/')) {
+ notifObj.image = status.attachments[0].url
+ }
+
+ return notifObj
+}
diff --git a/src/stores/notifications.js b/src/stores/notifications.js
index 1099b0e837..c45f5b0a3a 100644
--- a/src/stores/notifications.js
+++ b/src/stores/notifications.js
@@ -1,187 +1,189 @@
import { defineStore } from 'pinia'
import {
closeAllDesktopNotifications,
closeDesktopNotification,
} from '../services/desktop_notification_utils/desktop_notification_utils.js'
import {
- isStatusNotification,
isValidNotification,
maybeShowNotification,
} from '../services/notification_utils/notification_utils.js'
+import {
+ isStatusNotification,
+} from '../services/notification_utils/notification_utils_sw.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useReportsStore } from 'src/stores/reports.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUsersStore } from 'src/stores/users.js'
import { dismissNotification, markNotificationsAsSeen } from 'src/api/user.js'
export const defaultState = () => ({
desktopNotificationSilence: true,
maxId: 0,
minId: Number.POSITIVE_INFINITY,
data: [],
statusNotificationRelations: new WeakMap(),
idStore: new Map(),
loading: false,
})
export const useNotificationsStore = defineStore('notifications', {
state: defaultState,
actions: {
clearNotifications() {
const blankState = defaultState()
Object.keys(defaultState()).forEach((k) => {
this[k] = blankState[k]
})
},
updateNotificationsMinMaxId(id) {
this.maxId = id > this.maxId ? id : this.maxId
this.minId = id < this.minId ? id : this.minId
},
setNotificationsLoading(value) {
this.loading = value
},
setNotificationsSilence(value) {
this.desktopNotificationSilence = value
},
updateNotification({ id, updater }) {
const notification = this.idStore.get(id)
notification && updater(notification)
},
addNewNotifications(result) {
const { timestamp, data: notifications } = result
useUsersStore().addNewUsers({
timestamp,
data: notifications.map((n) => n.from_profile),
})
notifications.forEach(
(n) => (n.from_profile = useUsersStore().findUser(n.from_profile.id)),
)
const validNotifications = notifications.filter((notification) => {
// If invalid notification, update ids but don't add it to store
if (!isValidNotification(notification)) {
console.error('Invalid notification:', notification)
this.updateNotificationsMinMaxId(notification.id)
return false
}
return true
})
const statusNotifications = validNotifications.filter(
(notification) =>
isStatusNotification(notification.type) && notification.status,
)
// Synchronous commit to add all the statuses
useStatusesStore().addNewStatuses({
timestamp,
statuses: statusNotifications.map(
(notification) => notification.status,
),
})
// Update references to statuses in notifications to ones in the store
statusNotifications.forEach((notification) => {
const id = notification.status.id
const referenceStatus = useStatusesStore().allStatuses.get(id)
if (referenceStatus) {
notification.status = referenceStatus
}
})
validNotifications.forEach((notification) => {
if (notification.type === 'pleroma:report') {
useReportsStore().addReport(notification.report)
}
if (notification.type === 'pleroma:emoji_reaction') {
useStatusesStore().fetchEmojiReactionsBy(notification.status.id)
}
// Only add a new notification if we don't have one for the same action
if (!this.idStore.has(notification.id)) {
this.updateNotificationsMinMaxId(notification.id)
notifications.forEach((notification) => {
this.data.push(notification)
this.idStore.set(notification.id, notification)
})
this.statusNotificationRelations.set(
notification.status,
this.idStore.get(notification.id),
)
maybeShowNotification(
useMergedConfigStore().mergedConfig.notificationVisibility,
Object.values(useSyncConfigStore().prefsStorage.simple.muteFilters),
notification,
useI18nStore().i18n,
)
} else if (notification.seen) {
this.idStore.get(notification.id).seen = true
}
})
},
notificationClicked(id) {
const notification = this.idStore.get(id)
const { type, seen } = notification
if (!seen) {
switch (type) {
case 'mention':
case 'pleroma:report':
case 'follow_request':
break
default:
this.markSingleNotificationAsSeen({ id })
}
}
},
markNotificationsAsSeen() {
this.data.forEach((notification) => {
notification.seen = true
})
markNotificationsAsSeen({
id: this.maxId,
credentials: useUsersStore().currentUser.credentials,
}).then(() => {
closeAllDesktopNotifications()
})
},
markSingleNotificationAsSeen({ id }) {
const notification = this.idStore.get(id)
if (notification) notification.seen = true
markNotificationsAsSeen({
single: true,
id,
credentials: useUsersStore().currentUser.credentials,
}).then(() => {
closeDesktopNotification(id)
})
},
dismissNotificationLocal(id) {
this.data = this.data.filter((n) => n.id !== id)
delete this.idStore.delete(id)
},
dismissNotification(id) {
this.dismissNotificationLocal(id)
dismissNotification({
id,
credentials: useOAuthStore().token,
})
},
},
})
diff --git a/src/sw.js b/src/sw.js
index 908e865673..48a4c5e600 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -1,303 +1,303 @@
/* eslint-env serviceworker */
// biome-ignore: side effect import of assets list
import 'virtual:pleroma-fe/service_worker_env'
import { createI18n } from 'vue-i18n'
import { storage } from 'src/lib/storage.js'
import { INSTANCE_DEFAULT_CONFIG } from 'src/modules/default_config_state.js'
import { parseNotification } from 'src/services/entity_normalizer/entity_normalizer.service.js'
-import { prepareNotificationObject } from 'src/services/notification_utils/notification_utils.js'
+import { prepareNotificationObject } from 'src/services/notification_utils/notification_utils_sw.js'
import { cacheKey, emojiCacheKey, shouldCache } from 'src/services/sw/sw.js'
// Collects all messages for service workers
// Needed because service workers cannot use dynamic imports
// See /build/sw_plugin.js for more information
import messages from 'virtual:pleroma-fe/service_worker_messages'
const i18n = createI18n({
// By default, use the browser locale, we will update it if neccessary
locale: 'en',
fallbackLocale: 'en',
messages,
})
const state = {
lastFocused: null,
notificationIds: new Set(),
allowedNotificationTypes: null,
}
function getWindowClients() {
return clients
.matchAll({ includeUncontrolled: true })
.then((clientList) => clientList.filter(({ type }) => type === 'window'))
}
const setSettings = async () => {
const piniaState = await storage.getItem('pinia-local-sync_config')
const locale = piniaState.prefsStorage.simple.interfaceLanguage || 'en'
i18n.locale = locale
const notificationsNativeArray = Object.entries(
piniaState.prefsStorage.simple.notificationNative ||
INSTANCE_DEFAULT_CONFIG.notificationNative,
)
state.webPushAlwaysShowNotifications =
piniaState.prefsStorage.simple.webPushAlwaysShowNotifications
state.allowedNotificationTypes = new Set(
notificationsNativeArray
.filter(([, v]) => v)
.map(([k]) => {
switch (k) {
case 'mentions':
return 'mention'
case 'statuses':
return 'status'
case 'likes':
return 'like'
case 'repeats':
return 'repeat'
case 'emojiReactions':
return 'pleroma:emoji_reaction'
case 'reports':
return 'pleroma:report'
case 'followRequest':
return 'follow_request'
case 'follows':
return 'follow'
case 'polls':
return 'poll'
default:
return k
}
}),
)
}
const showPushNotification = async (event) => {
const activeClients = await getWindowClients()
await setSettings()
// Only show push notifications if all tabs/windows are closed
if (state.webPushAlwaysShowNotifications || activeClients.length === 0) {
const data = event.data.json()
const url = `${self.registration.scope}api/v1/notifications/${data.notification_id}`
const notification = await fetch(url, {
headers: { Authorization: 'Bearer ' + data.access_token },
})
const notificationJson = await notification.json()
const parsedNotification = parseNotification(notificationJson)
const res = prepareNotificationObject(parsedNotification, i18n)
if (
state.webPushAlwaysShowNotifications ||
state.allowedNotificationTypes.has(parsedNotification.type)
) {
return self.registration.showNotification(res.title, res)
}
}
return Promise.resolve()
}
const cacheFiles = self.serviceWorkerOption.assets
const isEmoji = (req) => {
if (req.method !== 'GET') {
return false
}
const url = new URL(req.url)
return url.pathname.startsWith('/emoji/')
}
const isNotMedia = (req) => {
if (req.method !== 'GET') {
return false
}
const url = new URL(req.url)
return !url.pathname.startsWith('/media/')
}
const isAsset = (req) => {
const url = new URL(req.url)
return cacheFiles.includes(url.pathname)
}
const isSuccessful = (resp) => {
if (!resp.ok) {
return false
}
if (new URL(resp.url).pathname === '/index.html') {
// For index.html itself, there is no fallback possible.
return true
}
const type = resp.headers.get('Content-Type')
// Backend will revert to index.html if the file does not exist, so text/html for emojis and assets is a failure
return type && !type.includes('text/html')
}
self.addEventListener('install', async (event) => {
if (shouldCache) {
event.waitUntil(
(async () => {
// Do not preload i18n and emoji annotations to speed up loading
const shouldPreload = (route) => {
return (
!route.startsWith('/static/js/i18n/') &&
!route.startsWith('/static/js/emoji-annotations/')
)
}
const cache = await caches.open(cacheKey)
await Promise.allSettled(
cacheFiles.filter(shouldPreload).map(async (route) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Cache/add
// originally we used addAll() but it will raise a problem in one edge case:
// when the file for the route is not found, backend will return index.html with code 200
// but it's wrong, and it's cached, so we end up with a bad cache.
// this can happen when you refresh when you are in the process of upgrading
// the frontend.
const resp = await fetch(route)
if (isSuccessful(resp)) {
await cache.put(route, resp)
}
}),
)
})(),
)
}
})
self.addEventListener('activate', async (event) => {
if (shouldCache) {
event.waitUntil(
(async () => {
const cache = await caches.open(cacheKey)
const keys = await cache.keys()
await Promise.all(
keys
.filter((request) => {
const url = new URL(request.url)
const shouldKeep = cacheFiles.includes(url.pathname)
return !shouldKeep
})
.map((k) => cache.delete(k)),
)
})(),
)
}
})
self.addEventListener('push', async (event) => {
if (event.data) {
// Supposedly, we HAVE to return a promise inside waitUntil otherwise it will
// show (extra) notification that website is updated in background
event.waitUntil(showPushNotification(event))
}
})
self.addEventListener('message', async (event) => {
await setSettings()
const { type, content } = event.data
if (self.location.origin !== event.origin) {
console.error('SW Message with strange origin received', event)
return
}
if (type === 'desktopNotification') {
const { title, ...rest } = content
const { tag, type } = rest
if (state.notificationIds.has(tag)) return
state.notificationIds.add(tag)
setTimeout(() => state.notificationIds.delete(tag), 10000)
if (state.allowedNotificationTypes.has(type)) {
self.registration.showNotification(title, rest)
}
}
if (type === 'desktopNotificationClose') {
const { id, all } = content
const search = all ? null : { tag: id }
const notifications = await self.registration.getNotifications(search)
notifications.forEach((n) => n.close())
}
if (type === 'updateFocus') {
state.lastFocused = event.source.id
const notifications = await self.registration.getNotifications()
notifications.forEach((n) => n.close())
}
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(
getWindowClients().then((list) => {
for (let i = 0; i < list.length; i++) {
const client = list[i]
client.postMessage({
type: 'notificationClicked',
id: event.notification.tag,
})
}
for (let i = 0; i < list.length; i++) {
const client = list[i]
if (state.lastFocused === null || client.id === state.lastFocused) {
if ('focus' in client) return client.focus()
}
}
if (clients.openWindow) return clients.openWindow('/')
}),
)
})
self.addEventListener('fetch', (event) => {
// Do not mess up with remote things
const isSameOrigin =
new URL(event.request.url).origin === self.location.origin
if (
shouldCache &&
event.request.method === 'GET' &&
isSameOrigin &&
isNotMedia(event.request)
) {
// this is a bit spammy
// console.debug('[Service worker] fetch:', event.request.url)
event.respondWith(
(async () => {
const r = await caches.match(event.request)
const isEmojiReq = isEmoji(event.request)
if (r && isSuccessful(r)) {
console.debug('[Service worker] already cached:', event.request.url)
return r
}
try {
const response = await fetch(event.request)
if (
response.ok &&
isSuccessful(response) &&
(isEmojiReq || isAsset(event.request))
) {
console.debug(
`[Service worker] caching ${isEmojiReq ? 'emoji' : 'asset'}:`,
event.request.url,
)
const cache = await caches.open(
isEmojiReq ? emojiCacheKey : cacheKey,
)
await cache.put(event.request.clone(), response.clone())
}
return response
} catch (e) {
console.error('[Service worker] error when caching emoji:', e)
throw e
}
})(),
)
}
})
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Oct 11, 5:39 AM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785623
Default Alt Text
(44 KB)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment