Fix alts for image uploads (#54)

* Fix alts for image uploads

Fixes #41

* fix alts mixed with no-alts
This commit is contained in:
Nolan Lawson 2018-04-09 18:30:15 -07:00 committed by GitHub
parent bca959d1a3
commit 7ae3212c55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 275 additions and 90 deletions

View file

@ -4,6 +4,7 @@ import { postStatus as postStatusToServer } from '../_api/statuses'
import { addStatusOrNotification } from './addStatusOrNotification' import { addStatusOrNotification } from './addStatusOrNotification'
import { database } from '../_database/database' import { database } from '../_database/database'
import { emit } from '../_utils/eventBus' import { emit } from '../_utils/eventBus'
import { putMediaDescription } from '../_api/media'
export async function insertHandleForReply (statusId) { export async function insertHandleForReply (statusId) {
let instanceName = store.get('currentInstance') let instanceName = store.get('currentInstance')
@ -20,7 +21,8 @@ export async function insertHandleForReply (statusId) {
} }
export async function postStatus (realm, text, inReplyToId, mediaIds, export async function postStatus (realm, text, inReplyToId, mediaIds,
sensitive, spoilerText, visibility) { sensitive, spoilerText, visibility,
mediaDescriptions = []) {
let instanceName = store.get('currentInstance') let instanceName = store.get('currentInstance')
let accessToken = store.get('accessToken') let accessToken = store.get('accessToken')
let online = store.get('online') let online = store.get('online')
@ -34,6 +36,9 @@ export async function postStatus (realm, text, inReplyToId, mediaIds,
postingStatus: true postingStatus: true
}) })
try { try {
await Promise.all(mediaDescriptions.map(async (description, i) => {
return description && putMediaDescription(instanceName, accessToken, mediaIds[i], description)
}))
let status = await postStatusToServer(instanceName, accessToken, text, let status = await postStatusToServer(instanceName, accessToken, text,
inReplyToId, mediaIds, sensitive, spoilerText, visibility) inReplyToId, mediaIds, sensitive, spoilerText, visibility)
addStatusOrNotification(instanceName, 'home', status) addStatusOrNotification(instanceName, 'home', status)

View file

@ -36,9 +36,15 @@ export function deleteMedia (realm, i) {
let composeText = store.getComposeData(realm, 'text') || '' let composeText = store.getComposeData(realm, 'text') || ''
composeText = composeText.replace(' ' + deletedMedia.data.text_url, '') composeText = composeText.replace(' ' + deletedMedia.data.text_url, '')
let mediaDescriptions = store.getComposeData(realm, 'mediaDescriptions') || []
if (mediaDescriptions[i]) {
mediaDescriptions[i] = null
}
store.setComposeData(realm, { store.setComposeData(realm, {
media: composeMedia, media: composeMedia,
text: composeText text: composeText,
mediaDescriptions: mediaDescriptions
}) })
scheduleIdleTask(() => store.save()) scheduleIdleTask(() => store.save())
} }

View file

@ -1,10 +1,17 @@
import { auth, basename } from './utils' import { auth, basename } from './utils'
import { postWithTimeout } from '../_utils/ajax' import { postWithTimeout, putWithTimeout } from '../_utils/ajax'
export async function uploadMedia (instanceName, accessToken, file, description) { export async function uploadMedia (instanceName, accessToken, file, description) {
let formData = new FormData() let formData = new FormData()
formData.append('file', file) formData.append('file', file)
if (description) {
formData.append('description', description) formData.append('description', description)
}
let url = `${basename(instanceName)}/api/v1/media` let url = `${basename(instanceName)}/api/v1/media`
return postWithTimeout(url, formData, auth(accessToken)) 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))
}

View file

@ -10,7 +10,7 @@
<ComposeLengthGauge :length :overLimit /> <ComposeLengthGauge :length :overLimit />
<ComposeToolbar :realm :postPrivacy :media :contentWarningShown :text /> <ComposeToolbar :realm :postPrivacy :media :contentWarningShown :text />
<ComposeLengthIndicator :length :overLimit /> <ComposeLengthIndicator :length :overLimit />
<ComposeMedia :realm :media /> <ComposeMedia :realm :media :mediaDescriptions />
</div> </div>
<div class="compose-box-button-sentinel {{hideAndFadeIn}}" ref:sentinel></div> <div class="compose-box-button-sentinel {{hideAndFadeIn}}" ref:sentinel></div>
<div class="compose-box-button-wrapper {{realm === 'home' ? 'compose-button-sticky' : ''}} {{hideAndFadeIn}}" > <div class="compose-box-button-wrapper {{realm === 'home' ? 'compose-button-sticky' : ''}} {{hideAndFadeIn}}" >
@ -170,7 +170,8 @@
overLimit: (length) => length > CHAR_LIMIT, overLimit: (length) => length > CHAR_LIMIT,
contentWarningShown: (composeData) => composeData.contentWarningShown, contentWarningShown: (composeData) => composeData.contentWarningShown,
contentWarning: (composeData) => composeData.contentWarning || '', contentWarning: (composeData) => composeData.contentWarning || '',
timelineInitialized: ($timelineInitialized) => $timelineInitialized timelineInitialized: ($timelineInitialized) => $timelineInitialized,
mediaDescriptions: (composeData) => composeData.mediaDescriptions || []
}, },
transitions: { transitions: {
slide slide
@ -193,6 +194,7 @@
let mediaIds = media.map(_ => _.data.id) let mediaIds = media.map(_ => _.data.id)
let inReplyTo = (realm === 'home' || realm === 'dialog') ? null : realm let inReplyTo = (realm === 'home' || realm === 'dialog') ? null : realm
let overLimit = this.get('overLimit') let overLimit = this.get('overLimit')
let mediaDescriptions = this.get('mediaDescriptions')
if (!text || overLimit) { if (!text || overLimit) {
return // do nothing if invalid return // do nothing if invalid
@ -200,7 +202,7 @@
/* no await */ /* no await */
postStatus(realm, text, inReplyTo, mediaIds, postStatus(realm, text, inReplyTo, mediaIds,
sensitive, contentWarning, postPrivacyKey) sensitive, contentWarning, postPrivacyKey, mediaDescriptions)
} }
}, },
setupStickyObserver() { setupStickyObserver() {

View file

@ -67,13 +67,13 @@
}) })
}, },
setupSyncToStore() { setupSyncToStore() {
const saveText = debounce(() => scheduleIdleTask(() => this.store.save()), 1000) const saveStore = debounce(() => scheduleIdleTask(() => this.store.save()), 1000)
this.observe('rawText', rawText => { this.observe('rawText', rawText => {
mark('observe rawText') mark('observe rawText')
let realm = this.get('realm') let realm = this.get('realm')
this.store.setComposeData(realm, {text: rawText}) this.store.setComposeData(realm, {text: rawText})
saveText() saveStore()
stop('observe rawText') stop('observe rawText')
}, {init: false}) }, {init: false})
}, },

