Fix alts for image uploads (#54)
* Fix alts for image uploads Fixes #41 * fix alts mixed with no-alts
This commit is contained in:
		
							parent
							
								
									bca959d1a3
								
							
						
					
					
						commit
						7ae3212c55
					
				
					 13 changed files with 275 additions and 90 deletions
				
			
		|  | @ -4,6 +4,7 @@ import { postStatus as postStatusToServer } from '../_api/statuses' | |||
| import { addStatusOrNotification } from './addStatusOrNotification' | ||||
| import { database } from '../_database/database' | ||||
| import { emit } from '../_utils/eventBus' | ||||
| import { putMediaDescription } from '../_api/media' | ||||
| 
 | ||||
| export async function insertHandleForReply (statusId) { | ||||
|   let instanceName = store.get('currentInstance') | ||||
|  | @ -20,7 +21,8 @@ export async function insertHandleForReply (statusId) { | |||
| } | ||||
| 
 | ||||
| export async function postStatus (realm, text, inReplyToId, mediaIds, | ||||
|                                   sensitive, spoilerText, visibility) { | ||||
|                                   sensitive, spoilerText, visibility, | ||||
|                                   mediaDescriptions = []) { | ||||
|   let instanceName = store.get('currentInstance') | ||||
|   let accessToken = store.get('accessToken') | ||||
|   let online = store.get('online') | ||||
|  | @ -34,6 +36,9 @@ export async function postStatus (realm, text, inReplyToId, mediaIds, | |||
|     postingStatus: true | ||||
|   }) | ||||
|   try { | ||||
|     await Promise.all(mediaDescriptions.map(async (description, i) => { | ||||
|       return description && putMediaDescription(instanceName, accessToken, mediaIds[i], description) | ||||
|     })) | ||||
|     let status = await postStatusToServer(instanceName, accessToken, text, | ||||
|       inReplyToId, mediaIds, sensitive, spoilerText, visibility) | ||||
|     addStatusOrNotification(instanceName, 'home', status) | ||||
|  |  | |||
|  | @ -36,9 +36,15 @@ export function deleteMedia (realm, i) { | |||
|   let composeText = store.getComposeData(realm, 'text') || '' | ||||
|   composeText = composeText.replace(' ' + deletedMedia.data.text_url, '') | ||||
| 
 | ||||
|   let mediaDescriptions = store.getComposeData(realm, 'mediaDescriptions') || [] | ||||
|   if (mediaDescriptions[i]) { | ||||
|     mediaDescriptions[i] = null | ||||
|   } | ||||
| 
 | ||||
|   store.setComposeData(realm, { | ||||
|     media: composeMedia, | ||||
|     text: composeText | ||||
|     text: composeText, | ||||
|     mediaDescriptions: mediaDescriptions | ||||
|   }) | ||||
|   scheduleIdleTask(() => store.save()) | ||||
| } | ||||
|  |  | |||
|  | @ -1,10 +1,17 @@ | |||
| import { auth, basename } from './utils' | ||||
| import { postWithTimeout } from '../_utils/ajax' | ||||
| import { postWithTimeout, putWithTimeout } from '../_utils/ajax' | ||||
| 
 | ||||
| export async function uploadMedia (instanceName, accessToken, file, description) { | ||||
|   let formData = new FormData() | ||||
|   formData.append('file', file) | ||||
|   formData.append('description', description) | ||||
|   if (description) { | ||||
|     formData.append('description', description) | ||||
|   } | ||||
|   let url = `${basename(instanceName)}/api/v1/media` | ||||
|   return postWithTimeout(url, formData, auth(accessToken)) | ||||
| } | ||||
| 
 | ||||
| export async function putMediaDescription (instanceName, accessToken, mediaId, description) { | ||||
|   let url = `${basename(instanceName)}/api/v1/media/${mediaId}` | ||||
|   return putWithTimeout(url, {description}, auth(accessToken)) | ||||
| } | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ | |||
|   <ComposeLengthGauge :length :overLimit /> | ||||
|   <ComposeToolbar :realm :postPrivacy :media :contentWarningShown :text /> | ||||
|   <ComposeLengthIndicator :length :overLimit /> | ||||
|   <ComposeMedia :realm :media /> | ||||
|   <ComposeMedia :realm :media :mediaDescriptions /> | ||||
| </div> | ||||
| <div class="compose-box-button-sentinel {{hideAndFadeIn}}" ref:sentinel></div> | ||||
| <div class="compose-box-button-wrapper {{realm === 'home' ? 'compose-button-sticky' : ''}} {{hideAndFadeIn}}" > | ||||
|  | @ -170,7 +170,8 @@ | |||
|       overLimit: (length) => length > CHAR_LIMIT, | ||||
|       contentWarningShown: (composeData) => composeData.contentWarningShown, | ||||
|       contentWarning: (composeData) => composeData.contentWarning || '', | ||||
|       timelineInitialized: ($timelineInitialized) => $timelineInitialized | ||||
|       timelineInitialized: ($timelineInitialized) => $timelineInitialized, | ||||
|       mediaDescriptions: (composeData) => composeData.mediaDescriptions || [] | ||||
|     }, | ||||
|     transitions: { | ||||
|       slide | ||||
|  | @ -193,6 +194,7 @@ | |||
|           let mediaIds = media.map(_ => _.data.id) | ||||
|           let inReplyTo = (realm === 'home' || realm === 'dialog') ? null : realm | ||||
|           let overLimit = this.get('overLimit') | ||||
|           let mediaDescriptions = this.get('mediaDescriptions') | ||||
| 
 | ||||
|           if (!text || overLimit) { | ||||
|             return // do nothing if invalid | ||||
|  | @ -200,7 +202,7 @@ | |||
| 
 | ||||
|           /* no await */ | ||||
|           postStatus(realm, text, inReplyTo, mediaIds, | ||||
|             sensitive, contentWarning, postPrivacyKey) | ||||
|             sensitive, contentWarning, postPrivacyKey, mediaDescriptions) | ||||
|         } | ||||
|       }, | ||||
|       setupStickyObserver() { | ||||
|  |  | |||
|  | @ -67,13 +67,13 @@ | |||
|         }) | ||||
|       }, | ||||
|       setupSyncToStore() { | ||||
|         const saveText = debounce(() => scheduleIdleTask(() => this.store.save()), 1000) | ||||
|         const saveStore = debounce(() => scheduleIdleTask(() => this.store.save()), 1000) | ||||
| 
 | ||||
|         this.observe('rawText', rawText => { | ||||
|           mark('observe rawText') | ||||
|           let realm = this.get('realm') | ||||
|           this.store.setComposeData(realm, {text: rawText}) | ||||
|           saveText() | ||||
|           saveStore() | ||||
|           stop('observe rawText') | ||||
|         }, {init: false}) | ||||
|       }, | ||||
|  |  | |||
|  | @ -1,23 +1,7 @@ | |||
| {{#if media.length}} | ||||
|   <div class="compose-media-container" style="grid-template-columns: repeat({{media.length}}, 1fr);"> | ||||
|     {{#each media as mediaItem, i}} | ||||
|       <div class="compose-media"> | ||||
|         <img src="{{mediaItem.data.preview_url}}" alt="{{mediaItem.file.name}}"/> | ||||
|         <div class="compose-media-delete"> | ||||
|           <button class="compose-media-delete-button" | ||||
|                   aria-label="Delete {{mediaItem.file.name}}" | ||||
|                   on:click="onDeleteMedia(i)" > | ||||
|             <svg class="compose-media-delete-button-svg"> | ||||
|               <use xlink:href="#fa-times" /> | ||||
|             </svg> | ||||
|           </button> | ||||
|         </div> | ||||
|         <div class="compose-media-alt"> | ||||
|           <input type="text" | ||||
|                  placeholder="Description" | ||||
|                  aria-label="Describe {{mediaItem.file.name}} for the visually impaired"> | ||||
|         </div> | ||||
|       </div> | ||||
|     {{#each media as mediaItem, index}} | ||||
|       <ComposeMediaItem :realm :mediaItem :index :mediaDescriptions /> | ||||
|     {{/each}} | ||||
|   </div> | ||||
| {{/if}} | ||||
|  | @ -33,72 +17,15 @@ | |||
|     padding: 5px; | ||||
|     border-radius: 4px; | ||||
|   } | ||||
|   .compose-media { | ||||
|     height: 200px; | ||||
|     overflow: hidden; | ||||
|     flex-direction: column; | ||||
|     position: relative; | ||||
|     display: flex; | ||||
|     background: var(--main-bg); | ||||
|   } | ||||
|   .compose-media img { | ||||
|     object-fit: contain; | ||||
|     object-position: center center; | ||||
|     flex: 1; | ||||
|     height: 100%; | ||||
|     width: 100%; | ||||
|   } | ||||
|   .compose-media-alt { | ||||
|     z-index: 10; | ||||
|     position: absolute; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|   } | ||||
|   .compose-media-alt input { | ||||
|     width: 100%; | ||||
|     font-size: 1.2em; | ||||
|     background: var(--alt-input-bg); | ||||
|   } | ||||
|   .compose-media-alt input:focus { | ||||
|     background: var(--main-bg); | ||||
|   } | ||||
|   .compose-media-delete { | ||||
|     position: absolute; | ||||
|     z-index: 10; | ||||
|     top: 0; | ||||
|     right: 0; | ||||
|     left: 0; | ||||
|     display: flex; | ||||
|     justify-content: flex-end; | ||||
|     margin: 2px; | ||||
|   } | ||||
|   .compose-media-delete-button { | ||||
|     padding: 10px; | ||||
|     background: none; | ||||
|     border: none; | ||||
|   } | ||||
|   .compose-media-delete-button:hover { | ||||
|     background: var(--toast-border); | ||||
|   } | ||||
|   .compose-media-delete-button-svg { | ||||
|     fill: var(--button-text); | ||||
|     width: 18px; | ||||
|     height: 18px; | ||||
|   } | ||||
| </style> | ||||
| <script> | ||||
|   import { store } from '../../_store/store' | ||||
|   import { deleteMedia } from '../../_actions/media' | ||||
|   import ComposeMediaItem from './ComposeMediaItem.html' | ||||
| 
 | ||||
|   export default { | ||||
|     store: () => store, | ||||
|     methods: { | ||||
|       onDeleteMedia(i) { | ||||
|         deleteMedia(this.get('realm'), i) | ||||
|       } | ||||
|     components: { | ||||
|       ComposeMediaItem | ||||
|     } | ||||
|   } | ||||
| </script> | ||||
							
								
								
									
										126
									
								
								routes/_components/compose/ComposeMediaItem.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								routes/_components/compose/ComposeMediaItem.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,126 @@ | |||
| <div class="compose-media"> | ||||
|   <img src="{{mediaItem.data.preview_url}}" alt="{{mediaItem.file.name}}"/> | ||||
|   <div class="compose-media-delete"> | ||||
|     <button class="compose-media-delete-button" | ||||
|             aria-label="Delete {{mediaItem.file.name}}" | ||||
|             on:click="onDeleteMedia()" > | ||||
|       <svg class="compose-media-delete-button-svg"> | ||||
|         <use xlink:href="#fa-times" /> | ||||
|       </svg> | ||||
|     </button> | ||||
|   </div> | ||||
|   <div class="compose-media-alt"> | ||||
|     <input type="text" | ||||
|            placeholder="Description" | ||||
|            aria-label="Describe {{mediaItem.file.name}} for the visually impaired" | ||||
|            bind:value=rawText | ||||
|     > | ||||
|   </div> | ||||
| </div> | ||||
| <style> | ||||
|   .compose-media { | ||||
|     height: 200px; | ||||
|     overflow: hidden; | ||||
|     flex-direction: column; | ||||
|     position: relative; | ||||
|     display: flex; | ||||
|     background: var(--main-bg); | ||||
|   } | ||||
|   .compose-media img { | ||||
|     object-fit: contain; | ||||
|     object-position: center center; | ||||
|     flex: 1; | ||||
|     height: 100%; | ||||
|     width: 100%; | ||||
|   } | ||||
|   .compose-media-alt { | ||||
|     z-index: 10; | ||||
|     position: absolute; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     right: 0; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|   } | ||||
|   .compose-media-alt input { | ||||
|     width: 100%; | ||||
|     font-size: 1.2em; | ||||
|     background: var(--alt-input-bg); | ||||
|   } | ||||
|   .compose-media-alt input:focus { | ||||
|     background: var(--main-bg); | ||||
|   } | ||||
|   .compose-media-delete { | ||||
|     position: absolute; | ||||
|     z-index: 10; | ||||
|     top: 0; | ||||
|     right: 0; | ||||
|     left: 0; | ||||
|     display: flex; | ||||
|     justify-content: flex-end; | ||||
|     margin: 2px; | ||||
|   } | ||||
|   .compose-media-delete-button { | ||||
|     padding: 10px; | ||||
|     background: none; | ||||
|     border: none; | ||||
|   } | ||||
|   .compose-media-delete-button:hover { | ||||
|     background: var(--toast-border); | ||||
|   } | ||||
|   .compose-media-delete-button-svg { | ||||
|     fill: var(--button-text); | ||||
|     width: 18px; | ||||
|     height: 18px; | ||||
|   } | ||||
| </style> | ||||
| <script> | ||||
|   import { store } from '../../_store/store' | ||||
|   import { deleteMedia } from '../../_actions/media' | ||||
|   import debounce from 'lodash-es/debounce' | ||||
|   import { scheduleIdleTask } from '../../_utils/scheduleIdleTask' | ||||
| 
 | ||||
|   export default { | ||||
|     oncreate() { | ||||
|       this.setupSyncFromStore() | ||||
|       this.setupSyncToStore() | ||||
|     }, | ||||
|     data: () => ({ | ||||
|       rawText: '' | ||||
|     }), | ||||
|     store: () => store, | ||||
|     methods: { | ||||
|       setupSyncFromStore() { | ||||
|         this.observe('mediaDescriptions', mediaDescriptions => { | ||||
|           mediaDescriptions = mediaDescriptions || [] | ||||
|           let index = this.get('index') | ||||
|           let text = mediaDescriptions[index] || '' | ||||
|           if (this.get('rawText') !== text) { | ||||
|             this.set({rawText: text}) | ||||
|           } | ||||
|         }) | ||||
|       }, | ||||
|       setupSyncToStore() { | ||||
|         const saveStore = debounce(() => scheduleIdleTask(() => this.store.save()), 1000) | ||||
| 
 | ||||
|         this.observe('rawText', rawText => { | ||||
|           let realm = this.get('realm') | ||||
|           let index = this.get('index') | ||||
|           let mediaDescriptions = store.getComposeData(realm, 'mediaDescriptions') || [] | ||||
|           if (mediaDescriptions[index] === rawText) { | ||||
|             return | ||||
|           } | ||||
|           while (mediaDescriptions.length <= index) { | ||||
|             mediaDescriptions.push(null) | ||||
|           } | ||||
|           mediaDescriptions[index] = rawText | ||||
|           store.setComposeData(realm, {mediaDescriptions}) | ||||
|           saveStore() | ||||
|         }, {init: false}) | ||||
|       }, | ||||
|       onDeleteMedia() { | ||||
|         deleteMedia(this.get('realm'), this.get('index')) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| </script> | ||||
|  | @ -2,4 +2,4 @@ let count = -1 | |||
| 
 | ||||
| export function createDialogId () { | ||||
|   return ++count | ||||
| } | ||||
| } | ||||
|  |  | |||
|  | @ -41,6 +41,26 @@ async function _post (url, body, headers, timeout) { | |||
|   return throwErrorIfInvalidResponse(response) | ||||
| } | ||||
| 
 | ||||
| async function _put (url, body, headers, timeout) { | ||||
|   let fetchFunc = timeout ? fetchWithTimeout : fetch | ||||
|   let opts = { | ||||
|     method: 'PUT' | ||||
|   } | ||||
|   if (body) { | ||||
|     opts.headers = Object.assign(headers, { | ||||
|       'Accept': 'application/json', | ||||
|       'Content-Type': 'application/json' | ||||
|     }) | ||||
|     opts.body = JSON.stringify(body) | ||||
|   } else { | ||||
|     opts.headers = Object.assign(headers, { | ||||
|       'Accept': 'application/json' | ||||
|     }) | ||||
|   } | ||||
|   let response = await fetchFunc(url, opts) | ||||
|   return throwErrorIfInvalidResponse(response) | ||||
| } | ||||
| 
 | ||||
| async function _get (url, headers, timeout) { | ||||
|   let fetchFunc = timeout ? fetchWithTimeout : fetch | ||||
|   let response = await fetchFunc(url, { | ||||
|  | @ -63,6 +83,14 @@ async function _delete (url, headers, timeout) { | |||
|   return throwErrorIfInvalidResponse(response) | ||||
| } | ||||
| 
 | ||||
| export async function put (url, body, headers = {}) { | ||||
|   return _put(url, body, headers, false) | ||||
| } | ||||
| 
 | ||||
| export async function putWithTimeout (url, body, headers = {}) { | ||||
|   return _put(url, body, headers, true) | ||||
| } | ||||
| 
 | ||||
| export async function post (url, body, headers = {}) { | ||||
|   return _post(url, body, headers, false) | ||||
| } | ||||
|  |  | |||
|  | @ -1,4 +1,7 @@ | |||
| import { composeInput, getNthDeleteMediaButton, getNthMedia, mediaButton, uploadKittenImage } from '../utils' | ||||
| import { | ||||
|   composeInput, getNthDeleteMediaButton, getNthMedia, mediaButton, | ||||
|   uploadKittenImage | ||||
| } from '../utils' | ||||
| import { foobarRole } from '../roles' | ||||
| 
 | ||||
| fixture`013-compose-media.js` | ||||
|  |  | |||
|  | @ -48,4 +48,4 @@ test('can use emoji dialog within compose dialog', async t => { | |||
|     .expect(showMoreButton.innerText).contains('Show 1 more') | ||||
|     .click(showMoreButton) | ||||
|   await t.expect(getNthStatus(0).find('img[alt=":blobpats:"]').exists).ok({timeout: 20000}) | ||||
| }) | ||||
| }) | ||||
|  |  | |||
							
								
								
									
										73
									
								
								tests/spec/109-compose-media.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								tests/spec/109-compose-media.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,73 @@ | |||
| import { | ||||
|   composeButton, getNthDeleteMediaButton, getNthMedia, getNthMediaAltInput, getNthStatusAndImage, getUrl, | ||||
|   homeNavButton, | ||||
|   mediaButton, notificationsNavButton, | ||||
|   uploadKittenImage | ||||
| } from '../utils' | ||||
| import { foobarRole } from '../roles' | ||||
| 
 | ||||
| fixture`109-compose-media.js` | ||||
|   .page`http://localhost:4002` | ||||
| 
 | ||||
| async function uploadTwoKittens (t) { | ||||
|   await (uploadKittenImage(1)()) | ||||
|   await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg') | ||||
|   await (uploadKittenImage(2)()) | ||||
|   await t.expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg') | ||||
|     .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg') | ||||
| } | ||||
| 
 | ||||
| test('uploads alts for media', async t => { | ||||
|   await t.useRole(foobarRole) | ||||
|     .expect(mediaButton.hasAttribute('disabled')).notOk() | ||||
|   await uploadTwoKittens(t) | ||||
|   await t.typeText(getNthMediaAltInput(2), 'kitten 2') | ||||
|     .typeText(getNthMediaAltInput(1), 'kitten 1') | ||||
|     .click(composeButton) | ||||
|     .expect(getNthStatusAndImage(0, 0).getAttribute('alt')).eql('kitten 1') | ||||
|     .expect(getNthStatusAndImage(0, 1).getAttribute('alt')).eql('kitten 2') | ||||
| }) | ||||
| 
 | ||||
| test('uploads alts when deleting and re-uploading media', async t => { | ||||
|   await t.useRole(foobarRole) | ||||
|     .expect(mediaButton.hasAttribute('disabled')).notOk() | ||||
|   await (uploadKittenImage(1)()) | ||||
|   await t.typeText(getNthMediaAltInput(1), 'this will be deleted') | ||||
|     .click(getNthDeleteMediaButton(1)) | ||||
|     .expect(getNthMedia(1).exists).notOk() | ||||
|   await (uploadKittenImage(2)()) | ||||
|   await t.expect(getNthMediaAltInput(1).value).eql('') | ||||
|     .expect(getNthMedia(1).getAttribute('alt')).eql('kitten2.jpg') | ||||
|     .typeText(getNthMediaAltInput(1), 'this will not be deleted') | ||||
|     .click(composeButton) | ||||
|     .expect(getNthStatusAndImage(0, 0).getAttribute('alt')).eql('this will not be deleted') | ||||
| }) | ||||
| 
 | ||||
| test('uploads alts mixed with no-alts', async t => { | ||||
|   await t.useRole(foobarRole) | ||||
|     .expect(mediaButton.hasAttribute('disabled')).notOk() | ||||
|   await uploadTwoKittens(t) | ||||
|   await t.typeText(getNthMediaAltInput(2), 'kitten numero dos') | ||||
|     .click(composeButton) | ||||
|     .expect(getNthStatusAndImage(0, 0).getAttribute('alt')).eql('') | ||||
|     .expect(getNthStatusAndImage(0, 1).getAttribute('alt')).eql('kitten numero dos') | ||||
| }) | ||||
| 
 | ||||
| test('saves alts to local storage', async t => { | ||||
|   await t.useRole(foobarRole) | ||||
|     .expect(mediaButton.hasAttribute('disabled')).notOk() | ||||
|   await uploadTwoKittens(t) | ||||
|   await t.typeText(getNthMediaAltInput(1), 'kitten numero uno') | ||||
|     .typeText(getNthMediaAltInput(2), 'kitten numero dos') | ||||
|     .click(notificationsNavButton) | ||||
|     .expect(getUrl()).contains('/notifications') | ||||
|     .click(homeNavButton) | ||||
|     .expect(getUrl()).eql('http://localhost:4002/') | ||||
|     .expect(getNthMedia(1).getAttribute('alt')).eql('kitten1.jpg') | ||||
|     .expect(getNthMedia(2).getAttribute('alt')).eql('kitten2.jpg') | ||||
|     .expect(getNthMediaAltInput(1).value).eql('kitten numero uno') | ||||
|     .expect(getNthMediaAltInput(2).value).eql('kitten numero dos') | ||||
|     .click(composeButton) | ||||
|     .expect(getNthStatusAndImage(0, 0).getAttribute('alt')).eql('kitten numero uno') | ||||
|     .expect(getNthStatusAndImage(0, 1).getAttribute('alt')).eql('kitten numero dos') | ||||
| }) | ||||
|  | @ -96,6 +96,10 @@ export const uploadKittenImage = i => (exec(() => { | |||
|   } | ||||
| })) | ||||
| 
 | ||||
| export function getNthMediaAltInput (n) { | ||||
|   return $(`.compose-box .compose-media:nth-child(${n}) .compose-media-alt input`) | ||||
| } | ||||
| 
 | ||||
| export function getNthComposeReplyInput (n) { | ||||
|   return getNthStatus(n).find('.compose-box-input') | ||||
| } | ||||
|  | @ -128,6 +132,10 @@ export function getNthStatus (n) { | |||
|   return $(`div[aria-hidden="false"] > article[aria-posinset="${n}"]`) | ||||
| } | ||||
| 
 | ||||
| export function getNthStatusAndImage (nStatus, nImage) { | ||||
|   return getNthStatus(nStatus).find(`.status-media .show-image-button:nth-child(${nImage + 1}) img`) | ||||
| } | ||||
| 
 | ||||
| export function getLastVisibleStatus () { | ||||
|   return $(`div[aria-hidden="false"] > article[aria-posinset]`).nth(-1) | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue