diff options
Diffstat (limited to 'src/services')
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 |