View file

@ -1,23 +1,7 @@
{{#if media.length}} {{#if media.length}}
<div class="compose-media-container" style="grid-template-columns: repeat({{media.length}}, 1fr);"> <div class="compose-media-container" style="grid-template-columns: repeat({{media.length}}, 1fr);">
{{#each media as mediaItem, i}} {{#each media as mediaItem, index}}
<div class="compose-media"> <ComposeMediaItem :realm :mediaItem :index :mediaDescriptions />
<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}} {{/each}}
</div> </div>
{{/if}} {{/if}}
@ -33,72 +17,15 @@
padding: 5px; padding: 5px;
border-radius: 4px; 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> </style>
<script> <script>
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { deleteMedia } from '../../_actions/media' import ComposeMediaItem from './ComposeMediaItem.html'
export default { export default {
store: () => store, store: () => store,
methods: { components: {
onDeleteMedia(i) { ComposeMediaItem
deleteMedia(this.get('realm'), i)
}
} }
} }
</script> </script>

View 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>

View file

@ -41,6 +41,26 @@ async function _post (url, body, headers, timeout) {
return throwErrorIfInvalidResponse(response) 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) { async function _get (url, headers, timeout) {
let fetchFunc = timeout ? fetchWithTimeout : fetch let fetchFunc = timeout ? fetchWithTimeout : fetch
let response = await fetchFunc(url, { let response = await fetchFunc(url, {
@ -63,6 +83,14 @@ async function _delete (url, headers, timeout) {
return throwErrorIfInvalidResponse(response) 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 = {}) { export async function post (url, body, headers = {}) {
return _post(url, body, headers, false) return _post(url, body, headers, false)
} }

View file

@ -1,4 +1,7 @@
import { composeInput, getNthDeleteMediaButton, getNthMedia, mediaButton, uploadKittenImage } from '../utils' import {
composeInput, getNthDeleteMediaButton, getNthMedia, mediaButton,
uploadKittenImage
} from '../utils'
import { foobarRole } from '../roles' import { foobarRole } from '../roles'
fixture`013-compose-media.js` fixture`013-compose-media.js`

View 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')
})

View file

@ -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) { export function getNthComposeReplyInput (n) {
return getNthStatus(n).find('.compose-box-input') return getNthStatus(n).find('.compose-box-input')
} }
@ -128,6 +132,10 @@ export function getNthStatus (n) {
return $(`div[aria-hidden="false"] > article[aria-posinset="${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 () { export function getLastVisibleStatus () {
return $(`div[aria-hidden="false"] > article[aria-posinset]`).nth(-1) return $(`div[aria-hidden="false"] > article[aria-posinset]`).nth(-1)
} }