diff --git a/__tests__/api/MicroBlogAuth.test.js b/__tests__/api/MicroBlogAuth.test.js index eb4120b1..277b8bad 100644 --- a/__tests__/api/MicroBlogAuth.test.js +++ b/__tests__/api/MicroBlogAuth.test.js @@ -15,7 +15,7 @@ describe('MicroBlogAuth helpers', () => { expect(auth_url).toContain('client_id=https%3A%2F%2Fmicro.blog%2Fclient.json') expect(auth_url).toContain('app=1') expect(auth_url).toContain('response_type=code') - expect(auth_url).toContain('scope=create') + expect(new URL(auth_url).searchParams.get('scope')).toBe('read write') expect(auth_url).toContain('state=abc123') expect(auth_url).toContain(`redirect_uri=${encodeURIComponent(get_micro_blog_redirect_uri())}`) }) diff --git a/__tests__/api/MicroPubApi.test.js b/__tests__/api/MicroPubApi.test.js new file mode 100644 index 00000000..d4ae093c --- /dev/null +++ b/__tests__/api/MicroPubApi.test.js @@ -0,0 +1,291 @@ +import axios from 'axios' +import { URLSearchParams } from 'url' + +import MicroPubApi, { FETCH_ERROR, NO_AUTH } from '../../src/api/MicroPubApi' + +jest.mock('axios', () => ({ + post: jest.fn() +})) + +jest.mock('../../src/stores/App', () => ({})) + +describe('MicroPubApi authorization code exchange', () => { + const service = { token_endpoint: 'https://tokens.example.com/token' } + + beforeEach(() => { + axios.post.mockReset() + axios.post.mockResolvedValue({ data: { access_token: 'test-access-token' } }) + jest.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test.each([ + ['abc123', 'abc123'], + ['abc%2Bdef%2Fghi%3D', 'abc+def/ghi='], + ['abc%252Fdef', 'abc%2Fdef'], + ['abc%26x%3Dy', 'abc&x=y'], + ['abc+def', 'abc def'], + ['abc%20def', 'abc def'], + ['abc=', 'abc='] + ])('preserves the code from callback parameter %s', async (encoded_code, expected_code) => { + const result = await MicroPubApi.verify_code( + service, + `microblog://indieauth?code=${encoded_code}&state=test-state` + ) + + expect(result).toBe('test-access-token') + expect(axios.post).toHaveBeenCalledTimes(1) + expect(axios.post).toHaveBeenCalledWith( + service.token_endpoint, + expect.any(String), + { headers: { 'Content-type': 'application/x-www-form-urlencoded', Accept: 'application/json' } } + ) + const params = new URLSearchParams(axios.post.mock.calls[0][1]) + expect(Object.fromEntries(params)).toEqual({ + client_id: 'https://micro.blog/', + code: expected_code, + redirect_uri: 'https://micro.blog/indieauth/redirect', + grant_type: 'authorization_code' + }) + }) + + test('ignores a fragment after the authorization code', async () => { + await MicroPubApi.verify_code(service, 'microblog://indieauth?state=test-state&code=abc123#fragment') + + const params = new URLSearchParams(axios.post.mock.calls[0][1]) + expect(params.get('code')).toBe('abc123') + }) + + test.each([ + 'microblog://indieauth?state=test-state', + 'microblog://indieauth?code=&state=test-state', + 'microblog://indieauth?state=test-state#code=fragment-only', + 'not-a-url?code=abc123', + null + ])('returns NO_AUTH without a request for callback %s', async callback => { + await expect(MicroPubApi.verify_code(service, callback)).resolves.toBe(NO_AUTH) + expect(axios.post).not.toHaveBeenCalled() + }) + + test('returns NO_AUTH when the response has no access token', async () => { + axios.post.mockResolvedValue({ data: {} }) + + await expect(MicroPubApi.verify_code(service, 'microblog://indieauth?code=abc123')) + .resolves.toBe(NO_AUTH) + }) + + test('returns FETCH_ERROR when the token request fails', async () => { + axios.post.mockRejectedValue(new Error('Invalid authorization code')) + + await expect(MicroPubApi.verify_code(service, 'microblog://indieauth?code=abc123')) + .resolves.toBe(FETCH_ERROR) + }) +}) + +describe('Micropub interoperability', () => { + const service = { + endpoint: 'https://posts.example/micropub?route=publish', + media_endpoint: 'https://media.example/upload', + token: 'third-party-token', + is_microblog: false + } + const post_url = 'https://posts.example/entry/1' + const response = (status = 201, data = {}, location = post_url) => ({ + ok: status >= 200 && status < 300, + status, + headers: { get: name => name.toLowerCase() === 'location' ? location : null }, + json: async () => data + }) + + beforeEach(() => { + jest.spyOn(global, 'fetch').mockResolvedValue(response()) + jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(require('react-native').Alert, 'alert').mockImplementation(() => {}) + }) + + afterEach(() => jest.restoreAllMocks()) + + test.each([201, 202])('sends a URL-encoded plain post and uses the Location from HTTP %s', async status => { + fetch.mockResolvedValue(response(status)) + const result = await MicroPubApi.send_post(service, 'A & B + C', null, [], ['one', 'two']) + const [url, options] = fetch.mock.calls[0] + expect(url).toBe(service.endpoint) + expect(options.headers).toMatchObject({ + Authorization: 'Bearer third-party-token', + 'Content-Type': 'application/x-www-form-urlencoded' + }) + const params = new URLSearchParams(options.body) + expect(params.get('content')).toBe('A & B + C') + expect(params.getAll('category[]')).toEqual(['one', 'two']) + expect(result).toEqual({ url: post_url }) + }) + + test('preserves Markdown when photo alt text requires JSON', async () => { + await MicroPubApi.send_post({ ...service, destination: 'blog' }, '**Hello**', null, [{ + remote_url: 'https://media.example/photo.jpg', did_upload: true, alt_text: 'A bird' + }], [], 'published', ['social-a', 'social-b']) + const options = fetch.mock.calls[0][1] + expect(options.headers['Content-Type']).toBe('application/json') + expect(JSON.parse(options.body)).toEqual({ + type: ['h-entry'], + properties: { + content: ['**Hello**'], + photo: [{ value: 'https://media.example/photo.jpg', alt: 'A bird' }], + 'post-status': ['published'] + }, + 'mp-destination': 'blog', + 'mp-syndicate-to': ['social-a', 'social-b'] + }) + }) + + test('sends inline image Markdown unchanged without duplicating the photo property', async () => { + await MicroPubApi.send_post(service, '![Bird](https://media.example/bird.jpg)', null, [{ + remote_url: 'https://media.example/bird.jpg', did_upload: true, is_inline: true + }]) + const options = fetch.mock.calls[0][1] + expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded') + const params = new URLSearchParams(options.body) + expect(params.get('content')).toBe('![Bird](https://media.example/bird.jpg)') + expect(params.has('photo')).toBe(false) + }) + + test('preserves an empty syndication array when sending photo alt text as JSON', async () => { + await MicroPubApi.send_post(service, 'Hello', null, [{ + remote_url: 'https://media.example/photo.jpg', did_upload: true, alt_text: 'A bird' + }], [], null, []) + const body = JSON.parse(fetch.mock.calls[0][1].body) + expect(body['mp-syndicate-to']).toEqual([]) + expect(body.properties.photo).toEqual([{ value: 'https://media.example/photo.jpg', alt: 'A bird' }]) + }) + + test.each([[null], [[]], [['social-a']], [['social-a', 'social-b']]])('encodes multipart syndication selection %j', async syndicate_to => { + const original_form_data = global.FormData + global.FormData = require('react-native/Libraries/Network/FormData').default + try { + await MicroPubApi.send_post({ ...service, media_endpoint: null }, 'Hello', null, [{ + uri: 'file:///tmp/photo.jpg', type: 'image/jpeg', did_upload: false + }], [], null, syndicate_to) + const parts = fetch.mock.calls[0][1].body.getParts() + const syndication_parts = parts.filter(part => part.fieldName.startsWith('mp-syndicate-to')) + const expected = syndicate_to == null ? [] : syndicate_to.length ? syndicate_to : [''] + expect(syndication_parts.map(part => ({ name: part.fieldName, value: part.string }))).toEqual( + expected.map(value => ({ name: 'mp-syndicate-to[]', value })) + ) + } + finally { + global.FormData = original_form_data + } + }) + + test('preserves Micro.blog Markdown and keeps photo descriptions aligned', async () => { + await MicroPubApi.send_post({ ...service, is_microblog: true }, '**Hello**', null, [ + { remote_url: 'https://media.example/1.jpg', did_upload: true }, + { remote_url: 'https://media.example/2.jpg', did_upload: true, alt_text: 'Second photo' } + ]) + const params = new URLSearchParams(fetch.mock.calls[0][1].body) + expect(params.get('content')).toBe('**Hello**') + expect(params.getAll('mp-photo-alt[]')).toEqual(['', 'Second photo']) + }) + + test('uploads local files in the create request when there is no media endpoint', async () => { + const original_form_data = global.FormData + global.FormData = require('react-native/Libraries/Network/FormData').default + try { + await MicroPubApi.send_post({ ...service, media_endpoint: null }, 'My photo and video', null, [ + { uri: 'file:///tmp/photo.jpg', type: 'image/jpeg', did_upload: false }, + { uri: 'file:///tmp/video.mp4', type: 'video/mp4', is_video: true, did_upload: false } + ]) + const [url, options] = fetch.mock.calls[0] + expect(url).toBe(service.endpoint) + expect(options.headers['Content-Type']).toBeUndefined() + expect(options.body.getParts()).toEqual(expect.arrayContaining([ + expect.objectContaining({ fieldName: 'photo', uri: 'file:///tmp/photo.jpg', type: 'image/jpeg' }), + expect.objectContaining({ fieldName: 'video', uri: 'file:///tmp/video.mp4', type: 'video/mp4' }) + ])) + } + finally { global.FormData = original_form_data } + }) + + test('uses the selected endpoint for edits and deletes, and removes an empty title', async () => { + await MicroPubApi.post_update(service, '

Hello

', post_url, null, [], 'published') + await MicroPubApi.delete_post(service, post_url) + await MicroPubApi.publish_draft(service, '

Hello

', post_url, '') + expect(fetch.mock.calls.map(([url]) => url)).toEqual([service.endpoint, service.endpoint, service.endpoint]) + const [edit, deletion, draft] = fetch.mock.calls.map(([, options]) => JSON.parse(options.body)) + expect(edit).toMatchObject({ action: 'update', url: post_url, delete: ['name'], replace: { content: ['

Hello

'] } }) + expect(edit.replace.name).toBeUndefined() + expect(deletion).toEqual({ action: 'delete', url: post_url }) + expect(draft.replace['post-status']).toEqual(['published']) + expect(draft.replace.content).toEqual(['

Hello

']) + }) + + test('does not delete unrelated properties when only content is being edited', async () => { + await MicroPubApi.post_update(service, 'Reply', post_url) + expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ action: 'update', url: post_url, replace: { content: ['Reply'] } }) + }) + + test('uses URL encoding for a bookmark and accepts an empty success body', async () => { + await MicroPubApi.send_entry(service, post_url, 'bookmark-of') + expect(new URLSearchParams(fetch.mock.calls[0][1].body).get('bookmark-of')).toBe(post_url) + }) + + test.each([400, 404, 405, 501])('accepts unsupported configuration with HTTP %s', async status => { + fetch.mockResolvedValue(response(status)) + await expect(MicroPubApi.get_config(service)).resolves.toEqual({}) + expect(fetch.mock.calls[0][0]).toBe(`${service.endpoint}&q=config`) + }) + + test('does not mask invalid credentials as empty configuration', async () => { + fetch.mockResolvedValue(response(401)) + await expect(MicroPubApi.get_config(service)).resolves.toBe(FETCH_ERROR) + }) + + test.each(['network', 'empty HTTP error', 'JSON HTTP error'])('handles %s without throwing', async failure => { + if (failure === 'network') { + fetch.mockRejectedValue(new Error('Network request failed')) + } + else { + const result = response(403, { error: 'insufficient_scope' }) + if (failure === 'empty HTTP error') { + result.json = async () => { throw new Error('Empty body') } + } + fetch.mockResolvedValue(result) + } + await expect(MicroPubApi.send_post(service, 'Hello')).resolves.toBe(3) + await expect(MicroPubApi.post_update(service, 'Hello', post_url)).resolves.toBe(3) + await expect(MicroPubApi.delete_post(service, post_url)).resolves.toBe(7) + await expect(MicroPubApi.publish_draft(service, 'Hello', post_url, '')).resolves.toBe(7) + await expect(MicroPubApi.send_entry(service, post_url, 'bookmark-of')).resolves.toBe(3) + }) + + test('preserves authorization endpoint query parameters and requests editing permissions', () => { + const url = new (require('url').URL)(MicroPubApi.make_auth_url('https://user.example/', 'https://auth.example/?action=authorize')) + expect(url.searchParams.get('action')).toBe('authorize') + expect(url.searchParams.get('me')).toBe('https://user.example/') + expect(url.searchParams.get('scope')).toBe('create update delete') + }) + + test('discovers relative HTTP Link endpoints without requiring HTML', async () => { + fetch.mockResolvedValue({ + url: 'https://user.example/profile/', + headers: { get: () => '; rel="micropub", ; rel="authorization_endpoint", ; rel="token_endpoint"' } + }) + await expect(MicroPubApi.discover_micropub_endpoints('http://user.example/')).resolves.toEqual({ + micropub: 'https://user.example/micropub', auth: 'https://user.example/auth', token: 'https://user.example/token', is_wordpress: false + }) + }) + + test('combines HTTP and HTML discovery, resolves against the redirected URL, and retains WordPress detection', async () => { + fetch.mockResolvedValue({ + url: 'https://user.example/profile/', + headers: { get: () => '; rel="micropub"' }, + text: async () => '' + }) + await expect(MicroPubApi.discover_micropub_endpoints('http://user.example/')).resolves.toEqual({ + micropub: 'https://user.example/wp-json/micropub', auth: 'https://user.example/profile/auth', token: 'https://user.example/token', is_wordpress: true + }) + }) +}) diff --git a/__tests__/stores/App.test.js b/__tests__/stores/App.test.js index 1e4fda19..08bf4eef 100644 --- a/__tests__/stores/App.test.js +++ b/__tests__/stores/App.test.js @@ -1,12 +1,13 @@ import App from '../../src/stores/App' import Push from '../../src/stores/Push' import Login from '../../src/stores/Login' +import MicroBlogApi from '../../src/api/MicroBlogApi' import { Linking } from 'react-native' import { CommonActions } from '@react-navigation/native' jest.mock('../../src/api/MicroBlogApi', () => ({ __esModule: true, - default: {} + default: { check_publishing_progress: jest.fn() } })) jest.mock('../../src/stores/Auth', () => ({ @@ -201,3 +202,48 @@ describe('App auth callback URLs', () => { expect(Login.trigger_login_from_url).toHaveBeenCalledWith(callback_url) }) }) + +describe('Publishing completion', () => { + afterEach(async () => { + await App.hide_publishing_progress() + jest.restoreAllMocks() + }) + + test('shows the third-party Location without polling Micro.blog', async () => { + MicroBlogApi.check_publishing_progress.mockClear() + await App.show_publishing_progress(false, 'https://third.example/post/1') + expect(App.latest_published_url).toBe('https://third.example/post/1') + expect(App.is_publishing).toBe(false) + expect(App.publishing_progress_visible).toBe(true) + expect(MicroBlogApi.check_publishing_progress).not.toHaveBeenCalled() + }) + + test('keeps the existing Micro.blog publishing progress behavior', async () => { + MicroBlogApi.check_publishing_progress.mockResolvedValue({ + is_publishing: false, publishing_progress: 1, latest_url: 'https://blog.example/post/1' + }) + await App.show_publishing_progress(true) + expect(MicroBlogApi.check_publishing_progress).toHaveBeenCalled() + expect(App.latest_published_url).toBe('https://blog.example/post/1') + }) + + test('does not let an earlier Micro.blog poll replace a third-party result', async () => { + let finish_poll + MicroBlogApi.check_publishing_progress.mockReturnValue(new Promise(resolve => { finish_poll = resolve })) + await App.show_publishing_progress(true) + await App.show_publishing_progress(false, 'https://third.example/post/1') + finish_poll({ is_publishing: false, publishing_progress: 1, latest_url: 'https://blog.example/old-post' }) + await Promise.resolve() + expect(App.latest_published_url).toBe('https://third.example/post/1') + }) + + test('finishes without polling when a third-party response has no Location', async () => { + const toast = jest.spyOn(App, 'show_toast').mockImplementation(() => {}) + MicroBlogApi.check_publishing_progress.mockClear() + await App.show_publishing_progress(false) + expect(App.is_publishing).toBe(false) + expect(App.publishing_progress_visible).toBe(false) + expect(toast).toHaveBeenCalledWith('Post sent.') + expect(MicroBlogApi.check_publishing_progress).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/stores/Micropub.test.js b/__tests__/stores/Micropub.test.js new file mode 100644 index 00000000..3562e803 --- /dev/null +++ b/__tests__/stores/Micropub.test.js @@ -0,0 +1,251 @@ +import { applySnapshot, getSnapshot } from 'mobx-state-tree' +import { Alert } from 'react-native' +import { launchImageLibrary } from 'react-native-image-picker' +import axios from 'axios' +import MicroPubApi from '../../src/api/MicroPubApi' +import Posting from '../../src/stores/models/Posting' +import Post from '../../src/stores/models/posting/Post' +import Destination from '../../src/stores/models/posting/Destination' +import * as largeMedia from '../../src/stores/models/posting/uploadLargeMediaTask' +import App from '../../src/stores/App' +import Tokens from '../../src/stores/Tokens' + +jest.mock('../../src/stores/App', () => ({ + show_publishing_progress: jest.fn(), + show_toast: jest.fn() +})) +jest.mock('../../src/stores/Auth', () => ({})) +jest.mock('../../src/stores/Tokens', () => ({ + token_for_service_id: jest.fn(), + token_for_username: jest.fn() +})) +jest.mock('../../src/api/XMLRPCApi', () => ({ __esModule: true, default: {}, XML_ERROR: 2 })) +jest.mock('react-native-fs', () => ({})) +jest.mock('react-native-image-picker', () => ({ launchImageLibrary: jest.fn() })) +jest.mock('@react-native-documents/picker', () => ({})) +jest.mock('@react-native-clipboard/clipboard', () => ({})) +jest.mock('react-native-simple-toast', () => ({})) +jest.mock('axios', () => ({ + get: jest.fn(), + post: jest.fn(), + CancelToken: { source: () => ({ token: {}, cancel: jest.fn() }) }, + isCancel: () => false +})) + +const endpoint = 'https://third.example/micropub' +const media_endpoint = 'https://media.third.example/upload' +const post_url = 'https://third.example/post/1' +const createPosting = (is_microblog = false, media = media_endpoint) => { + Tokens.token_for_service_id.mockReturnValue(undefined) + Tokens.token_for_username.mockReturnValue(undefined) + const posting = Posting.create({ + username: 'test', + services: [{ + id: 'service', name: is_microblog ? 'Micro.blog' : 'Third party', type: 'micropub', url: endpoint, + username: 'test', is_microblog, + config: { 'media-endpoint': media, destination: [{ uid: 'blog', syndicates: [{ uid: 'a', name: 'A' }, { uid: 'b', name: 'B' }] }] } + }], + selected_service: 'service', + post_text: 'Hello' + }) + Tokens.token_for_service_id.mockReturnValue({ token: 'token' }) + Tokens.token_for_username.mockReturnValue({ token: 'token' }) + return posting +} + +beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(Alert, 'alert').mockImplementation(() => {}) + jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 201, headers: { get: () => post_url } }) + axios.get.mockResolvedValue({ data: {} }) + App.show_publishing_progress.mockClear() +}) +afterEach(() => jest.restoreAllMocks()) + +test('initializes a generic destination and keeps standard syndication targets', async () => { + const posting = createPosting() + const service = posting.selected_service + const targets = [{ uid: 'social.example/user', name: 'Social' }] + await service.set_initial_config({ 'media-endpoint': media_endpoint, 'syndicate-to': targets }) + expect(service.active_destination().uid).toBe('') + expect(service.config.posts_destination()).toBe(service.active_destination()) + expect(getSnapshot(service.active_destination().syndicates)).toEqual(targets) + expect(service.service_object()).toMatchObject({ is_microblog: false, media_endpoint, destination: null, temporary_destination: null }) + await service.set_initial_config({}) + expect(service.active_destination()).not.toBeNull() +}) + +test.each([[undefined], [[]], [[{ uid: 'blog', name: 'Blog' }]], [[{ uid: endpoint, name: 'Blog' }]]])('only sends advertised destinations for configuration %j', async destinations => { + const service = createPosting().selected_service + await service.set_initial_config({ 'media-endpoint': media_endpoint, destination: destinations, 'syndicate-to': [] }) + const destination = service.active_destination() + const api_service = service.service_object() + const expected_destination = destinations?.[0]?.uid || null + + expect(service.config.posts_destination()).toBe(destination) + expect(api_service.destination).toBe(expected_destination) + expect(api_service.temporary_destination).toBe(expected_destination) + + await MicroPubApi.send_post(api_service, 'Hello') + await MicroPubApi.post_update(api_service, 'Edited', post_url) + await MicroPubApi.delete_post(api_service, post_url) + for (const [, options] of fetch.mock.calls) { + const body = options.headers['Content-Type'] === 'application/json' ? JSON.parse(options.body) : + Object.fromEntries(new (require('url').URLSearchParams)(options.body)) + if (expected_destination) { + expect(body['mp-destination']).toBe(expected_destination) + } + else { + expect(body).not.toHaveProperty('mp-destination') + } + } + + axios.get.mockClear() + for (const method of ['get_categories', 'get_syndicate_to', 'get_posts', 'get_pages', 'get_uploads', 'get_collections', 'get_uploads_from_collection']) { + await MicroPubApi[method](api_service, destination.uid) + } + for (const [url, options] of axios.get.mock.calls) { + const request_url = jest.requireActual('axios').getUri({ url, ...options }) + const params = new (require('url').URL)(request_url).searchParams + expect(params.get('mp-destination')).toBe(expected_destination) + } + + const original_form_data = global.FormData + global.FormData = require('react-native/Libraries/Network/FormData').default + axios.post.mockClear() + axios.post.mockResolvedValue({ data: {} }) + try { + await MicroPubApi.upload_image(api_service, { + uri: 'file:///tmp/photo.jpg', type: 'image/jpeg', cancel_source: { token: {} } + }) + const parts = axios.post.mock.calls[0][1].getParts() + expect(parts.filter(part => part.fieldName === 'mp-destination').map(part => part.string)).toEqual( + expected_destination ? [expected_destination] : [] + ) + } + finally { + global.FormData = original_form_data + } +}) + +test('keeps existing configuration when refresh fails', async () => { + const service = createPosting().selected_service + const config = getSnapshot(service.config) + fetch.mockRejectedValue(new Error('Offline')) + await service.hydrate() + expect(getSnapshot(service.config)).toEqual(config) +}) + +test('sends all selected syndication targets and uses the external post URL', async () => { + const posting = createPosting() + await expect(posting.send_post()).resolves.toBe(true) + const params = new (require('url').URLSearchParams)(fetch.mock.calls[0][1].body) + expect(params.getAll('mp-syndicate-to[]')).toEqual(['a', 'b']) + expect(App.show_publishing_progress).toHaveBeenCalledWith(false, post_url) +}) + +test('omits syndication when all Micro.blog targets are selected', async () => { + const posting = createPosting(true) + await expect(posting.send_post()).resolves.toBe(true) + const params = new (require('url').URLSearchParams)(fetch.mock.calls[0][1].body) + expect(params.has('mp-syndicate-to')).toBe(false) + expect(params.has('mp-syndicate-to[]')).toBe(false) +}) + +test.each([false, true])('omits syndication and clears stale selections when no targets exist, is_microblog=%s', async is_microblog => { + const posting = createPosting(is_microblog) + posting.selected_service.active_destination().set_syndicate_to_targets([]) + await posting.reset_post_syndicates() + expect(getSnapshot(posting.post_syndicates)).toEqual([]) + await expect(posting.send_post()).resolves.toBe(true) + const params = new (require('url').URLSearchParams)(fetch.mock.calls[0][1].body) + expect(params.has('mp-syndicate-to')).toBe(false) + expect(params.has('mp-syndicate-to[]')).toBe(false) +}) + +test.each([false, true])('sends a single selected target as an array, is_microblog=%s', async is_microblog => { + const posting = createPosting(is_microblog) + await posting.handle_post_syndicates_select('b') + await expect(posting.send_post()).resolves.toBe(true) + const params = new (require('url').URLSearchParams)(fetch.mock.calls[0][1].body) + expect(params.getAll('mp-syndicate-to[]')).toEqual(['a']) + expect(params.has('mp-syndicate-to')).toBe(false) +}) + +test.each([false, true])('sends an explicit empty list when all targets are deselected, is_microblog=%s', async is_microblog => { + const posting = createPosting(is_microblog) + await posting.handle_post_syndicates_select('a') + await posting.handle_post_syndicates_select('b') + await expect(posting.send_post()).resolves.toBe(true) + const options = fetch.mock.calls[0][1] + expect(options.headers['Content-Type']).toBe('application/json') + expect(JSON.parse(options.body)['mp-syndicate-to']).toEqual([]) +}) + +test('clears the sending flag and preserves the draft when the network fails', async () => { + const posting = createPosting() + fetch.mockRejectedValue(new Error('Offline')) + await expect(posting.send_post()).resolves.toBe(false) + expect(posting.is_sending_post).toBe(false) + expect(posting.post_text).toBe('Hello') + expect(App.show_publishing_progress).not.toHaveBeenCalled() +}) + +test('posts a file without trying a separate upload when there is no media endpoint', async () => { + const posting = createPosting(false, null) + const upload = jest.spyOn(MicroPubApi, 'upload_image') + await posting.create_and_attach_asset({ uri: 'file:///tmp/photo.jpg', type: 'image/jpeg' }) + expect(posting.post_assets[0].is_uploading).toBe(false) + expect(upload).not.toHaveBeenCalled() + const original_form_data = global.FormData + global.FormData = require('react-native/Libraries/Network/FormData').default + try { + await expect(posting.send_post()).resolves.toBe(true) + expect(fetch.mock.calls[0][1].body.getParts()).toEqual(expect.arrayContaining([ + expect.objectContaining({ fieldName: 'photo', uri: 'file:///tmp/photo.jpg' }) + ])) + } + finally { global.FormData = original_form_data } +}) + +test.each([false, true])('selects the correct video upload protocol when is_microblog=%s', async is_microblog => { + const posting = createPosting(is_microblog) + launchImageLibrary.mockResolvedValue({ assets: [{ uri: 'file:///tmp/clip.mp4', type: 'video/mp4', fileSize: 8 }] }) + const standard_upload = jest.spyOn(MicroPubApi, 'upload_image').mockResolvedValue({ success: true, headers: { location: 'https://media.third.example/clip.mp4' } }) + const chunked_upload = jest.spyOn(largeMedia, 'upload_large_media_task').mockResolvedValue({ url: 'https://media.third.example/clip.mp4' }) + await posting.handle_asset_action() + if (is_microblog) { + expect(chunked_upload).toHaveBeenCalledWith(expect.objectContaining({ service_object: expect.objectContaining({ is_microblog: true }) })) + expect(standard_upload).not.toHaveBeenCalled() + } + else { + expect(standard_upload).toHaveBeenCalledWith(expect.objectContaining({ is_microblog: false }), expect.anything()) + expect(chunked_upload).not.toHaveBeenCalled() + } +}) + +test('loads HTML posts, drafts, and pages without requiring numeric IDs', () => { + const destination = Destination.create({ uid: 'blog' }) + const entries = [{ properties: { url: [post_url], content: [{ html: '

Hello

' }] } }] + destination.set_posts(entries) + destination.set_drafts(entries) + destination.set_pages(entries) + for (const item of [destination.posts[0], destination.drafts[0], destination.pages[0]]) { + expect(item.uid).toBe(post_url) + expect(item.content).toBe('

Hello

') + } + destination.set_posts([{ properties: { ...entries[0].properties, uid: ['urn:entry:abc'] } }]) + expect(destination.posts[0].uid).toBe('urn:entry:abc') + expect(Post.create({ uid: 123 }).uid).toBe('123') +}) + +test('preserves Markdown and clears the sending flag when an edit fails', async () => { + const posting = createPosting() + applySnapshot(posting, { ...getSnapshot(posting), post_text: '**Hello**', post_url }) + fetch.mockRejectedValueOnce(new Error('Offline')) + await expect(posting.send_update_post()).resolves.toBe(false) + expect(posting.is_sending_post).toBe(false) + await expect(posting.send_update_post()).resolves.toBe(true) + expect(JSON.parse(fetch.mock.calls[1][1].body).replace.content).toEqual(['**Hello**']) + expect(App.show_publishing_progress).toHaveBeenCalledWith(false, post_url) +}) diff --git a/__tests__/stores/Services.test.js b/__tests__/stores/Services.test.js new file mode 100644 index 00000000..d684a212 --- /dev/null +++ b/__tests__/stores/Services.test.js @@ -0,0 +1,55 @@ +import { applySnapshot } from 'mobx-state-tree' +import { Alert, Linking } from 'react-native' +import Services from '../../src/stores/Services' +import MicroPubApi from '../../src/api/MicroPubApi' +import XMLRPCApi from '../../src/api/XMLRPCApi' + +jest.mock('../../src/stores/Auth', () => ({})) +jest.mock('../../src/stores/Tokens', () => ({})) +jest.mock('../../src/stores/App', () => ({ now: () => 123 })) +jest.mock('../../src/api/MicroPubApi', () => ({ + __esModule: true, + default: { + discover_micropub_endpoints: jest.fn(), + make_auth_url: jest.fn(() => 'https://example.com/auth') + } +})) +jest.mock('../../src/api/XMLRPCApi', () => ({ + __esModule: true, + default: { discover_rsd_endpoint: jest.fn() } +})) + +beforeEach(() => { + applySnapshot(Services, {}) + jest.clearAllMocks() + jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(Alert, 'alert').mockImplementation(() => {}) + jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined) + MicroPubApi.discover_micropub_endpoints.mockResolvedValue({ + micropub: 'https://example.com/micropub', + auth: 'https://example.com/auth', + token: 'https://example.com/token' + }) +}) + +afterEach(() => jest.restoreAllMocks()) + +test('rejects a malformed URL without leaving setup loading or starting discovery', async () => { + await Services.set_url('https://exa mple.com') + await expect(Services.setup_new_service()).resolves.toBeUndefined() + expect(Services.is_setting_up).toBe(false) + expect(Alert.alert).toHaveBeenCalledWith('Invalid URL', 'Please enter a valid URL for your weblog.') + expect(MicroPubApi.discover_micropub_endpoints).not.toHaveBeenCalled() + expect(XMLRPCApi.discover_rsd_endpoint).not.toHaveBeenCalled() +}) + +test('can retry with a corrected URL and preserve its existing query parameters', async () => { + await Services.set_url('https://exa mple.com') + await Services.setup_new_service() + await Services.set_url('example.com/?user=alice') + await Services.setup_new_service() + expect(MicroPubApi.discover_micropub_endpoints).toHaveBeenCalledWith('https://example.com/?user=alice&v=123') + expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/auth') + expect(Services.micropub_endpoint).toBe('https://example.com/micropub') + expect(Services.is_setting_up).toBe(false) +}) diff --git a/package.json b/package.json index 9bc4b7ba..a5eb9405 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,10 @@ "typescript": "~5.8.3" }, "jest": { - "preset": "react-native" + "preset": "react-native", + "transformIgnorePatterns": [ + "node_modules/(?!((jest-)?react-native|react-native-url-polyfill|@react-native(-community)?)/)" + ] }, "engines": { "node": ">=20.19.4", diff --git a/src/api/MicroBlogAuth.js b/src/api/MicroBlogAuth.js index 3d9d5bc2..cc12bbd7 100644 --- a/src/api/MicroBlogAuth.js +++ b/src/api/MicroBlogAuth.js @@ -1,7 +1,7 @@ export const MICRO_BLOG_AUTH_URL = 'https://micro.blog/indieauth/auth' export const MICRO_BLOG_TOKEN_URL = 'https://micro.blog/indieauth/token' export const MICRO_BLOG_CLIENT_ID = 'https://micro.blog/client.json' -export const MICRO_BLOG_SCOPE = 'create' +export const MICRO_BLOG_SCOPE = 'read write' export const MICRO_BLOG_SCHEME = 'microblog' export const MICRO_BLOG_REDIRECT_URI = `${MICRO_BLOG_SCHEME}://auth/callback` diff --git a/src/api/MicroPubApi.js b/src/api/MicroPubApi.js index 9f096ffa..7ce70dd2 100644 --- a/src/api/MicroPubApi.js +++ b/src/api/MicroPubApi.js @@ -1,5 +1,6 @@ import { Alert } from 'react-native'; import axios from 'axios'; +import { URL, URLSearchParams } from 'react-native-url-polyfill' import { DOMParser } from "@xmldom/xmldom"; import App from "./../stores/App"; import { buildUploadFileName } from "../utils/file_names" @@ -27,241 +28,224 @@ const progress_from_upload_event = progressEvent => { class MicroPubApi { async discover_micropub_endpoints(url, alternate_html_match = false) { - console.log("MicroPubApi:discover_micropub_endpoints", url, alternate_html_match) try { const response = await fetch(url, { - headers: { - "Accept": "text/html", - "Cache-Control": "no-cache" - } + headers: { Accept: 'text/html', 'Cache-Control': 'no-cache' } }) - const html = await response.text() - let links = [] - - if (alternate_html_match) { - const headContent = html.match(/]*>[\s\S]*?<\/head>/i)?.[0] || "" - - if (!headContent) { - throw new Error("Head content not found") + const base_url = response.url || url + const endpoints = {} + const addLink = (href, rel) => { + if (!href) { + return } - - const wrappedHtml = `${headContent}` - const dom_parser = new DOMParser() - const doc = dom_parser.parseFromString(wrappedHtml, "text/html") - links = doc.getElementsByTagName("link") - } - else { - const dom_parser = new DOMParser() - const doc = dom_parser.parseFromString(html, "text/html") - const head = doc.getElementsByTagName("head")[0] - links = head.getElementsByTagName("link") - } - - let micropub_link, auth_link, token_link - for (let i = 0; i < links.length; i++) { - const link = links[i] - if (link.getAttribute("rel") === "micropub") { - micropub_link = link + for (const name of rel.split(/\s+/)) { + if (['micropub', 'authorization_endpoint', 'token_endpoint'].includes(name) && !endpoints[name]) { + endpoints[name] = new URL(href, base_url).href + } } - else if (link.getAttribute("rel") === "authorization_endpoint") { - auth_link = link + } + // HTTP Link headers take precedence over HTML links. + const link_header = response.headers.get('Link') || '' + for (const match of link_header.matchAll(/<([^>]+)>([^,]*)/g)) { + const rel = match[2].match(/;\s*rel\s*=\s*(?:"([^"]+)"|([^;\s]+))/i) + if (rel) { + addLink(match[1], rel[1] || rel[2]) } - else if (link.getAttribute("rel") === "token_endpoint") { - token_link = link + } + if (!endpoints.micropub || !endpoints.authorization_endpoint || !endpoints.token_endpoint) { + const html = await response.text() + const source = alternate_html_match ? `${html.match(/]*>[\s\S]*?<\/head>/i)?.[0] || ''}` : html + const doc = new DOMParser().parseFromString(source, 'text/html') + const links = doc.getElementsByTagName('head')[0]?.getElementsByTagName('link') || [] + for (let i = 0; i < links.length; i++) { + addLink(links[i].getAttribute('href'), links[i].getAttribute('rel') || '') } } - - if (micropub_link && auth_link && token_link) { + if (endpoints.micropub && endpoints.authorization_endpoint && endpoints.token_endpoint) { return { - micropub: micropub_link.getAttribute("href"), - auth: auth_link.getAttribute("href"), - token: token_link.getAttribute("href"), - is_wordpress: micropub_link.getAttribute("href").includes("/wp-json") + micropub: endpoints.micropub, + auth: endpoints.authorization_endpoint, + token: endpoints.token_endpoint, + is_wordpress: endpoints.micropub.includes('/wp-json') } } - else { - return MICROPUB_NOT_FOUND - } + return MICROPUB_NOT_FOUND } catch (error) { console.log(error) - if (error?.toString()?.includes("Network error")) { - Alert.alert("Whoops. There was an error connecting to the URL. Please check the url and try again.") - } - else if (!alternate_html_match) { + if (!alternate_html_match) { return this.discover_micropub_endpoints(url, true) } - else { - Alert.alert("An error occurred trying to connect. Please try again.") - } return MICROPUB_NOT_FOUND } } - make_auth_url(me_url, base_auth_url) { - var new_url = base_auth_url - var new_state = Math.floor(Math.random() * 10000).toString(); // need to store this - - new_url = new_url + "?me=" + encodeURIComponent(me_url) - new_url = new_url + "&redirect_uri=" + encodeURIComponent("https://micro.blog/indieauth/redirect") - new_url = new_url + "&client_id=" + encodeURIComponent("https://micro.blog/") - new_url = new_url + "&state=" + new_state - new_url = new_url + "&scope=" + "create" - new_url = new_url + "&response_type=" + "code" - - return new_url - } + make_auth_url(me_url, base_auth_url) { + const url = new URL(base_auth_url) + url.searchParams.set('me', me_url) + url.searchParams.set('redirect_uri', 'https://micro.blog/indieauth/redirect') + url.searchParams.set('client_id', 'https://micro.blog/') + url.searchParams.set('state', Math.floor(Math.random() * 10000).toString()) + url.searchParams.set('scope', 'create update delete') + url.searchParams.set('response_type', 'code') + return url.href + } - async verify_code(service, auth_url) { - const regex = /[?&]code=([^&]+)/ - const match = regex.exec(auth_url) - if (match) { - const auth_code = match[1]; - console.log("Micropub: Got code:", auth_code); - console.log("Micropub: Sending to", service.token_endpoint) - var params_s = "" - params_s = params_s + "client_id=" + encodeURIComponent("https://micro.blog/") - params_s = params_s + "&code=" + encodeURIComponent(auth_code) - params_s = params_s + "&redirect_uri=" + encodeURIComponent("https://micro.blog/indieauth/redirect") - params_s = params_s + "&grant_type=" + "authorization_code" - const verify_response = axios - .post(service.token_endpoint, params_s, { - headers: { - "Content-type": "application/x-www-form-urlencoded", - "Accept": "application/json" - } - }) - .then(response => { - const access_token = response.data["access_token"] - console.log("Micropub: Got access token:", access_token) - if(access_token != null){ - return access_token - } - return NO_AUTH; - }) - .catch(error => { - console.log(error) - return FETCH_ERROR; - }); - return verify_response - } - else { - return NO_AUTH - } - } + async verify_code(service, auth_url) { + let auth_code + try { + // Decode the callback value before encoding it once in the token request. + auth_code = new URL(auth_url).searchParams.get('code') + } + catch (error) { + return NO_AUTH + } - async get_config(service) { - console.log('MicroPubApi:get_config', service.username); - const config = axios - .get(service.endpoint, { - headers: { Authorization: `Bearer ${service.token}` }, - params: { q: "config" } - }) - .then(response => { - return response.data; - }) - .catch(error => { - console.log(error); - return FETCH_ERROR; - }); - return config; - } + if (!auth_code) { + return NO_AUTH + } - async send_post(service, content, title = null, assets = [], categories = [], status = null, syndicate_to = null, summary = null) { - console.log('MicroBlogApi:send_post', service, content, title, assets, status, syndicate_to); - const params = new FormData() - params.append('h', 'entry') - params.append('content', content) - if (title) { - params.append('name', title) - } - if (status) { - params.append('post-status', status) - } - if (assets.length) { - const images_with_url = assets.filter(asset => asset.remote_url !== null && asset.did_upload && !asset.is_video) - if (images_with_url) { - // Now that we have images, we can append them to our params - if (images_with_url.length === 1) { - const first_image = images_with_url[0] - params.append('photo', first_image.remote_url) - if(first_image.alt_text != null && first_image.alt_text !== ""){ - params.append('mp-photo-alt', first_image.alt_text) - } - } - else { - images_with_url.map((image) => { - params.append('photo[]', image.remote_url) - if(image.alt_text != null && image.alt_text !== ""){ - params.append('mp-photo-alt[]', image.alt_text) - } - }) - } - } - const videos_with_url = assets.filter(asset => asset.remote_url !== null && asset.did_upload && asset.is_video) - if (videos_with_url) { - // Now that we have images, we can append them to our params - if (videos_with_url.length === 1) { - const first_asset = videos_with_url[0] - params.append('video', first_asset.remote_url) - } - else { - videos_with_url.map((video) => { - params.append('video[]', video.remote_url) - }) - } - } - } - if (categories.length) { - categories.map((category) => { - params.append('category[]', category) - }) - } - params.append('mp-destination', service.destination) - if (syndicate_to != null && syndicate_to.length > 0){ - syndicate_to.map((syndicate) => { - params.append('mp-syndicate-to[]', syndicate) - }) - } - else if(syndicate_to != null && syndicate_to.length === 0){ - params.append('mp-syndicate-to[]', "") - } - if (summary) { - params.append('summary', summary) - } - console.log("MicroBlogApi:send_post:FORM_DATA:PARAMS", params) - const post = axios - .post(service.endpoint, params ,{ - headers: { Authorization: `Bearer ${service.token}` } - }) - .then(() => { - return true; - }) - .catch(error => { - console.log("MicroBlogApi:send_post:ERROR", error.response.status, error.response.data); - if (error.response.data.error_description !== undefined && error.response.data.error_description !== null) { - Alert.alert( - "Something went wrong.", - `${error.response.data.error_description}`, - ) - } - else { - Alert.alert( - "Something went wrong.", - `Please try again later.`, - ) - } - return POST_ERROR; - }); - return post; - } + const params = new URLSearchParams({ + client_id: 'https://micro.blog/', + code: auth_code, + redirect_uri: 'https://micro.blog/indieauth/redirect', + grant_type: 'authorization_code' + }) + + try { + const response = await axios.post(service.token_endpoint, params.toString(), { + headers: { + 'Content-type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + } + }) + return response.data.access_token ?? NO_AUTH + } + catch (error) { + console.log(error) + return FETCH_ERROR + } + } + + async get_config(service) { + try { + const url = new URL(service.endpoint) + url.searchParams.set('q', 'config') + const response = await fetch(url.href, { + headers: { Authorization: `Bearer ${service.token}`, Accept: 'application/json' } + }) + if ([400, 404, 405, 501].includes(response.status)) { + return {} + } + if (!response.ok) { + return FETCH_ERROR + } + const config = await response.json().catch(() => ({})) + return config && typeof config === 'object' && !Array.isArray(config) ? config : {} + } + catch (error) { + console.log(error) + return FETCH_ERROR + } + } + + async sendRequest(service, body, content_type, error_code = POST_ERROR) { + try { + const headers = { Authorization: `Bearer ${service.token}` } + if (content_type) { + headers['Content-Type'] = content_type + } + const response = await fetch(service.endpoint, { method: 'POST', headers, body }) + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error?.error_description || `Server error (${response.status}). Please try again later.`) + } + const location = response.headers.get('Location') + return { url: location ? new URL(location, response.url || service.endpoint).href : null } + } + catch (error) { + console.log('MicroPubApi:sendRequest:error', error) + Alert.alert('Something went wrong.', error.message || 'Please try again later.') + return error_code + } + } + + async send_post(service, content, title = null, assets = [], categories = [], status = null, syndicate_to = null, summary = null) { + const properties = { content: [content] } + if (title) { + properties.name = [title] + } + if (status) { + properties['post-status'] = [status] + } + if (categories.length) { + properties.category = categories + } + if (summary) { + properties.summary = [summary] + } + if (service.destination) { + properties['mp-destination'] = [service.destination] + } + if (syndicate_to != null) { + properties['mp-syndicate-to'] = syndicate_to + } + + let has_files = false + for (const asset of assets) { + if (asset.is_inline && !service.is_microblog) { + continue + } + const property = asset.is_video ? 'video' : 'photo' + let value = asset.remote_url + if (!asset.did_upload && !service.media_endpoint) { + has_files = true + value = { uri: asset.cached_uri || asset.uri, type: asset.type, name: buildUploadFileName(asset, Date.now()) } + } + else if (!asset.did_upload || !value) { + continue + } + else if (!asset.is_video && asset.alt_text && !service.is_microblog) { + value = { value, alt: asset.alt_text } + } + if (!properties[property]) { + properties[property] = [] + } + properties[property].push(value) + if (!asset.is_video && service.is_microblog) { + if (!properties['mp-photo-alt']) { + properties['mp-photo-alt'] = [] + } + properties['mp-photo-alt'].push(asset.alt_text || '') + } + } + + const needs_json = !has_files && Object.values(properties).some(values => !values.length || values.some(value => typeof value === 'object')) + if (needs_json) { + const params = { type: ['h-entry'], properties } + for (const key of Object.keys(properties).filter(key => key.startsWith('mp-'))) { + params[key] = key === 'mp-destination' ? properties[key][0] : properties[key] + delete properties[key] + } + return this.sendRequest(service, JSON.stringify(params), 'application/json') + } + const params = has_files ? new FormData() : new URLSearchParams() + params.append('h', 'entry') + for (const [key, values] of Object.entries(properties)) { + // Multipart cannot express [], so retain an explicit empty value for no syndication. + for (const value of values.length ? values : ['']) { + params.append(key === 'mp-syndicate-to' || values.length > 1 ? `${key}[]` : key, value?.value || value) + } + } + return this.sendRequest(service, has_files ? params : params.toString(), has_files ? null : 'application/x-www-form-urlencoded') + } async get_categories(service, destination = null) { console.log('MicroPubApi:get_categories'); const config = axios .get(service.endpoint, { headers: { Authorization: `Bearer ${service.token}` }, - params: { q: "category", "mp-destination": destination } + params: { q: "category", "mp-destination": destination || undefined } }) .then(response => { return response.data; @@ -278,7 +262,7 @@ class MicroPubApi { const config = axios .get(service.endpoint, { headers: { Authorization: `Bearer ${service.token}` }, - params: { q: "syndicate-to", "mp-destination": destination } + params: { q: "syndicate-to", "mp-destination": destination || undefined } }) .then(response => { return response.data; @@ -298,7 +282,10 @@ class MicroPubApi { type: file.type, uri: file.uri }) - data.append("mp-destination", App.current_screen_name === "microblog.UploadsScreen" ? service.temporary_destination : service.destination) + const destination = App.current_screen_name === "microblog.UploadsScreen" ? service.temporary_destination : service.destination + if (destination) { + data.append("mp-destination", destination) + } console.log('MicroPubApi:upload_image', service, file, data); const upload = axios @@ -516,94 +503,39 @@ class MicroPubApi { }) } - async send_entry(service, entry, entry_type) { - console.log('MicroBlogApi:send_post', service, entry, entry_type); - const params = new FormData() - params.append('h', 'entry') - params.append(entry_type, entry) - params.append('mp-destination', service.destination) - console.log("MicroBlogApi:send_entry:FORM_DATA:PARAMS", params) - - const post = axios - .post(service.endpoint, params ,{ - headers: { Authorization: `Bearer ${service.token}` } - }) - .then(() => { - return true; - }) - .catch(error => { - console.log("MicroBlogApi:send_entry:ERROR", error.response.status, error.response.data); - if (axios.isCancel(error)) { - console.log("Request canceled:", error.message) - } - else if (error.response.data.error_description !== undefined && error.response.data.error_description !== null) { - Alert.alert( - "Something went wrong.", - `${error.response.data.error_description}`, - ) - } - else { - Alert.alert( - "Something went wrong.", - `Please try again later.`, - ) - } - return POST_ERROR; - }); - return post; - } - - async post_update(service, content, url, title, categories, post_status = "") { - console.log('MicroBlogApi:MicroPub:post_update', content, url, title, categories); - const params = { - "action": "update", - "url": url, - "mp-destination": service.destination, - "replace": { - "content": [ - content - ], - "name": [ - title - ], - "category": categories, - "post-status": [ - post_status - ] - } - } - console.log("MicroBlogApi:MicroPub:post_update:PARAMS", params) - - const post = axios - .post(`https://micro.blog/micropub`, params ,{ - headers: { Authorization: `Bearer ${service.token}` } - }) - .then(response => { - return true; - }) - .catch(error => { - console.log("MicroBlogApi:post_update:ERROR", error.response.status, error.response.data); - if (error.response.data.error_description !== undefined && error.response.data.error_description !== null) { - Alert.alert( - "Something went wrong.", - `${error.response.data.error_description}. Try again later.`, - ) - } - else { - Alert.alert( - "Something went wrong.", - `Please try again later.`, - ) - } - return POST_ERROR; - }); - return post; - } - + async send_entry(service, entry, entry_type) { + const params = new URLSearchParams({ h: 'entry', [entry_type]: entry }) + if (service.destination) { + params.set('mp-destination', service.destination) + } + return this.sendRequest(service, params.toString(), 'application/x-www-form-urlencoded') + } + + async post_update(service, content, url, title, categories, post_status = '') { + const replace = { content: [content] } + const params = { action: 'update', url, replace } + if (title === null || title === '') { + params.delete = ['name'] + } + else if (title !== undefined) { + replace.name = [title] + } + if (categories) { + replace.category = categories + } + if (post_status) { + replace['post-status'] = [post_status] + } + if (service.destination) { + params['mp-destination'] = service.destination + } + return this.sendRequest(service, JSON.stringify(params), 'application/json') + } + async get_posts(service, destination = null, is_drafts = false) { console.log('MicroPubApi:get_posts', is_drafts); let params = { - q: "source", "mp-destination": destination + q: "source", "mp-destination": destination || undefined }; if (is_drafts) { params["post-status"] = "draft"; @@ -624,87 +556,25 @@ class MicroPubApi { return config; } - async delete_post(service, url) { - console.log('MicroBlogApi:MicroPub:delete_post', url); - const params = { - "action": "delete", - "url": url, - "mp-destination": service.destination - } - console.log("MicroBlogApi:MicroPub:delete_post:PARAMS", params) - - const post = axios - .post(`https://micro.blog/micropub`, params ,{ - headers: { Authorization: `Bearer ${service.token}` } - }) - .then(response => { - return true; - }) - .catch(error => { - console.log("MicroBlogApi:delete_post:ERROR", error.response.status, error.response.data); - if (error.response.data.error_description !== undefined && error.response.data.error_description !== null) { - Alert.alert( - "Something went wrong.", - `${error.response.data.error_description}. Try again later.`, - ) - } - else { - Alert.alert( - "Something went wrong.", - `Please try again later.`, - ) - } - return DELETE_ERROR; - }); - return post; - } + async delete_post(service, url) { + const params = { action: 'delete', url } + if (service.destination) { + params['mp-destination'] = service.destination + } + return this.sendRequest(service, JSON.stringify(params), 'application/json', DELETE_ERROR) + } + + async publish_draft(service, content, url, title) { + const result = await this.post_update(service, content, url, title, undefined, 'published') + return result === POST_ERROR ? DELETE_ERROR : result + } - async publish_draft(service, content, url, title) { - console.log('MicroBlogApi:MicroPub:publish_post', url); - const params = { - "action": "update", - "url": url, - "mp-destination": service.destination, - "replace": { - "name": [ title ], - "content": [ content ], - "post-status": [ "published" ] - } - } - console.log("MicroBlogApi:MicroPub:publish_draft:PARAMS", params) - - const post = axios - .post(`https://micro.blog/micropub`, params ,{ - headers: { Authorization: `Bearer ${service.token}` } - }) - .then(response => { - return true; - }) - .catch(error => { - console.log("MicroBlogApi:publish_draft:ERROR", error.response.status, error.response.data); - if (error.response.data.error_description !== undefined && error.response.data.error_description !== null) { - Alert.alert( - "Something went wrong.", - `${error.response.data.error_description}. Try again later.`, - ) - } - else { - Alert.alert( - "Something went wrong.", - `Please try again later.`, - ) - } - return DELETE_ERROR; - }); - return post; - } - async get_pages(service, destination = null) { console.log('MicroPubApi:get_pages'); const config = axios .get(service.endpoint, { headers: { Authorization: `Bearer ${service.token}` }, - params: { q: "source", "mp-destination": destination, "mp-channel": "pages" } + params: { q: "source", "mp-destination": destination || undefined, "mp-channel": "pages" } }) .then(response => { return response.data; @@ -721,7 +591,7 @@ class MicroPubApi { const config = axios .get(service.media_endpoint, { headers: { Authorization: `Bearer ${service.token}` }, - params: { q: "source", "mp-destination": destination } + params: { q: "source", "mp-destination": destination || undefined } }) .then(response => { return response.data; @@ -733,48 +603,22 @@ class MicroPubApi { return config; } - async delete_upload(service, url) { - console.log('MicroBlogApi:MicroPub:delete_upload', url); - const params = { - "action": "delete", - "url": url, - "mp-destination": service.temporary_destination - } - console.log("MicroBlogApi:MicroPub:delete_upload:PARAMS", params) - - const upload = axios - .post(service.media_endpoint, "", { - headers: { Authorization: `Bearer ${ service.token }` }, - params: params - }) - .then(response => { - return true; - }) - .catch(error => { - console.log("MicroBlogApi:delete_upload:ERROR", error.response.status, error.response.data); - if (error.response.data.error_description !== undefined && error.response.data.error_description !== null) { - Alert.alert( - "Something went wrong.", - `${error.response.data.error_description}. Try again later.`, - ) - } - else { - Alert.alert( - "Something went wrong.", - `Please try again later.`, - ) - } - return DELETE_ERROR; - }); - return upload; - } + async delete_upload(service, url) { + const endpoint = new URL(service.media_endpoint) + endpoint.searchParams.set('action', 'delete') + endpoint.searchParams.set('url', url) + if (service.temporary_destination) { + endpoint.searchParams.set('mp-destination', service.temporary_destination) + } + return this.sendRequest({ ...service, endpoint: endpoint.href }, '', null, DELETE_ERROR) + } async get_collections(service, destination = null) { console.log('MicroPubApi:get_collections'); const config = axios .get(service.endpoint, { headers: { Authorization: `Bearer ${service.token}` }, - params: { q: "source", "mp-destination": destination, "mp-channel": "collections" } + params: { q: "source", "mp-destination": destination || undefined, "mp-channel": "collections" } }) .then(response => { return response.data; @@ -793,7 +637,7 @@ class MicroPubApi { headers: { Authorization: `Bearer ${service.token}` }, params: { q: "source", - "mp-destination": destination, + "mp-destination": destination || undefined, "microblog-collection": collection_url } }) @@ -813,7 +657,7 @@ class MicroPubApi { const params = { "action": "update", "mp-channel": "collections", - "mp-destination": service.temporary_destination, + "mp-destination": service.temporary_destination || undefined, "url": collection_url, "add": { "photo": [ upload_url ] @@ -840,7 +684,7 @@ class MicroPubApi { const params = { "action": "update", "mp-channel": "collections", - "mp-destination": service.temporary_destination, + "mp-destination": service.temporary_destination || undefined, "url": collection_url, "delete": { "photo": [ upload_url ] @@ -866,7 +710,7 @@ class MicroPubApi { const params = { "mp-channel": "collections", - "mp-destination": service.temporary_destination, + "mp-destination": service.temporary_destination || undefined, "properties": { "name": [ name ] } @@ -891,7 +735,7 @@ class MicroPubApi { const params = { "mp-channel": "collections", - "mp-destination": service.temporary_destination, + "mp-destination": service.temporary_destination || undefined, "action": "delete", "url": collection_url }; @@ -914,7 +758,9 @@ class MicroPubApi { console.log('MicroPubApi:set_alt_for_upload'); const params = new FormData() - params.append('mp-destination', service.temporary_destination); + if (service.temporary_destination) { + params.append('mp-destination', service.temporary_destination) + } params.append('action', 'update'); params.append('url', upload_url); params.append('alt', alt_text); diff --git a/src/components/cells/post_cell.js b/src/components/cells/post_cell.js index 29aa88c7..b91852c8 100644 --- a/src/components/cells/post_cell.js +++ b/src/components/cells/post_cell.js @@ -206,6 +206,7 @@ export default class PostCell extends React.Component { actions={menu_items} > this._right_actions(progress, reply)} > { ) }) -export default PublishingProgress \ No newline at end of file +export default PublishingProgress diff --git a/src/stores/App.js b/src/stores/App.js index 92b15e74..20f46187 100644 --- a/src/stores/App.js +++ b/src/stores/App.js @@ -960,7 +960,9 @@ export default App = types.model('App', { set_current_tab_index: flow(function*(tab_index) { console.log("App:set_current_tab_index", tab_index) - if (tab_index === self.current_tab_index) { return } + if (tab_index === self.current_tab_index) { + return + } self.current_tab_index = tab_index AsyncStorage.setItem("App:tab_index", JSON.stringify(self.current_tab_index)) }), @@ -1164,8 +1166,20 @@ export default App = types.model('App', { self.toolbar_categories_open = !self.toolbar_categories_open }), - show_publishing_progress: flow(function*() { + show_publishing_progress: flow(function* (is_microblog = true, url = null) { console.log("App:show_publishing_progress") + self.stop_publishing_progress_polling() + if (!is_microblog) { + self.is_publishing = false + self.publishing_progress = 100 + self.publishing_status = 'Post sent.' + self.latest_published_url = url || null + self.publishing_progress_visible = !!url + if (!url) { + self.show_toast('Post sent.') + } + return + } self.publishing_progress_visible = true self.is_publishing = true self.publishing_progress = 0 @@ -1204,6 +1218,9 @@ export default App = types.model('App', { } const response = yield MicroBlogApi.check_publishing_progress() + if (!self.publishing_progress_visible || !self.is_publishing) { + return + } if (response !== API_ERROR && response != null) { self.is_publishing = response.is_publishing self.publishing_progress = response.publishing_progress * 100 diff --git a/src/stores/Services.js b/src/stores/Services.js index bda818d3..f28c4758 100644 --- a/src/stores/Services.js +++ b/src/stores/Services.js @@ -5,6 +5,7 @@ import Auth from "./Auth"; import { blog_services } from './enums/blog_services'; import Tokens from "./Tokens"; import { Alert, Linking } from 'react-native'; +import { URL } from 'react-native-url-polyfill' import App from './App'; export default Services = types.model('Services', { @@ -75,7 +76,18 @@ export default Services = types.model('Services', { } // check for Micropub first, then try XML-RPC - const micropub_endpoints = yield MicroPubApi.discover_micropub_endpoints(`${discover_url}?v=${App.now()}`) + let discovery_url + try { + discovery_url = new URL(discover_url) + } + catch (error) { + console.log('Services:setup_new_service:invalid_url', error) + self.is_setting_up = false + Alert.alert('Invalid URL', 'Please enter a valid URL for your weblog.') + return + } + discovery_url.searchParams.set('v', App.now().toString()) + const micropub_endpoints = yield MicroPubApi.discover_micropub_endpoints(discovery_url.href) if (micropub_endpoints !== MICROPUB_NOT_FOUND && !micropub_endpoints.is_wordpress) { console.log("Micropub: Found endpoints:", micropub_endpoints) self.micropub_endpoint = micropub_endpoints["micropub"] @@ -86,7 +98,7 @@ export default Services = types.model('Services', { Linking.openURL(auth_url) } else { - const rsd_link = yield XMLRPCApi.discover_rsd_endpoint(`${discover_url}?v=${App.now()}`) + const rsd_link = yield XMLRPCApi.discover_rsd_endpoint(discovery_url.href) console.log("Services:setup_new_service:rsd_link", rsd_link) if(rsd_link !== RSD_NOT_FOUND){ const blog_info = yield XMLRPCApi.discover_preferred_blog(rsd_link) @@ -289,4 +301,4 @@ export default Services = types.model('Services', { } })) -.create({}) \ No newline at end of file +.create({}) diff --git a/src/stores/models/Posting.js b/src/stores/models/Posting.js index f422b78d..db6e5c4f 100644 --- a/src/stores/models/Posting.js +++ b/src/stores/models/Posting.js @@ -164,7 +164,8 @@ export default Posting = types.model('Posting', { return false } // Check if any uploads are still pending (for both regular posts and share extension) - const pending_uploads = self.post_assets.filter(asset => !asset.did_upload && !asset.is_uploading) + const upload_before_post = self.selected_service.type === 'xmlrpc' || !!self.selected_service.service_object().media_endpoint + const pending_uploads = upload_before_post ? self.post_assets.filter(asset => !asset.did_upload && !asset.is_uploading) : [] const uploading_assets = self.post_assets.filter(asset => asset.is_uploading) if (pending_uploads.length > 0) { @@ -190,9 +191,15 @@ export default Posting = types.model('Posting', { } self.is_sending_post = true const should_show_progress = should_show_publishing_progress(self.post_status) + const syndicates = self.selected_service.active_destination()?.syndicates || [] + let syndicate_to = self.post_syndicates + // Use Micro.blog's cross-post-everywhere default when all targets are selected. + if (!syndicates.length || (self.selected_service.is_microblog && self.post_syndicates.length === syndicates.length)) { + syndicate_to = null + } const post_success = self.selected_service.type === "xmlrpc" ? yield XMLRPCApi.send_post(self.selected_service.service_object(), self.post_text, self.post_title, self.post_assets, self.post_categories, self.post_status) - : yield MicroPubApi.send_post(self.selected_service.service_object(), self.post_text, self.post_title, self.post_assets, self.post_categories, self.post_status, self.post_syndicates.length === self.selected_service.active_destination()?.syndicates?.length ? null : self.post_syndicates, self.summary) + : yield MicroPubApi.send_post(self.selected_service.service_object(), self.post_text, self.post_title, self.post_assets, self.post_categories, self.post_status, syndicate_to, self.summary) if(post_success !== POST_ERROR && post_success !== XML_ERROR){ self.post_text = "" self.post_title = null @@ -201,17 +208,11 @@ export default Posting = types.model('Posting', { self.new_category_text = "" // self.post_status = "published" self.summary = null - if(self.selected_service && self.selected_service.active_destination()?.syndicates?.length > 0){ - let syndicate_targets = [] - self.selected_service.active_destination()?.syndicates.forEach((syndicate) => { - syndicate_targets.push(syndicate.uid) - }) - self.post_syndicates = syndicate_targets - } + self.reset_post_syndicates() self.is_sending_post = false self.is_closing_after_post = true if (should_show_progress) { - App.show_publishing_progress(); + App.show_publishing_progress(self.selected_service.is_microblog, post_success.url) } return true } @@ -317,7 +318,7 @@ export default Posting = types.model('Posting', { } const media_asset = MediaAsset.create(asset) self.post_assets.push(media_asset) - if (is_video && self.selected_service?.type !== "xmlrpc") { + if (is_video && self.selected_service?.is_microblog) { self.upload_video_asset(media_asset) } else { @@ -482,7 +483,7 @@ export default Posting = types.model('Posting', { self.is_adding_bookmark = false if (post_success !== POST_ERROR) { App.handle_web_view_message("bookmark_added_from_app") - App.show_publishing_progress() + App.show_publishing_progress(self.selected_service.is_microblog, post_success.url) return true } return false @@ -510,12 +511,13 @@ export default Posting = types.model('Posting', { } self.is_sending_post = true const should_show_progress = should_show_publishing_progress(self.post_status) - const post_success = yield MicroPubApi.post_update(self.selected_service.service_object(), self.post_text, self.post_url, self.post_title, self.post_categories, self.post_status) + const post_url = self.post_url + const post_success = yield MicroPubApi.post_update(self.selected_service.service_object(), self.post_text, post_url, self.post_title, self.post_categories, self.post_status) self.is_sending_post = false if(post_success !== POST_ERROR){ self.clear_post() if (should_show_progress) { - App.show_publishing_progress() + App.show_publishing_progress(self.selected_service.is_microblog, post_success.url || post_url) } return true } @@ -582,7 +584,9 @@ export default Posting = types.model('Posting', { }), activate_new_service: flow(function* (service = null) { - if(service === null){return false} + if(service === null){ + return false + } self.selected_service = service console.log("Posting:activate_new_service", service) return true @@ -626,13 +630,7 @@ export default Posting = types.model('Posting', { reset_post_syndicates: flow(function* () { console.log("Posting:reset_post_syndicates") - if(self.selected_service && self.selected_service.active_destination()?.syndicates?.length > 0){ - let syndicate_targets = [] - self.selected_service.active_destination()?.syndicates.forEach((syndicate) => { - syndicate_targets.push(syndicate.uid) - }) - self.post_syndicates = syndicate_targets - } + self.post_syndicates = self.selected_service?.active_destination()?.syndicates?.map(syndicate => syndicate.uid) || [] }), reset_post_status: flow(function* () { @@ -641,7 +639,9 @@ export default Posting = types.model('Posting', { toggle_title: flow(function* () { console.log("Posting:toggle_title") - if(self.post_title){return} + if(self.post_title){ + return + } self.show_title = !self.show_title }), diff --git a/src/stores/models/posting/Destination.js b/src/stores/models/posting/Destination.js index 40612a05..1f4656a2 100644 --- a/src/stores/models/posting/Destination.js +++ b/src/stores/models/posting/Destination.js @@ -53,7 +53,7 @@ const Destination = types.model('Destination', { set_posts(entries) { console.log("Destination:set_posts", entries.length) const posts = entries.reduce((acc, entry) => { - const uid = entry.properties.uid && entry.properties.uid[0] ? parseInt(entry.properties.uid[0], 10) : 0 + const uid = String(entry.properties.uid?.[0] ?? entry.properties.url?.[0] ?? '') const name = entry.properties.name ? entry.properties.name[0] : "" const content = entry.properties.content ? entry.properties.content[0] : "" const published = entry.properties.published ? entry.properties.published[0] : "" @@ -64,14 +64,14 @@ const Destination = types.model('Destination', { const post = { uid: uid, name: name, - content: content, + content: content?.html ?? content, published: published, url: url, post_status: post_status, category: categories, summary: summary } - if (uid === 0 || url === "") { + if (url === "") { return acc } return [...acc, post] @@ -83,7 +83,7 @@ const Destination = types.model('Destination', { set_drafts(entries) { console.log("Destination:set_drafts", entries.length) const posts = entries.reduce((acc, entry) => { - const uid = entry.properties.uid && entry.properties.uid[0] ? parseInt(entry.properties.uid[0], 10) : 0 + const uid = String(entry.properties.uid?.[0] ?? entry.properties.url?.[0] ?? '') const name = entry.properties.name ? entry.properties.name[0] : "" const content = entry.properties.content ? entry.properties.content[0] : "" const published = entry.properties.published ? entry.properties.published[0] : "" @@ -94,14 +94,14 @@ const Destination = types.model('Destination', { const post = { uid: uid, name: name, - content: content, + content: content?.html ?? content, published: published, url: url, post_status: post_status, category: categories, summary: summary } - if (uid === 0 || url === "") { + if (url === "") { return acc } return [...acc, post] @@ -113,7 +113,7 @@ const Destination = types.model('Destination', { set_pages(entries) { console.log("Destination:set_pages", entries.length) const pages = entries.reduce((acc, entry) => { - const uid = entry.properties.uid && entry.properties.uid[0] ? parseInt(entry.properties.uid[0], 10) : 0 + const uid = String(entry.properties.uid?.[0] ?? entry.properties.url?.[0] ?? '') const name = entry.properties.name ? entry.properties.name[0] : "" const content = entry.properties.content ? entry.properties.content[0] : "" const published = entry.properties.published ? entry.properties.published[0] : "" @@ -122,12 +122,12 @@ const Destination = types.model('Destination', { const post = { uid: uid, name: name, - content: content, + content: content?.html ?? content, published: published, url: url, template: template } - if (uid === 0 || url === "") { + if (url === "") { return acc; } return [...acc, post] diff --git a/src/stores/models/posting/MediaAsset.js b/src/stores/models/posting/MediaAsset.js index b15dcc38..dda19431 100644 --- a/src/stores/models/posting/MediaAsset.js +++ b/src/stores/models/posting/MediaAsset.js @@ -35,6 +35,10 @@ export default MediaAsset = types.model('MediaAsset', { .actions(self => ({ upload: flow(function* (service_object) { + // Without a media endpoint the file is sent with the post itself. + if (service_object.type === 'micropub' && !service_object.media_endpoint) { + return true + } self.cancelled = false self.is_uploading = true if (service_object.type !== "xmlrpc") { diff --git a/src/stores/models/posting/Page.js b/src/stores/models/posting/Page.js index eab2012d..f71046df 100644 --- a/src/stores/models/posting/Page.js +++ b/src/stores/models/posting/Page.js @@ -6,13 +6,14 @@ let html_parser = new DOMParser({ onError: (error) => { }}); export default Page = types.model('Page', { - uid: types.identifierNumber, + uid: types.identifier, name: types.maybe(types.string), content: types.maybe(types.string), published: types.maybe(types.string), url: types.maybe(types.string), template: types.optional(types.boolean, false) }) +.preProcessSnapshot(snapshot => ({ ...snapshot, uid: String(snapshot.uid) })) .views(self => ({ plain_text_content(){ diff --git a/src/stores/models/posting/Post.js b/src/stores/models/posting/Post.js index fadb7c7d..12056255 100644 --- a/src/stores/models/posting/Post.js +++ b/src/stores/models/posting/Post.js @@ -6,7 +6,7 @@ let html_parser = new DOMParser({ onError: (error) => { }}); export default Post = types.model('Post', { - uid: types.identifierNumber, + uid: types.identifier, name: types.maybe(types.string), content: types.maybe(types.string), published: types.maybe(types.string), @@ -15,6 +15,7 @@ export default Post = types.model('Post', { category: types.optional(types.array(types.string), []), summary: types.maybeNull(types.string) }) +.preProcessSnapshot(snapshot => ({ ...snapshot, uid: String(snapshot.uid) })) .views(self => ({ plain_text_content(){ diff --git a/src/stores/models/posting/Service.js b/src/stores/models/posting/Service.js index 7f9ed76a..7eef60e0 100644 --- a/src/stores/models/posting/Service.js +++ b/src/stores/models/posting/Service.js @@ -26,33 +26,18 @@ const Service = types.model('Service', { hydrate: flow(function* () { console.log("Service:hydrate", self.id) - if(self.credentials()?.token == null){ return } + if(self.credentials()?.token == null){ + return + } if(self.is_microblog || self.type === "micropub"){ const config = yield MicroPubApi.get_config(self.service_object()) console.log("Service:hydrate:micropub:config", config) - if(config){ - // We need to check if config.destination exists, and if not, we need to create it. - if(config.destination == null){ - config.destination = [ - { - uid: self.name.includes("http://") || self.name.includes("https://") ? self.name : `https://${self.name}`, - name: self.name - } - ] - } - // Before we set the new config, let's check if we had a config beforehand + if (config !== FETCH_ERROR) { const previously_set_destination = self.config?.selected_posts_destination - self.config = config - if(previously_set_destination != null){ + yield self.set_initial_config(config) + if (previously_set_destination != null) { self.config.set_previously_selected_posts_destination(previously_set_destination) } - else{ - self.config.hydrate_default_destination() - } - self.check_for_syndicate_to_targets() - if(App.is_share_extension){ - self.check_for_categories() - } } } else if(self.type === "xmlrpc"){ @@ -66,7 +51,9 @@ const Service = types.model('Service', { check_for_categories: flow(function* () { - if(self.credentials()?.token == null){ return } + if(self.credentials()?.token == null){ + return + } if(self.config?.destination != null && self.config.destination.length > 0 && self.type !== "xmlrpc"){ self.config.destination.forEach(async (destination) => { @@ -372,7 +359,7 @@ const Service = types.model('Service', { if (res.assets?.length > 0) { res.assets.forEach((asset) => { console.log("Destination:pick_image:asset", asset) - if (asset?.type?.startsWith("video")) { + if (self.is_microblog && asset?.type?.startsWith("video")) { destination.upload_large_media(asset, self); } else { @@ -388,8 +375,18 @@ const Service = types.model('Service', { if((self.is_microblog || self.type === "micropub") && self.credentials()?.token != null && config != null){ console.log("Service:set_initial_config:config", config) if(config){ - self.config = config + self.config = { + ...config, + // Keep a UI entry without inventing a server destination. + destination: config.destination?.length ? config.destination : [{ uid: '', name: self.name }] + } self.config.hydrate_default_destination() + if (Array.isArray(config['syndicate-to'])) { + self.config.active_destination().set_syndicate_to_targets(config['syndicate-to']) + } + else { + self.check_for_syndicate_to_targets() + } if(App.is_share_extension){ self.check_for_categories() } @@ -448,11 +445,12 @@ export default Service endpoint: self.url, username: self.username, token: this.credentials()?.token, - destination: self.config?.active_destination()?.uid, + destination: self.config?.active_destination()?.uid || null, media_endpoint: self.config?.media_endpoint(), - temporary_destination: self.config?.temporary_destination()?.uid, + temporary_destination: self.config?.temporary_destination()?.uid || null, blog_id: self.blog_id, type: self.type, + is_microblog: self.is_microblog, } },