aboutsummaryrefslogtreecommitdiff
path: root/src/services
diff options
context:
space:
mode:
Diffstat (limited to 'src/services')
-rw-r--r--src/services/api/api.service.js219
-rw-r--r--src/services/backend_interactor_service/backend_interactor_service.js4
-rw-r--r--src/services/chat_service/chat_service.js151
-rw-r--r--src/services/chat_utils/chat_utils.js19
-rw-r--r--src/services/desktop_notification_utils/desktop_notification_utils.js9
-rw-r--r--src/services/entity_normalizer/entity_normalizer.service.js67
-rw-r--r--src/services/follow_request_fetcher/follow_request_fetcher.service.js1
-rw-r--r--src/services/notification_utils/notification_utils.js84
-rw-r--r--src/services/notifications_fetcher/notifications_fetcher.service.js12
-rw-r--r--src/services/resettable_async_component.js32
-rw-r--r--src/services/status_parser/status_parser.js18
-rw-r--r--src/services/status_poster/status_poster.service.js30
-rw-r--r--src/services/style_setter/style_setter.js3
-rw-r--r--src/services/theme_data/pleromafe.js99
-rw-r--r--src/services/theme_data/theme_data.service.js9
-rw-r--r--src/services/timeline_fetcher/timeline_fetcher.service.js23
-rw-r--r--src/services/window_utils/window_utils.js5
17 files changed, 688 insertions, 97 deletions
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index 9d1ce393..da519001 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -1,6 +1,5 @@
import { each, map, concat, last, get } from 'lodash'
-import { parseStatus, parseUser, parseNotification, parseAttachment } from '../entity_normalizer/entity_normalizer.service.js'
-import 'whatwg-fetch'
+import { parseStatus, parseUser, parseNotification, parseAttachment, parseChat, parseLinkHeaderPagination } from '../entity_normalizer/entity_normalizer.service.js'
import { RegistrationError, StatusCodeError } from '../errors/errors'
/* eslint-env browser */
@@ -51,6 +50,7 @@ const MASTODON_USER_URL = '/api/v1/accounts'
const MASTODON_USER_RELATIONSHIPS_URL = '/api/v1/accounts/relationships'
const MASTODON_USER_TIMELINE_URL = id => `/api/v1/accounts/${id}/statuses`
const MASTODON_TAG_TIMELINE_URL = tag => `/api/v1/timelines/tag/${tag}`
+const MASTODON_BOOKMARK_TIMELINE_URL = '/api/v1/bookmarks'
const MASTODON_USER_BLOCKS_URL = '/api/v1/blocks/'
const MASTODON_USER_MUTES_URL = '/api/v1/mutes/'
const MASTODON_BLOCK_USER_URL = id => `/api/v1/accounts/${id}/block`
@@ -59,6 +59,8 @@ const MASTODON_MUTE_USER_URL = id => `/api/v1/accounts/${id}/mute`
const MASTODON_UNMUTE_USER_URL = id => `/api/v1/accounts/${id}/unmute`
const MASTODON_SUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/subscribe`
const MASTODON_UNSUBSCRIBE_USER = id => `/api/v1/pleroma/accounts/${id}/unsubscribe`
+const MASTODON_BOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/bookmark`
+const MASTODON_UNBOOKMARK_STATUS_URL = id => `/api/v1/statuses/${id}/unbookmark`
const MASTODON_POST_STATUS_URL = '/api/v1/statuses'
const MASTODON_MEDIA_UPLOAD_URL = '/api/v1/media'
const MASTODON_VOTE_URL = id => `/api/v1/polls/${id}/votes`
@@ -75,9 +77,15 @@ const MASTODON_SEARCH_2 = `/api/v2/search`
const MASTODON_USER_SEARCH_URL = '/api/v1/accounts/search'
const MASTODON_DOMAIN_BLOCKS_URL = '/api/v1/domain_blocks'
const MASTODON_STREAMING = '/api/v1/streaming'
+const MASTODON_KNOWN_DOMAIN_LIST_URL = '/api/v1/instance/peers'
const PLEROMA_EMOJI_REACTIONS_URL = id => `/api/v1/pleroma/statuses/${id}/reactions`
const PLEROMA_EMOJI_REACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
const PLEROMA_EMOJI_UNREACT_URL = (id, emoji) => `/api/v1/pleroma/statuses/${id}/reactions/${emoji}`
+const PLEROMA_CHATS_URL = `/api/v1/pleroma/chats`
+const PLEROMA_CHAT_URL = id => `/api/v1/pleroma/chats/by-account-id/${id}`
+const PLEROMA_CHAT_MESSAGES_URL = id => `/api/v1/pleroma/chats/${id}/messages`
+const PLEROMA_CHAT_READ_URL = id => `/api/v1/pleroma/chats/${id}/read`
+const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) => `/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
const oldfetch = window.fetch
@@ -138,20 +146,11 @@ const updateNotificationSettings = ({ credentials, settings }) => {
}).then((data) => data.json())
}
-const updateAvatar = ({ credentials, avatar }) => {
+const updateProfileImages = ({ credentials, avatar = null, banner = null, background = null }) => {
const form = new FormData()
- form.append('avatar', avatar)
- return fetch(MASTODON_PROFILE_UPDATE_URL, {
- headers: authHeaders(credentials),
- method: 'PATCH',
- body: form
- }).then((data) => data.json())
- .then((data) => parseUser(data))
-}
-
-const updateBg = ({ credentials, background }) => {
- const form = new FormData()
- form.append('pleroma_background_image', background)
+ if (avatar !== null) form.append('avatar', avatar)
+ if (banner !== null) form.append('header', banner)
+ if (background !== null) form.append('pleroma_background_image', background)
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
@@ -161,17 +160,6 @@ const updateBg = ({ credentials, background }) => {
.then((data) => parseUser(data))
}
-const updateBanner = ({ credentials, banner }) => {
- const form = new FormData()
- form.append('header', banner)
- return fetch(MASTODON_PROFILE_UPDATE_URL, {
- headers: authHeaders(credentials),
- method: 'PATCH',
- body: form
- }).then((data) => data.json())
- .then((data) => parseUser(data))
-}
-
const updateProfile = ({ credentials, params }) => {
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
@@ -324,7 +312,8 @@ const fetchFriends = ({ id, maxId, sinceId, limit = 20, credentials }) => {
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
- limit && `limit=${limit}`
+ limit && `limit=${limit}`,
+ `with_relationships=true`
].filter(_ => _).join('&')
url = url + (args ? '?' + args : '')
@@ -358,7 +347,8 @@ const fetchFollowers = ({ id, maxId, sinceId, limit = 20, credentials }) => {
const args = [
maxId && `max_id=${maxId}`,
sinceId && `since_id=${sinceId}`,
- limit && `limit=${limit}`
+ limit && `limit=${limit}`,
+ `with_relationships=true`
].filter(_ => _).join('&')
url += args ? '?' + args : ''
@@ -497,7 +487,7 @@ const fetchTimeline = ({
userId = false,
tag = false,
withMuted = false,
- withMove = false
+ replyVisibility = 'all'
}) => {
const timelineUrls = {
public: MASTODON_PUBLIC_TIMELINE,
@@ -508,7 +498,8 @@ const fetchTimeline = ({
user: MASTODON_USER_TIMELINE_URL,
media: MASTODON_USER_TIMELINE_URL,
favorites: MASTODON_USER_FAVORITES_TIMELINE_URL,
- tag: MASTODON_TAG_TIMELINE_URL
+ tag: MASTODON_TAG_TIMELINE_URL,
+ bookmarks: MASTODON_BOOKMARK_TIMELINE_URL
}
const isNotifications = timeline === 'notifications'
const params = []
@@ -537,27 +528,33 @@ const fetchTimeline = ({
if (timeline === 'public' || timeline === 'publicAndExternal') {
params.push(['only_media', false])
}
- if (timeline === 'notifications') {
- params.push(['with_move', withMove])
+ if (timeline !== 'favorites' && timeline !== 'bookmarks') {
+ params.push(['with_muted', withMuted])
+ }
+ if (replyVisibility !== 'all') {
+ params.push(['reply_visibility', replyVisibility])
}
params.push(['limit', 20])
- params.push(['with_muted', withMuted])
const queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
url += `?${queryString}`
let status = ''
let statusText = ''
+ let pagination = {}
return fetch(url, { headers: authHeaders(credentials) })
.then((data) => {
status = data.status
statusText = data.statusText
+ pagination = parseLinkHeaderPagination(data.headers.get('Link'), {
+ flakeId: timeline !== 'bookmarks' && timeline !== 'notifications'
+ })
return data
})
.then((data) => data.json())
.then((data) => {
if (!data.error) {
- return data.map(isNotifications ? parseNotification : parseStatus)
+ return { data: data.map(isNotifications ? parseNotification : parseStatus), pagination }
} else {
data.status = status
data.statusText = statusText
@@ -608,6 +605,22 @@ const unretweet = ({ id, credentials }) => {
.then((data) => parseStatus(data))
}
+const bookmarkStatus = ({ id, credentials }) => {
+ return promisedRequest({
+ url: MASTODON_BOOKMARK_STATUS_URL(id),
+ headers: authHeaders(credentials),
+ method: 'POST'
+ })
+}
+
+const unbookmarkStatus = ({ id, credentials }) => {
+ return promisedRequest({
+ url: MASTODON_UNBOOKMARK_STATUS_URL(id),
+ headers: authHeaders(credentials),
+ method: 'POST'
+ })
+}
+
const postStatus = ({
credentials,
status,
@@ -617,7 +630,9 @@ const postStatus = ({
poll,
mediaIds = [],
inReplyToStatusId,
- contentType
+ contentType,
+ preview,
+ idempotencyKey
}) => {
const form = new FormData()
const pollOptions = poll.options || []
@@ -647,20 +662,22 @@ const postStatus = ({
if (inReplyToStatusId) {
form.append('in_reply_to_id', inReplyToStatusId)
}
+ if (preview) {
+ form.append('preview', 'true')
+ }
+
+ let postHeaders = authHeaders(credentials)
+ if (idempotencyKey) {
+ postHeaders['idempotency-key'] = idempotencyKey
+ }
return fetch(MASTODON_POST_STATUS_URL, {
body: form,
method: 'POST',
- headers: authHeaders(credentials)
+ headers: postHeaders
})
.then((response) => {
- if (response.ok) {
- return response.json()
- } else {
- return {
- error: response
- }
- }
+ return response.json()
})
.then((data) => data.error ? data : parseStatus(data))
}
@@ -682,6 +699,17 @@ const uploadMedia = ({ formData, credentials }) => {
.then((data) => parseAttachment(data))
}
+const setMediaDescription = ({ id, description, credentials }) => {
+ return promisedRequest({
+ url: `${MASTODON_MEDIA_UPLOAD_URL}/${id}`,
+ method: 'PUT',
+ headers: authHeaders(credentials),
+ payload: {
+ description
+ }
+ }).then((data) => parseAttachment(data))
+}
+
const importBlocks = ({ file, credentials }) => {
const formData = new FormData()
formData.append('list', file)
@@ -975,6 +1003,8 @@ const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
params.push(['following', true])
}
+ params.push(['with_relationships', true])
+
let queryString = map(params, (param) => `${param[0]}=${param[1]}`).join('&')
url += `?${queryString}`
@@ -993,6 +1023,10 @@ const search2 = ({ credentials, q, resolve, limit, offset, following }) => {
})
}
+const fetchKnownDomains = ({ credentials }) => {
+ return promisedRequest({ url: MASTODON_KNOWN_DOMAIN_LIST_URL, credentials })
+}
+
const fetchDomainMutes = ({ credentials }) => {
return promisedRequest({ url: MASTODON_DOMAIN_BLOCKS_URL, credentials })
}
@@ -1044,6 +1078,10 @@ const MASTODON_STREAMING_EVENTS = new Set([
'filters_changed'
])
+const PLEROMA_STREAMING_EVENTS = new Set([
+ 'pleroma:chat_update'
+])
+
// A thin wrapper around WebSocket API that allows adding a pre-processor to it
// Uses EventTarget and a CustomEvent to proxy events
export const ProcessedWS = ({
@@ -1100,7 +1138,7 @@ export const handleMastoWS = (wsEvent) => {
if (!data) return
const parsedEvent = JSON.parse(data)
const { event, payload } = parsedEvent
- if (MASTODON_STREAMING_EVENTS.has(event)) {
+ if (MASTODON_STREAMING_EVENTS.has(event) || PLEROMA_STREAMING_EVENTS.has(event)) {
// MastoBE and PleromaBE both send payload for delete as a PLAIN string
if (event === 'delete') {
return { event, id: payload }
@@ -1110,6 +1148,8 @@ export const handleMastoWS = (wsEvent) => {
return { event, status: parseStatus(data) }
} else if (event === 'notification') {
return { event, notification: parseNotification(data) }
+ } else if (event === 'pleroma:chat_update') {
+ return { event, chatUpdate: parseChat(data) }
}
} else {
console.warn('Unknown event', wsEvent)
@@ -1117,6 +1157,81 @@ export const handleMastoWS = (wsEvent) => {
}
}
+export const WSConnectionStatus = Object.freeze({
+ 'JOINED': 1,
+ 'CLOSED': 2,
+ 'ERROR': 3
+})
+
+const chats = ({ credentials }) => {
+ return fetch(PLEROMA_CHATS_URL, { headers: authHeaders(credentials) })
+ .then((data) => data.json())
+ .then((data) => {
+ return { chats: data.map(parseChat).filter(c => c) }
+ })
+}
+
+const getOrCreateChat = ({ accountId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_CHAT_URL(accountId),
+ method: 'POST',
+ credentials
+ })
+}
+
+const chatMessages = ({ id, credentials, maxId, sinceId, limit = 20 }) => {
+ let url = PLEROMA_CHAT_MESSAGES_URL(id)
+ const args = [
+ maxId && `max_id=${maxId}`,
+ sinceId && `since_id=${sinceId}`,
+ limit && `limit=${limit}`
+ ].filter(_ => _).join('&')
+
+ url = url + (args ? '?' + args : '')
+
+ return promisedRequest({
+ url,
+ method: 'GET',
+ credentials
+ })
+}
+
+const sendChatMessage = ({ id, content, mediaId = null, credentials }) => {
+ const payload = {
+ 'content': content
+ }
+
+ if (mediaId) {
+ payload['media_id'] = mediaId
+ }
+
+ return promisedRequest({
+ url: PLEROMA_CHAT_MESSAGES_URL(id),
+ method: 'POST',
+ payload: payload,
+ credentials
+ })
+}
+
+const readChat = ({ id, lastReadId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_CHAT_READ_URL(id),
+ method: 'POST',
+ payload: {
+ 'last_read_id': lastReadId
+ },
+ credentials
+ })
+}
+
+const deleteChatMessage = ({ chatId, messageId, credentials }) => {
+ return promisedRequest({
+ url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
+ method: 'DELETE',
+ credentials
+ })
+}
+
const apiService = {
verifyCredentials,
fetchTimeline,
@@ -1140,9 +1255,12 @@ const apiService = {
unfavorite,
retweet,
unretweet,
+ bookmarkStatus,
+ unbookmarkStatus,
postStatus,
deleteStatus,
uploadMedia,
+ setMediaDescription,
fetchMutes,
muteUser,
unmuteUser,
@@ -1160,10 +1278,8 @@ const apiService = {
deactivateUser,
register,
getCaptcha,
- updateAvatar,
- updateBg,
+ updateProfileImages,
updateProfile,
- updateBanner,
importBlocks,
importFollows,
deleteAccount,
@@ -1191,9 +1307,16 @@ const apiService = {
updateNotificationSettings,
search2,
searchUsers,
+ fetchKnownDomains,
fetchDomainMutes,
muteDomain,
- unmuteDomain
+ unmuteDomain,
+ chats,
+ getOrCreateChat,
+ chatMessages,
+ sendChatMessage,
+ readChat,
+ deleteChatMessage
}
export default apiService
diff --git a/src/services/backend_interactor_service/backend_interactor_service.js b/src/services/backend_interactor_service/backend_interactor_service.js
index e1c32860..45e6bd0e 100644
--- a/src/services/backend_interactor_service/backend_interactor_service.js
+++ b/src/services/backend_interactor_service/backend_interactor_service.js
@@ -12,10 +12,6 @@ const backendInteractorService = credentials => ({
return notificationsFetcher.startFetching({ store, credentials })
},
- fetchAndUpdateNotifications ({ store }) {
- return notificationsFetcher.fetchAndUpdate({ store, credentials })
- },
-
startFetchingFollowRequests ({ store }) {
return followRequestFetcher.startFetching({ store, credentials })
},
diff --git a/src/services/chat_service/chat_service.js b/src/services/chat_service/chat_service.js
new file mode 100644
index 00000000..b60a889b
--- /dev/null
+++ b/src/services/chat_service/chat_service.js
@@ -0,0 +1,151 @@
+import _ from 'lodash'
+
+const empty = (chatId) => {
+ return {
+ idIndex: {},
+ messages: [],
+ newMessageCount: 0,
+ lastSeenTimestamp: 0,
+ chatId: chatId,
+ minId: undefined,
+ lastMessage: undefined
+ }
+}
+
+const clear = (storage) => {
+ storage.idIndex = {}
+ storage.messages.splice(0, storage.messages.length)
+ storage.newMessageCount = 0
+ storage.lastSeenTimestamp = 0
+ storage.minId = undefined
+ storage.lastMessage = undefined
+}
+
+const deleteMessage = (storage, messageId) => {
+ if (!storage) { return }
+ storage.messages = storage.messages.filter(m => m.id !== messageId)
+ delete storage.idIndex[messageId]
+
+ if (storage.lastMessage && (storage.lastMessage.id === messageId)) {
+ storage.lastMessage = _.maxBy(storage.messages, 'id')
+ }
+
+ if (storage.minId === messageId) {
+ const firstMessage = _.minBy(storage.messages, 'id')
+ storage.minId = firstMessage.id
+ }
+}
+
+const add = (storage, { messages: newMessages }) => {
+ if (!storage) { return }
+ for (let i = 0; i < newMessages.length; i++) {
+ const message = newMessages[i]
+
+ // sanity check
+ if (message.chat_id !== storage.chatId) { return }
+
+ if (!storage.minId || message.id < storage.minId) {
+ storage.minId = message.id
+ }
+
+ if (!storage.lastMessage || message.id > storage.lastMessage.id) {
+ storage.lastMessage = message
+ }
+
+ if (!storage.idIndex[message.id]) {
+ if (storage.lastSeenTimestamp < message.created_at) {
+ storage.newMessageCount++
+ }
+ storage.messages.push(message)
+ storage.idIndex[message.id] = message
+ }
+ }
+}
+
+const resetNewMessageCount = (storage) => {
+ if (!storage) { return }
+ storage.newMessageCount = 0
+ storage.lastSeenTimestamp = new Date()
+}
+
+// Inserts date separators and marks the head and tail if it's the chain of messages made by the same user
+const getView = (storage) => {
+ if (!storage) { return [] }
+
+ const result = []
+ const messages = _.sortBy(storage.messages, ['id', 'desc'])
+ const firstMessage = messages[0]
+ let previousMessage = messages[messages.length - 1]
+ let currentMessageChainId
+
+ if (firstMessage) {
+ const date = new Date(firstMessage.created_at)
+ date.setHours(0, 0, 0, 0)
+ result.push({
+ type: 'date',
+ date,
+ id: date.getTime().toString()
+ })
+ }
+
+ let afterDate = false
+
+ for (let i = 0; i < messages.length; i++) {
+ const message = messages[i]
+ const nextMessage = messages[i + 1]
+
+ const date = new Date(message.created_at)
+ date.setHours(0, 0, 0, 0)
+
+ // insert date separator and start a new message chain
+ if (previousMessage && previousMessage.date < date) {
+ result.push({
+ type: 'date',
+ date,
+ id: date.getTime().toString()
+ })
+
+ previousMessage['isTail'] = true
+ currentMessageChainId = undefined
+ afterDate = true
+ }
+
+ const object = {
+ type: 'message',
+ data: message,
+ date,
+ id: message.id,
+ messageChainId: currentMessageChainId
+ }
+
+ // end a message chian
+ if ((nextMessage && nextMessage.account_id) !== message.account_id) {
+ object['isTail'] = true
+ currentMessageChainId = undefined
+ }
+
+ // start a new message chain
+ if ((previousMessage && previousMessage.data && previousMessage.data.account_id) !== message.account_id || afterDate) {
+ currentMessageChainId = _.uniqueId()
+ object['isHead'] = true
+ object['messageChainId'] = currentMessageChainId
+ }
+
+ result.push(object)
+ previousMessage = object
+ afterDate = false
+ }
+
+ return result
+}
+
+const ChatService = {
+ add,
+ empty,
+ getView,
+ deleteMessage,
+ resetNewMessageCount,
+ clear
+}
+
+export default ChatService
diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js
new file mode 100644
index 00000000..ab898ced
--- /dev/null
+++ b/src/services/chat_utils/chat_utils.js
@@ -0,0 +1,19 @@
+import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
+
+export const maybeShowChatNotification = (store, chat) => {
+ if (!chat.lastMessage) return
+ if (store.rootState.chats.currentChatId === chat.id && !document.hidden) return
+
+ const opts = {
+ tag: chat.lastMessage.id,
+ title: chat.account.name,
+ icon: chat.account.profile_image_url,
+ body: chat.lastMessage.content
+ }
+
+ if (chat.lastMessage.attachment && chat.lastMessage.attachment.type === 'image') {
+ opts.image = chat.lastMessage.attachment.preview_url
+ }
+
+ showDesktopNotification(store.rootState, opts)
+}
diff --git a/src/services/desktop_notification_utils/desktop_notification_utils.js b/src/services/desktop_notification_utils/desktop_notification_utils.js
new file mode 100644
index 00000000..b84a1f75
--- /dev/null
+++ b/src/services/desktop_notification_utils/desktop_notification_utils.js
@@ -0,0 +1,9 @@
+export const showDesktopNotification = (rootState, desktopNotificationOpts) => {
+ if (!('Notification' in window && window.Notification.permission === 'granted')) return
+ if (rootState.statuses.notifications.desktopNotificationSilence) { return }
+
+ const desktopNotification = new window.Notification(desktopNotificationOpts.title, desktopNotificationOpts)
+ // Chrome is known for not closing notifications automatically
+ // according to MDN, anyway.
+ setTimeout(desktopNotification.close.bind(desktopNotification), 5000)
+}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index 6dac7c15..1884478a 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,4 +1,5 @@
import escape from 'escape-html'
+import parseLinkHeader from 'parse-link-header'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
const qvitterStatusType = (status) => {
@@ -56,6 +57,12 @@ export const parseUser = (data) => {
value: addEmojis(field.value, data.emojis)
}
})
+ output.fields_text = data.fields.map(field => {
+ return {
+ name: unescape(field.name.replace(/<[^>]*>/g, '')),
+ value: unescape(field.value.replace(/<[^>]*>/g, ''))
+ }
+ })
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
@@ -72,6 +79,7 @@ export const parseUser = (data) => {
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) {
@@ -176,6 +184,7 @@ export const parseUser = (data) => {
output.deactivated = data.pleroma.deactivated
output.notification_settings = data.pleroma.notification_settings
+ output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || []
@@ -200,6 +209,7 @@ export const parseAttachment = (data) => {
}
output.url = data.url
+ output.large_thumb_url = data.preview_url
output.description = data.description
return output
@@ -210,7 +220,7 @@ export const addEmojis = (string, emojis) => {
const regexSafeShortCode = emoji.shortcode.replace(matchOperatorsRegex, '\\$&')
return acc.replace(
new RegExp(`:${regexSafeShortCode}:`, 'g'),
- `<img src='${emoji.url}' alt='${emoji.shortcode}' title='${emoji.shortcode}' class='emoji' />`
+ `<img src='${emoji.url}' alt=':${emoji.shortcode}:' title=':${emoji.shortcode}:' class='emoji' />`
)
}, string)
}
@@ -226,6 +236,8 @@ export const parseStatus = (data) => {
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
+ output.bookmarked = data.bookmarked
+
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
@@ -242,6 +254,7 @@ export const parseStatus = (data) => {
output.in_reply_to_screen_name = data.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
} else {
output.text = data.content
output.summary = data.spoiler_text
@@ -258,6 +271,12 @@ export const parseStatus = (data) => {
output.summary_html = addEmojis(escape(data.spoiler_text), data.emojis)
output.external_url = data.url
output.poll = data.poll
+ if (output.poll) {
+ output.poll.options = (output.poll.options || []).map(field => ({
+ ...field,
+ title_html: addEmojis(field.title, data.emojis)
+ }))
+ }
output.pinned = data.pinned
output.muted = data.muted
} else {
@@ -356,7 +375,7 @@ export const parseNotification = (data) => {
? parseStatus(data.notice.favorited_status)
: parsedNotice
output.action = parsedNotice
- output.from_profile = parseUser(data.from_profile)
+ output.from_profile = output.type === 'pleroma:chat_mention' ? parseUser(data.account) : parseUser(data.from_profile)
}
output.created_at = new Date(data.created_at)
@@ -369,3 +388,47 @@ const isNsfw = (status) => {
const nsfwRegex = /#nsfw/i
return (status.tags || []).includes('nsfw') || !!(status.text || '').match(nsfwRegex)
}
+
+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 : parseInt(maxId, 10),
+ minId: flakeId ? minId : 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
+ if (message.content) {
+ output.content = addEmojis(message.content, message.emojis)
+ } else {
+ output.content = ''
+ }
+ if (message.attachment) {
+ output.attachments = [parseAttachment(message.attachment)]
+ } else {
+ output.attachments = []
+ }
+ output.isNormalized = true
+ return output
+}
diff --git a/src/services/follow_request_fetcher/follow_request_fetcher.service.js b/src/services/follow_request_fetcher/follow_request_fetcher.service.js
index 786740b7..93fac9bc 100644
--- a/src/services/follow_request_fetcher/follow_request_fetcher.service.js
+++ b/src/services/follow_request_fetcher/follow_request_fetcher.service.js
@@ -4,6 +4,7 @@ const fetchAndUpdate = ({ store, credentials }) => {
return apiService.fetchFollowRequests({ credentials })
.then((requests) => {
store.commit('setFollowRequests', requests)
+ store.commit('addNewUsers', requests)
}, () => {})
.catch(() => {})
}
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index eb479227..d912d19f 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -1,16 +1,22 @@
import { filter, sortBy, includes } from 'lodash'
+import { muteWordHits } from '../status_parser/status_parser.js'
+import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
export const notificationsFromStore = store => store.state.statuses.notifications.data
-export const visibleTypes = store => ([
- store.state.config.notificationVisibility.likes && 'like',
- store.state.config.notificationVisibility.mentions && 'mention',
- store.state.config.notificationVisibility.repeats && 'repeat',
- store.state.config.notificationVisibility.follows && 'follow',
- store.state.config.notificationVisibility.followRequest && 'follow_request',
- store.state.config.notificationVisibility.moves && 'move',
- store.state.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
-].filter(_ => _))
+export const visibleTypes = store => {
+ const rootState = store.rootState || store.state
+
+ return ([
+ rootState.config.notificationVisibility.likes && 'like',
+ rootState.config.notificationVisibility.mentions && 'mention',
+ rootState.config.notificationVisibility.repeats && 'repeat',
+ rootState.config.notificationVisibility.follows && 'follow',
+ rootState.config.notificationVisibility.followRequest && 'follow_request',
+ rootState.config.notificationVisibility.moves && 'move',
+ rootState.config.notificationVisibility.emojiReactions && 'pleroma:emoji_reaction'
+ ].filter(_ => _))
+}
const statusNotifications = ['like', 'mention', 'repeat', 'pleroma:emoji_reaction']
@@ -32,6 +38,22 @@ const sortById = (a, b) => {
}
}
+const isMutedNotification = (store, notification) => {
+ if (!notification.status) return
+ return notification.status.muted || muteWordHits(notification.status, store.rootGetters.mergedConfig.muteWords).length > 0
+}
+
+export const maybeShowNotification = (store, notification) => {
+ const rootState = store.rootState || store.state
+
+ if (notification.seen) return
+ if (!visibleTypes(store).includes(notification.type)) return
+ if (notification.type === 'mention' && isMutedNotification(store, notification)) return
+
+ const notificationObject = prepareNotificationObject(notification, store.rootGetters.i18n)
+ showDesktopNotification(rootState, notificationObject)
+}
+
export const filteredNotificationsFromStore = (store, types) => {
// map is just to clone the array since sort mutates it and it causes some issues
let sortedNotifications = notificationsFromStore(store).map(_ => _).sort(sortById)
@@ -43,3 +65,47 @@ export const filteredNotificationsFromStore = (store, types) => {
export const unseenNotificationsFromStore = store =>
filter(filteredNotificationsFromStore(store), ({ seen }) => !seen)
+
+export const prepareNotificationObject = (notification, i18n) => {
+ const notifObj = {
+ tag: notification.id
+ }
+ 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 'repeat':
+ i18nString = 'repeated_you'
+ break
+ case 'follow':
+ i18nString = 'followed_you'
+ break
+ case 'move':
+ i18nString = 'migrated_to'
+ break
+ case 'follow_request':
+ i18nString = 'follow_request'
+ 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 && status.attachments && status.attachments.length > 0 && !status.nsfw &&
+ status.attachments[0].mimetype.startsWith('image/')) {
+ notifObj.image = status.attachments[0].url
+ }
+
+ return notifObj
+}
diff --git a/src/services/notifications_fetcher/notifications_fetcher.service.js b/src/services/notifications_fetcher/notifications_fetcher.service.js
index 864e32f8..133e913f 100644
--- a/src/services/notifications_fetcher/notifications_fetcher.service.js
+++ b/src/services/notifications_fetcher/notifications_fetcher.service.js
@@ -30,21 +30,25 @@ const fetchAndUpdate = ({ store, credentials, older = false }) => {
}
const result = fetchNotifications({ store, args, older })
- // load unread notifications repeatedly to provide consistency between browser tabs
+ // If there's any unread notifications, try fetch notifications since
+ // the newest read notification to check if any of the unread notifs
+ // have changed their 'seen' state (marked as read in another session), so
+ // we can update the state in this session to mark them as read as well.
+ // The normal maxId-check does not tell if older notifications have changed
const notifications = timelineData.data
const readNotifsIds = notifications.filter(n => n.seen).map(n => n.id)
- if (readNotifsIds.length) {
+ const numUnseenNotifs = notifications.length - readNotifsIds.length
+ if (numUnseenNotifs > 0 && readNotifsIds.length > 0) {
args['since'] = Math.max(...readNotifsIds)
fetchNotifications({ store, args, older })
}
-
return result
}
}
const fetchNotifications = ({ store, args, older }) => {
return apiService.fetchTimeline(args)
- .then((notifications) => {
+ .then(({ data: notifications }) => {
update({ store, notifications, older })
return notifications
}, () => store.dispatch('setNotificationsError', { value: true }))
diff --git a/src/services/resettable_async_component.js b/src/services/resettable_async_component.js
new file mode 100644
index 00000000..517bbd88
--- /dev/null
+++ b/src/services/resettable_async_component.js
@@ -0,0 +1,32 @@
+import Vue from 'vue'
+
+/* By default async components don't have any way to recover, if component is
+ * failed, it is failed forever. This helper tries to remedy that by recreating
+ * async component when retry is requested (by user). You need to emit the
+ * `resetAsyncComponent` event from child to reset the component. Generally,
+ * this should be done from error component but could be done from loading or
+ * actual target component itself if needs to be.
+ */
+function getResettableAsyncComponent (asyncComponent, options) {
+ const asyncComponentFactory = () => () => ({
+ component: asyncComponent(),
+ ...options
+ })
+
+ const observe = Vue.observable({ c: asyncComponentFactory() })
+
+ return {
+ functional: true,
+ render (createElement, { data, children }) {
+ // emit event resetAsyncComponent to reloading
+ data.on = {}
+ data.on.resetAsyncComponent = () => {
+ observe.c = asyncComponentFactory()
+ // parent.$forceUpdate()
+ }
+ return createElement(observe.c, data, children)
+ }
+ }
+}
+
+export default getResettableAsyncComponent
diff --git a/src/services/status_parser/status_parser.js b/src/services/status_parser/status_parser.js
index 900cd56e..ed0f6d57 100644
--- a/src/services/status_parser/status_parser.js
+++ b/src/services/status_parser/status_parser.js
@@ -1,15 +1,11 @@
-import sanitize from 'sanitize-html'
+import { filter } from 'lodash'
-export const removeAttachmentLinks = (html) => {
- return sanitize(html, {
- allowedTags: false,
- allowedAttributes: false,
- exclusiveFilter: ({ tag, attribs }) => tag === 'a' && typeof attribs.class === 'string' && attribs.class.match(/attachment/)
+export const muteWordHits = (status, muteWords) => {
+ const statusText = status.text.toLowerCase()
+ const statusSummary = status.summary.toLowerCase()
+ const hits = filter(muteWords, (muteWord) => {
+ return statusText.includes(muteWord.toLowerCase()) || statusSummary.includes(muteWord.toLowerCase())
})
-}
-export const parse = (html) => {
- return removeAttachmentLinks(html)
+ return hits
}
-
-export default parse
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
index 9e904d3a..f09196aa 100644
--- a/src/services/status_poster/status_poster.service.js
+++ b/src/services/status_poster/status_poster.service.js
@@ -1,7 +1,19 @@
import { map } from 'lodash'
import apiService from '../api/api.service.js'
-const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, media = [], inReplyToStatusId = undefined, contentType = 'text/plain' }) => {
+const postStatus = ({
+ store,
+ status,
+ spoilerText,
+ visibility,
+ sensitive,
+ poll,
+ media = [],
+ inReplyToStatusId = undefined,
+ contentType = 'text/plain',
+ preview = false,
+ idempotencyKey = ''
+}) => {
const mediaIds = map(media, 'id')
return apiService.postStatus({
@@ -13,9 +25,12 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m
mediaIds,
inReplyToStatusId,
contentType,
- poll })
+ poll,
+ preview,
+ idempotencyKey
+ })
.then((data) => {
- if (!data.error) {
+ if (!data.error && !preview) {
store.dispatch('addNewStatuses', {
statuses: [data],
timeline: 'friends',
@@ -34,13 +49,18 @@ const postStatus = ({ store, status, spoilerText, visibility, sensitive, poll, m
const uploadMedia = ({ store, formData }) => {
const credentials = store.state.users.currentUser.credentials
-
return apiService.uploadMedia({ credentials, formData })
}
+const setMediaDescription = ({ store, id, description }) => {
+ const credentials = store.state.users.currentUser.credentials
+ return apiService.setMediaDescription({ credentials, id, description })
+}
+
const statusPosterService = {
postStatus,
- uploadMedia
+ uploadMedia,
+ setMediaDescription
}
export default statusPosterService
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index fbdcf562..07425abd 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -106,7 +106,8 @@ export const generateRadii = (input) => {
avatar: 5,
avatarAlt: 50,
tooltip: 2,
- attachment: 5
+ attachment: 5,
+ chatMessage: inputRadii.panel
})
return {
diff --git a/src/services/theme_data/pleromafe.js b/src/services/theme_data/pleromafe.js
index 0c1fe543..7ed85797 100644
--- a/src/services/theme_data/pleromafe.js
+++ b/src/services/theme_data/pleromafe.js
@@ -23,7 +23,9 @@ export const LAYERS = {
inputTopBar: 'topBar',
alert: 'bg',
alertPanel: 'panel',
- poll: 'bg'
+ poll: 'bg',
+ chatBg: 'underlay',
+ chatMessage: 'chatBg'
}
/* By default opacity slots have 1 as default opacity
@@ -34,7 +36,8 @@ export const DEFAULT_OPACITY = {
alert: 0.5,
input: 0.5,
faint: 0.5,
- underlay: 0.15
+ underlay: 0.15,
+ alertPopup: 0.95
}
/** SUBJECT TO CHANGE IN THE FUTURE, this is all beta
@@ -356,6 +359,12 @@ export const SLOT_INHERITANCE = {
textColor: 'preserve'
},
+ postGreentext: {
+ depends: ['cGreen'],
+ layer: 'bg',
+ textColor: 'preserve'
+ },
+
border: {
depends: ['fg'],
opacity: 'border',
@@ -621,11 +630,97 @@ export const SLOT_INHERITANCE = {
textColor: true
},
+ alertPopupError: {
+ depends: ['alertError'],
+ opacity: 'alertPopup'
+ },
+ alertPopupErrorText: {
+ depends: ['alertErrorText'],
+ layer: 'popover',
+ variant: 'alertPopupError',
+ textColor: true
+ },
+
+ alertPopupWarning: {
+ depends: ['alertWarning'],
+ opacity: 'alertPopup'
+ },
+ alertPopupWarningText: {
+ depends: ['alertWarningText'],
+ layer: 'popover',
+ variant: 'alertPopupWarning',
+ textColor: true
+ },
+
+ alertPopupNeutral: {
+ depends: ['alertNeutral'],
+ opacity: 'alertPopup'
+ },
+ alertPopupNeutralText: {
+ depends: ['alertNeutralText'],
+ layer: 'popover',
+ variant: 'alertPopupNeutral',
+ textColor: true
+ },
+
badgeNotification: '--cRed',
badgeNotificationText: {
depends: ['text', 'badgeNotification'],
layer: 'badge',
variant: 'badgeNotification',
textColor: 'bw'
+ },
+
+ chatBg: {
+ depends: ['bg']
+ },
+
+ chatMessageIncomingBg: {
+ depends: ['chatBg']
+ },
+
+ chatMessageIncomingText: {
+ depends: ['text'],
+ layer: 'chatMessage',
+ variant: 'chatMessageIncomingBg',
+ textColor: true
+ },
+
+ chatMessageIncomingLink: {
+ depends: ['link'],
+ layer: 'chatMessage',
+ variant: 'chatMessageIncomingBg',
+ textColor: 'preserve'
+ },
+
+ chatMessageIncomingBorder: {
+ depends: ['border'],
+ opacity: 'border',
+ color: (mod, border) => brightness(2 * mod, border).rgb
+ },
+
+ chatMessageOutgoingBg: {
+ depends: ['chatMessageIncomingBg'],
+ color: (mod, chatMessage) => brightness(5 * mod, chatMessage).rgb
+ },
+
+ chatMessageOutgoingText: {
+ depends: ['text'],
+ layer: 'chatMessage',
+ variant: 'chatMessageOutgoingBg',
+ textColor: true
+ },
+
+ chatMessageOutgoingLink: {
+ depends: ['link'],
+ layer: 'chatMessage',
+ variant: 'chatMessageOutgoingBg',
+ textColor: 'preserve'
+ },
+
+ chatMessageOutgoingBorder: {
+ depends: ['chatMessageOutgoingBg'],
+ opacity: 'border',
+ color: (mod, border) => brightness(2 * mod, border).rgb
}
}
diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js
index dd87e3cf..b619f810 100644
--- a/src/services/theme_data/theme_data.service.js
+++ b/src/services/theme_data/theme_data.service.js
@@ -128,14 +128,17 @@ export const topoSort = (
while (unprocessed.length > 0) {
step(unprocessed.pop())
}
- return output.sort((a, b) => {
+
+ // The index thing is to make sorting stable on browsers
+ // where Array.sort() isn't stable
+ return output.map((data, index) => ({ data, index })).sort(({ data: a, index: ai }, { data: b, index: bi }) => {
const depsA = getDeps(a, inheritance).length
const depsB = getDeps(b, inheritance).length
- if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return 0
+ if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return ai - bi
if (depsA === 0 && depsB !== 0) return -1
if (depsB === 0 && depsA !== 0) return 1
- })
+ }).map(({ data }) => data)
}
const expandSlotValue = (value) => {
diff --git a/src/services/timeline_fetcher/timeline_fetcher.service.js b/src/services/timeline_fetcher/timeline_fetcher.service.js
index c6b28ad5..d0cddf84 100644
--- a/src/services/timeline_fetcher/timeline_fetcher.service.js
+++ b/src/services/timeline_fetcher/timeline_fetcher.service.js
@@ -2,7 +2,7 @@ import { camelCase } from 'lodash'
import apiService from '../api/api.service.js'
-const update = ({ store, statuses, timeline, showImmediately, userId }) => {
+const update = ({ store, statuses, timeline, showImmediately, userId, pagination }) => {
const ccTimeline = camelCase(timeline)
store.dispatch('setError', { value: false })
@@ -12,7 +12,8 @@ const update = ({ store, statuses, timeline, showImmediately, userId }) => {
timeline: ccTimeline,
userId,
statuses,
- showImmediately
+ showImmediately,
+ pagination
})
}
@@ -30,7 +31,8 @@ const fetchAndUpdate = ({
const rootState = store.rootState || store.state
const { getters } = store
const timelineData = rootState.statuses.timelines[camelCase(timeline)]
- const hideMutedPosts = getters.mergedConfig.hideMutedPosts
+ const { hideMutedPosts, replyVisibility } = getters.mergedConfig
+ const loggedIn = !!rootState.users.currentUser
if (older) {
args['until'] = until || timelineData.minId
@@ -41,20 +43,25 @@ const fetchAndUpdate = ({
args['userId'] = userId
args['tag'] = tag
args['withMuted'] = !hideMutedPosts
+ if (loggedIn && ['friends', 'public', 'publicAndExternal'].includes(timeline)) {
+ args['replyVisibility'] = replyVisibility
+ }
const numStatusesBeforeFetch = timelineData.statuses.length
return apiService.fetchTimeline(args)
- .then((statuses) => {
- if (statuses.error) {
- store.dispatch('setErrorData', { value: statuses })
+ .then(response => {
+ if (response.error) {
+ store.dispatch('setErrorData', { value: response })
return
}
+
+ const { data: statuses, pagination } = response
if (!older && statuses.length >= 20 && !timelineData.loading && numStatusesBeforeFetch > 0) {
store.dispatch('queueFlush', { timeline: timeline, id: timelineData.maxId })
}
- update({ store, statuses, timeline, showImmediately, userId })
- return statuses
+ update({ store, statuses, timeline, showImmediately, userId, pagination })
+ return { statuses, pagination }
}, () => store.dispatch('setError', { value: true }))
}
diff --git a/src/services/window_utils/window_utils.js b/src/services/window_utils/window_utils.js
index faff6cb9..909088db 100644
--- a/src/services/window_utils/window_utils.js
+++ b/src/services/window_utils/window_utils.js
@@ -3,3 +3,8 @@ export const windowWidth = () =>
window.innerWidth ||
document.documentElement.clientWidth ||
document.body.clientWidth
+
+export const windowHeight = () =>
+ window.innerHeight ||
+ document.documentElement.clientHeight ||
+ document.body.clientHeight