diff options
author | Louis Chemineau <louis@chmn.me> | 2023-01-19 17:18:11 +0100 |
---|---|---|
committer | Louis Chemineau <louis@chmn.me> | 2023-03-09 19:02:31 +0100 |
commit | ab347790a8fb92c5e1fe87ff3b8d00989fd0386d (patch) | |
tree | cd68054617654bf7d1154843f672acb4178fa9c5 /src | |
parent | 991a49c7b9a226522b76dabff5991acdae148989 (diff) |
Use new Mastodon like API
Revert "Revert "Merge pull request #1581 from nextcloud/artonge/feat/use_new_api""
This reverts commit c4eef0b2d476ffcf10cfc3a44ea426d3ec7587b4.
Signed-off-by: Louis Chemineau <louis@chmn.me>
Adapt views to new timeline api
Signed-off-by: Louis Chemineau <louis@chmn.me>
Add types
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix type errors
Signed-off-by: Louis Chemineau <louis@chmn.me>
Adapte front-end to mastodon data format
+ Add typing
+ Modernize code
Signed-off-by: Louis Chemineau <louis@chmn.me>
Use new API for media attachments
+ Split Composer.vue into tinier composent
+ Use blurhash value of attachments
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix media attachment rendering in post component
Signed-off-by: Louis Chemineau <louis@chmn.me>
Use square container to display statuses attachments
Signed-off-by: Louis Chemineau <louis@chmn.me>
Add typing to timeline.js
And fix type errors
Signed-off-by: Louis Chemineau <louis@chmn.me>
Forward format to getStreamSelectSql for direct timeline
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix liked timeline
Signed-off-by: Louis Chemineau <louis@chmn.me>
Use new API for local and federated timelines
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix profile and avatar for local users
Signed-off-by: Louis Chemineau <louis@chmn.me>
Update babel config
Signed-off-by: Louis Chemineau <louis@chmn.me>
Improve typing in account.js
Signed-off-by: Louis Chemineau <louis@chmn.me>
Handle new notification format
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix follow button
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix condition of delete button for statuses
Signed-off-by: Louis Chemineau <louis@chmn.me>
Add relationship fetching
Signed-off-by: Louis Chemineau <louis@chmn.me>
Improve attachments viewer
Signed-off-by: Louis Chemineau <louis@chmn.me>
Correctly use twemoji
Signed-off-by: Louis Chemineau <louis@chmn.me>
Clean up composer
Signed-off-by: Louis Chemineau <louis@chmn.me>
Insert emoji on the last line instead of creating a new one
Signed-off-by: Louis Chemineau <louis@chmn.me>
Overall improvements in composer
Signed-off-by: Louis Chemineau <louis@chmn.me>
Clean up PreviewGridItem
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix fetching relationships
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix followers and following list
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix direct link to followers and following lists
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix notifications endpoint
Signed-off-by: Louis Chemineau <louis@chmn.me>
Handle different types of notifications
Signed-off-by: Louis Chemineau <louis@chmn.me>
Add formatted date as title for statuses
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix entryContent computed property
Signed-off-by: Louis Chemineau <louis@chmn.me>
Handle reblog
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix favourite type
Signed-off-by: Louis Chemineau <louis@chmn.me>
Load context of status for single post
Signed-off-by: Louis Chemineau <louis@chmn.me>
Use new format to set the uid in single post
Signed-off-by: Louis Chemineau <louis@chmn.me>
Fix display name property
Signed-off-by: Louis Chemineau <louis@chmn.me>
Hack to handle context of single post
Signed-off-by: Louis Chemineau <louis@chmn.me>
Use item id to fetch context
Signed-off-by: Louis Chemineau <louis@chmn.me>
Remove unsused variable
Signed-off-by: Louis Chemineau <louis@chmn.me>
Diffstat (limited to 'src')
38 files changed, 1546 insertions, 929 deletions
diff --git a/src/App.vue b/src/App.vue index d1f70e76..45fdd29d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -106,9 +106,11 @@ export default { } }, computed: { + /** @return {import('vue').PropType<import('../types/Mastodon.js').Account>} */ timeline() { return this.$store.getters.getTimeline }, + /** @return {{items: {id: string, icon: object, title: string, to: { name: string } }, loading: boolean}} */ menu() { const defaultCategories = [ { @@ -152,7 +154,7 @@ export default { title: t('social', 'Liked'), to: { name: 'timeline', - params: { type: 'liked' }, + params: { type: 'favourites' }, }, }, { @@ -263,3 +265,10 @@ export default { } </style> +<style lang="css"> +img.emoji { + margin: 3px; + width: 16px; + vertical-align: text-bottom; +} +</style> diff --git a/src/components/ActorAvatar.vue b/src/components/ActorAvatar.vue index 50f026a8..890399c4 100644 --- a/src/components/ActorAvatar.vue +++ b/src/components/ActorAvatar.vue @@ -21,10 +21,10 @@ --> <template> - <NcAvatar v-if="actor.local" + <NcAvatar v-if="isLocal" :size="size" - :user="actor.preferredUsername" - :display-name="actor.account" + :user="actor.username" + :display-name="actor.acct" :disable-tooltip="true" :show-user-status="false" /> <NcAvatar v-else @@ -44,8 +44,15 @@ export default { NcAvatar, }, props: { - actor: { type: Object, default: () => {} }, - size: { type: Number, default: 32 }, + /** @type {import('vue').PropType<import('../types/Mastodon.js').Account>} */ + actor: { + type: Object, + default: () => {}, + }, + size: { + type: Number, + default: 32, + }, }, data() { return { @@ -53,9 +60,16 @@ export default { } }, computed: { + /** @return {string} */ avatarUrl() { return generateUrl('/apps/social/api/v1/global/actor/avatar?id=' + this.item.attributedTo) }, + /** + * @return {boolean} + */ + isLocal() { + return !this.actor.acct.includes('@') + }, }, } </script> diff --git a/src/components/Composer/Composer.vue b/src/components/Composer/Composer.vue index 62d4f6ca..343a58f1 100644 --- a/src/components/Composer/Composer.vue +++ b/src/components/Composer/Composer.vue @@ -26,6 +26,8 @@ <input id="file-upload" ref="fileUploadInput" type="file" + accept="image/*" + multiple="true" tabindex="-1" aria-hidden="true" class="hidden-visually" @@ -47,8 +49,8 @@ <div v-if="replyTo" class="reply-to"> <p class="reply-info"> <span>{{ t('social', 'In reply to') }}</span> - <ActorAvatar :actor="replyTo.actor_info" :size="16" /> - <strong>{{ replyTo.actor_info.account }}</strong> + <ActorAvatar :actor="replyTo.account" :size="16" /> + <strong>{{ replyTo.account.acct }}</strong> <NcButton type="tertiary" class="close-button" :aria-label="t('social', 'Close reply')" @@ -64,25 +66,24 @@ </div> <form class="new-post-form" @submit.prevent="createPost"> <VueTribute :options="tributeOptions"> - <!-- eslint-disable-next-line vue/valid-v-model --> <div ref="composerInput" - v-contenteditable:post.dangerousHTML="canType && !loading" + :disabled="loading" class="message" placeholder="What would you like to share?" :class="{'icon-loading': loading}" @keyup.prevent.enter="keyup" + @input="updateStatusContent" @tribute-replaced="updatePostFromTribute" /> </VueTribute> <PreviewGrid :uploading="false" :upload-progress="0.4" - :miniatures="previewUrls" + :miniatures="attachments" @deleted="deletePreview" /> <div class="options"> <NcButton v-tooltip="t('social', 'Add attachment')" type="tertiary" - :disabled="previewUrls.length >= 1" :aria-label="t('social', 'Add attachment')" @click.prevent="clickImportInput"> <template #icon> @@ -94,7 +95,7 @@ <NcEmojiPicker ref="emojiPicker" :search="search" :close-on-select="false" - :container="container" + container="#content-vue" @select="insert"> <NcButton v-tooltip="t('social', 'Add emoji')" type="tertiary" @@ -107,18 +108,11 @@ </NcEmojiPicker> </div> - <div v-click-outside="hidePopoverMenu" class="popovermenu-parent"> - <NcButton v-tooltip="t('social', 'Visibility')" - type="tertiary" - :class="currentVisibilityIconClass" - @click.prevent="togglePopoverMenu" /> - <div :class="{open: menuOpened}" class="popovermenu"> - <NcPopoverMenu :menu="visibilityPopover" /> - </div> - </div> - + <VisibilitySelect :type.sync="type" /> <div class="emptySpace" /> - <NcButton :value="currentVisibilityPostLabel" + <SubmitStatusButton :type="type" :disabled="canPost || loading" @click="createPost" /> + + <!-- <NcButton :value="currentVisibilityPostLabel" :disabled="!canPost" native-type="submit" type="primary" @@ -127,7 +121,7 @@ <Send title="" :size="22" decorative /> </template> {{ postTo }} - </NcButton> + </NcButton> --> </div> </form> </div> @@ -136,12 +130,11 @@ <script> import EmoticonOutline from 'vue-material-design-icons/EmoticonOutline.vue' -import Send from 'vue-material-design-icons/Send.vue' import Close from 'vue-material-design-icons/Close.vue' import FileUpload from 'vue-material-design-icons/FileUpload.vue' +import debounce from 'debounce' import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' -import NcPopoverMenu from '@nextcloud/vue/dist/Components/NcPopoverMenu.js' import NcEmojiPicker from '@nextcloud/vue/dist/Components/NcEmojiPicker.js' import VueTribute from 'vue-tribute' import he from 'he' @@ -151,11 +144,18 @@ import axios from '@nextcloud/axios' import ActorAvatar from '../ActorAvatar.vue' import { generateUrl } from '@nextcloud/router' import PreviewGrid from './PreviewGrid.vue' +import VisibilitySelect from './VisibilitySelect.vue' +import SubmitStatusButton from './SubmitStatusButton.vue' + +/** + * @typedef LocalAttachment + * @property {File} file - The file object from the input element. + * @property {import('../../types/Mastodon.js').MediaAttachment} data - The attachment information from the server. + */ export default { name: 'Composer', components: { - NcPopoverMenu, NcAvatar, NcEmojiPicker, NcButton, @@ -163,25 +163,24 @@ export default { FileUpload, VueTribute, EmoticonOutline, - Send, Close, PreviewGrid, + VisibilitySelect, + SubmitStatusButton, }, directives: { FocusOnCreate, }, mixins: [CurrentUserMixin], - props: {}, data() { return { + statusContent: '', type: localStorage.getItem('social.lastPostType') || 'followers', loading: false, - post: '', - miniatures: [], // miniatures of images stored in postAttachments - postAttachments: [], // The toot's attachments - previewUrls: [], - canType: true, + /** @type {Object<string, LocalAttachment>} */ + attachments: {}, search: '', + /** @type {import('../../types/Mastodon.js').Status} */ replyTo: null, tributeOptions: { spaceSelectsMatch: true, @@ -201,25 +200,23 @@ export default { return '<span class="mention" contenteditable="false">' + '<a href="' + item.original.url + '" target="_blank"><img src="' + item.original.avatar + '" />@' + item.original.value + '</a></span>' }, - values: (text, cb) => { - const users = [] - + values: debounce(async (text, populate) => { if (text.length < 1) { - cb(users) + populate([]) } - this.remoteSearchAccounts(text).then((result) => { - for (const i in result.data.result.accounts) { - const user = result.data.result.accounts[i] - users.push({ - key: user.preferredUsername, - value: user.account, - url: user.url, - avatar: user.local ? generateUrl(`/avatar/${user.preferredUsername}/32`) : generateUrl(`apps/social/api/v1/global/actor/avatar?id=${user.id}`), - }) - } - cb(users) - }) - }, + + const response = await this.remoteSearchAccounts(text) + + const users = response.data.result.accounts.map((user) => ({ + key: user.preferredUsername, + value: user.account, + url: user.url, + avatar: user.local ? generateUrl(`/avatar/${user.preferredUsername}/32`) : generateUrl(`apps/social/api/v1/global/actor/avatar?id=${user.id}`), + })) + + console.debug('[Composer] Found users for', text, response.data.result, users) + populate(users) + }, 200), }, { trigger: '#', @@ -237,29 +234,20 @@ export default { return '<span class="hashtag" contenteditable="false">' + '<a href="' + generateUrl('/timeline/tags/' + tag) + '" target="_blank">#' + tag + '</a></span>' }, - values: (text, cb) => { - const tags = [] - + values: debounce(async (text, populate) => { if (text.length < 1) { - cb(tags) + populate([]) } - this.remoteSearchHashtags(text).then((result) => { - if (result.data.result.exact) { - tags.push({ - key: result.data.result.exact, - value: result.data.result.exact, - }) - } - for (const i in result.data.result.tags) { - const tag = result.data.result.tags[i] - tags.push({ - key: tag.hashtag, - value: tag.hashtag, - }) - } - cb(tags) - }) - }, + + const response = await this.remoteSearchHashtags(text) + const tags = [ + ...(response.data.result.exact && !Array.isArray(response.data.result.exact) ? [{ key: response.data.result.exact, value: response.data.result.exact }] : []), + ...response.data.result.tags.map(({ hashtag }) => ({ key: hashtag, value: hashtag })), + ] + + console.debug('[Composer] Found tags for', text, response.data.result, tags) + populate(tags) + }, 200), }, ], noMatchTemplate() { @@ -272,123 +260,15 @@ export default { } }, }, - menuOpened: false, - } }, computed: { - postTo() { - switch (this.type) { - case 'public': - case 'unlisted': - return t('social', 'Post') - case 'followers': - return t('social', 'Post to followers') - case 'direct': - return t('social', 'Post to mentioned users') - } - return '' - }, - currentVisibilityIconClass() { - return this.visibilityIconClass(this.type) - }, - visibilityIconClass() { - return (type) => { - if (typeof type === 'undefined') { - type = this.type - } - switch (type) { - case 'public': - return 'icon-link' - case 'followers': - return 'icon-contacts-dark' - case 'direct': - return 'icon-external' - case 'unlisted': - return 'icon-password' - } - } - }, - currentVisibilityPostLabel() { - return this.visibilityPostLabel(this.type) - }, - visibilityPostLabel() { - return (type) => { - if (typeof type === 'undefined') { - type = this.type - } - switch (type) { - case 'public': - return t('social', 'Post publicly') - case 'followers': - return t('social', 'Post to followers') - case 'direct': - return t('social', 'Post to recipients') - case 'unlisted': - return t('social', 'Post unlisted') - } - } - }, - activeState() { - return (type) => { - if (type === this.type) { - return true - } else { - return false - } - } - }, - visibilityPopover() { - return [ - { - action: () => { - this.switchType('public') - }, - icon: this.visibilityIconClass('public'), - active: this.activeState('public'), - text: t('social', 'Public'), - longtext: t('social', 'Post to public timelines'), - }, - { - action: () => { - this.switchType('unlisted') - }, - icon: this.visibilityIconClass('unlisted'), - active: this.activeState('unlisted'), - text: t('social', 'Unlisted'), - longtext: t('social', 'Do not post to public timelines'), - }, - { - action: () => { - this.switchType('followers') - }, - icon: this.visibilityIconClass('followers'), - active: this.activeState('followers'), - text: t('social', 'Followers'), - longtext: t('social', 'Post to followers only'), - }, - { - action: () => { - this.switchType('direct') - }, - icon: this.visibilityIconClass('direct'), - active: this.activeState('direct'), - text: t('social', 'Direct'), - longtext: t('social', 'Post to mentioned users only'), - }, - ] - }, - container() { - return '#content-vue' - }, - containerElement() { - return document.querySelector(this.container) - }, + /** @return {boolean} */ canPost() { - if (this.previewUrls.length > 0) { + if (Object.keys(this.attachments).length > 0) { return true } - return this.post.length !== 0 && this.post !== '<br>' + return this.statusContent.length !== 0 && this.statusContent !== '<br>' }, }, mounted() { @@ -398,95 +278,63 @@ export default { }) }, methods: { + updateStatusContent() { + this.statusContent = this.$refs.composerInput.innerHTML + }, clickImportInput() { this.$refs.fileUploadInput.click() }, + /** @param {InputEvent} event */ handleFileChange(event) { - event.target.files.forEach((file) => { - this.previewUrls.push({ - description: '', - url: URL.createObjectURL(file), - result: file, + /** @type {HTMLInputElement} */ + const target = event.target + Array.from(target.files).forEach(async (file) => { + const url = URL.createObjectURL(file) + this.$set(this.attachments, url, { + file, + data: null, }) + this.$set(this.attachments[url], 'data', await this.$store.dispatch('createMedia', file)) }) }, - removeAttachment(idx) { - this.previewUrls.splice(idx, 1) - }, insert(emoji) { + console.debug('[Composer] insert emoji', emoji) if (typeof emoji === 'object') { const category = Object.keys(emoji)[0] const emojis = emoji[category] const firstEmoji = Object.keys(emojis)[0] emoji = emojis[firstEmoji] } - this.post += this.$twemoji.parse(emoji) + ' ' - this.$refs.composerInput.innerHTML += this.$twemoji.parse(emoji) + ' ' - }, - togglePopoverMenu() { - this.menuOpened = !this.menuOpened - }, - hidePopoverMenu() { - this.menuOpened = false - }, - switchType(type) { - this.type = type - this.menuOpened = false - localStorage.setItem('social.lastPostType', type) - }, - getPostData() { - const element = this.$refs.composerInput.cloneNode(true) - Array.from(element.getElementsByClassName('emoji')).forEach((emoji) => { - const em = document.createTextNode(emoji.getAttribute('alt')) - emoji.replaceWith(em) - }) - const contentHtml = element.innerHTML - - // Extract mentions from content and create an array out of them - const to = [] - const mentionRegex = /<span class="mention"[^>]+><a[^>]+><img[^>]+>@([\w-_.]+@[\w-.]+)/g - let match = null - do { - match = mentionRegex.exec(contentHtml) - if (match) { - to.push(match[1]) - } - } while (match) - - // Add author of original post in case of reply - if (this.replyTo !== null) { - to.push(this.replyTo.actor_info.account) - } - - // Extract hashtags from content and create an array ot of them - const hashtagRegex = />#([^<]+)</g - const hashtags = [] - match = null - do { - match = hashtagRegex.exec(contentHtml) - if (match) { - hashtags.push(match[1]) + /** @type {Element} */ + const lastChild = this.$refs.composerInput.lastChild + const div = document.createElement('div') + div.innerHTML = this.$twemoji.parse(emoji) + ' ' + + if (lastChild === null) { + this.$refs.composerInput.innerHTML = div.innerHTML + } else { + + // Content usually ends with </br> or </> + // This makes sure that we put the emoji before those tags. + switch (lastChild.tagName) { + case 'BR': + lastChild.before(div.firstChild) + break + case 'DIV': + switch (lastChild.lastChild.tagName) { + case 'BR': + lastChild.lastChild.before(div.firstChild) + break + default: + lastChild.append(div.firstChild) + } + break + default: + lastChild.after(div.firstChild) } - } while (match) - - // Remove all html tags but </div> (wich we turn in newlines) and decode the remaining html entities - let content = contentHtml.replace(/<(?!\/div)[^>]+>/gi, '').replace(/<\/div>/gi, '\n').trim() - content = he.decode(content) - - const formData = new FormData() - formData.append('content', content) - to.forEach(to => formData.append('to[]', to)) - hashtags.forEach(hashtag => formData.append('hashtags[]', hashtag)) - formData.append('type', this.type) - this.previewUrls.forEach(preview => formData.append('attachments[]', preview.result)) - this.previewUrls.forEach(preview => formData.append('attachmentDescriptions[]', preview.description)) - - if (this.replyTo) { - formData.append('replyTo', this.replyTo.id) } - - return formData + this.updateStatusContent() }, keyup(event) { if (event.shiftKey || event.ctrlKey) { @@ -494,45 +342,44 @@ export default { } }, updatePostFromTribute(event) { - // Trick to let vue-contenteditable know that tribute replaced a mention or hashtag - this.$refs.composerInput.oninput(event) + console.debug('[Composer] update from tribute', event) + this.updateStatusContent() }, async createPost(event) { + // Replace emoji <img> tag with actual emojis. + // They will be replaced again with twemoji during rendering + const element = this.$refs.composerInput.cloneNode(true) + Array.from(element.getElementsByClassName('emoji')).forEach((emoji) => { |