make replies inline

This commit is contained in:
Nolan Lawson 2018-03-30 01:06:17 -07:00
parent 3b8f551477
commit 5a0c4897b0
12 changed files with 148 additions and 138 deletions

View file

@ -1,4 +1,4 @@
<div class="{{className}} {{hideAndFadeIn}}"> <div class="{{computedClassName}} {{hideAndFadeIn}}">
<ComposeAuthor /> <ComposeAuthor />
{{#if contentWarningShown}} {{#if contentWarningShown}}
<div class="compose-content-warning-wrapper" <div class="compose-content-warning-wrapper"
@ -13,10 +13,12 @@
<ComposeMedia :realm :media /> <ComposeMedia :realm :media />
</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 {{hideAndFadeIn}}" > <div class="compose-box-button-wrapper {{realm === 'home' ? 'compose-button-sticky' : ''}} {{hideAndFadeIn}}" >
<ComposeButton :length :overLimit :sticky on:click="onClickPostButton()" /> <ComposeButton :length :overLimit :sticky on:click="onClickPostButton()" />
</div> </div>
{{#if !hideBottomBorder}}
<div class="compose-box-border-bottom {{hideAndFadeIn}}"></div> <div class="compose-box-border-bottom {{hideAndFadeIn}}"></div>
{{/if}}
<style> <style>
.compose-box { .compose-box {
border-radius: 4px; border-radius: 4px;
@ -35,7 +37,7 @@
max-width: calc(100vw - 40px); max-width: calc(100vw - 40px);
} }
.compose-box.dialog-size { .compose-box.slim-size {
width: 540px; width: 540px;
max-width: calc(100vw - 60px); max-width: calc(100vw - 60px);
} }
@ -54,11 +56,14 @@
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
pointer-events: none;
}
.compose-box-button-wrapper.compose-button-sticky {
position: -webkit-sticky; position: -webkit-sticky;
position: sticky; position: sticky;
top: 10px; top: 10px;
z-index: 1000; z-index: 1000;
pointer-events: none;
} }
.compose-content-warning-wrapper { .compose-content-warning-wrapper {
@ -71,7 +76,7 @@
max-width: calc(100vw - 20px); max-width: calc(100vw - 20px);
width: 580px; width: 580px;
} }
.compose-box.dialog-size { .compose-box.slim-size {
width: 560px; width: 560px;
max-width: calc(100vw - 40px); max-width: calc(100vw - 40px);
} }
@ -115,10 +120,6 @@
return return
} }
this.fire('postedStatus') this.fire('postedStatus')
// if this is a reply, go back immediately after it's posted
if (realm !== 'home' && realm !== 'dialog') {
window.history.back()
}
}, {init: false}) }, {init: false})
}, },
ondestroy() { ondestroy() {
@ -138,11 +139,11 @@
}, },
store: () => store, store: () => store,
computed: { computed: {
className: (overLimit, realm) => { computedClassName: (overLimit, realm, size) => {
return classname( return classname(
'compose-box', 'compose-box',
overLimit && 'over-char-limit', overLimit && 'over-char-limit',
realm === 'dialog' && 'dialog-size' size === 'slim' && 'slim-size'
) )
}, },
hideAndFadeIn: (hidden) => { hideAndFadeIn: (hidden) => {

View file

@ -1,5 +1,5 @@
<ModalDialog :label :shown :closed :title background="var(--main-bg)"> <ModalDialog :label :shown :closed :title background="var(--main-bg)">
<ComposeBox realm="dialog" on:postedStatus="onPostedStatus()" /> <ComposeBox realm="dialog" size="slim" on:postedStatus="onPostedStatus()" />
</ModalDialog> </ModalDialog>
<script> <script>
import ModalDialog from './ModalDialog.html' import ModalDialog from './ModalDialog.html'

View file

@ -35,7 +35,11 @@
<StatusDetails :originalStatus :originalStatusId /> <StatusDetails :originalStatus :originalStatusId />
{{/if}} {{/if}}
<StatusToolbar :originalStatus :originalStatusId :originalAccountId <StatusToolbar :originalStatus :originalStatusId :originalAccountId
:isStatusInOwnThread :uuid :visibility /> :isStatusInOwnThread :uuid :visibility :replyShown
on:recalculateHeight />
{{#if replyShown}}
<StatusComposeBox :originalStatusId :uuid on:recalculateHeight />
{{/if}}
</article> </article>
<style> <style>
@ -51,9 +55,10 @@
"sidebar spoiler-btn spoiler-btn spoiler-btn" "sidebar spoiler-btn spoiler-btn spoiler-btn"
"sidebar content content content" "sidebar content content content"
"media media media media" "media media media media"
"....... toolbar toolbar toolbar"; "....... toolbar toolbar toolbar"
"compose compose compose compose";
grid-template-columns: min-content minmax(0, max-content) 1fr min-content; grid-template-columns: min-content minmax(0, max-content) 1fr min-content;
grid-template-rows: repeat(7, max-content); grid-template-rows: repeat(8, max-content);
} }
.status-article.status-in-timeline { .status-article.status-in-timeline {
@ -74,9 +79,10 @@
"content content" "content content"
"media media" "media media"
"details details" "details details"
"toolbar toolbar"; "toolbar toolbar"
"compose compose";
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
grid-template-rows: repeat(6, max-content); grid-template-rows: repeat(7, max-content);
} }
@media (max-width: 767px) { @media (max-width: 767px) {
@ -100,11 +106,14 @@
import StatusMediaAttachments from './StatusMediaAttachments.html' import StatusMediaAttachments from './StatusMediaAttachments.html'
import StatusContent from './StatusContent.html' import StatusContent from './StatusContent.html'
import StatusSpoiler from './StatusSpoiler.html' import StatusSpoiler from './StatusSpoiler.html'
import StatusComposeBox from './StatusComposeBox.html'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import { goto } from 'sapper/runtime.js' import { goto } from 'sapper/runtime.js'
import { registerClickDelegate, unregisterClickDelegate } from '../../_utils/delegate' import { registerClickDelegate, unregisterClickDelegate } from '../../_utils/delegate'
import { classname } from '../../_utils/classname' import { classname } from '../../_utils/classname'
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea'])
export default { export default {
oncreate() { oncreate() {
let delegateKey = this.get('delegateKey') let delegateKey = this.get('delegateKey')
@ -129,7 +138,8 @@
StatusToolbar, StatusToolbar,
StatusMediaAttachments, StatusMediaAttachments,
StatusContent, StatusContent,
StatusSpoiler StatusSpoiler,
StatusComposeBox
}, },
store: () => store, store: () => store,
methods: { methods: {
@ -138,12 +148,9 @@
let { localName, parentElement } = e.target let { localName, parentElement } = e.target
if ((type === 'click' || (type === 'keydown' && keyCode === 13)) && if ((type === 'click' || (type === 'keydown' && keyCode === 13)) &&
localName !== 'a' && !INPUT_TAGS.has(localName) &&
localName !== 'button' && !INPUT_TAGS.has(parentElement.localName) &&
parentElement.localName !== 'a' && !INPUT_TAGS.has(parentElement.parentElement.localName)) {
parentElement.localName !== 'button' &&
parentElement.parentElement.localName !== 'a' &&
parentElement.parentElement.localName !== 'button') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
goto(`/statuses/${this.get('originalStatusId')}`) goto(`/statuses/${this.get('originalStatusId')}`)
@ -174,6 +181,7 @@
notification.type !== 'mention' && notification.status.id === originalStatusId notification.type !== 'mention' && notification.status.id === originalStatusId
}, },
spoilerShown: ($spoilersShown, uuid) => !!$spoilersShown[uuid], spoilerShown: ($spoilersShown, uuid) => !!$spoilersShown[uuid],
replyShown: ($repliesShown, uuid) => !!$repliesShown[uuid],
ariaLabel: (originalAccount, originalStatus, visibility) => { ariaLabel: (originalAccount, originalStatus, visibility) => {
return (visibility === 'direct' ? 'Direct message' : 'Status') + return (visibility === 'direct' ? 'Direct message' : 'Status') +
` by ${originalAccount.display_name || originalAccount.username}` ` by ${originalAccount.display_name || originalAccount.username}`

View file

@ -0,0 +1,57 @@
<div class="status-article-compose-box">
<ComposeBox realm="{{originalStatusId}}"
size="slim"
autoFocus="true"
hideBottomBorder="true"
on:postedStatus="onPostedStatus()"
/>
</div>
<style>
.status-article-compose-box {
grid-area: compose;
}
</style>
<script>
import ComposeBox from '../../_components/compose/ComposeBox.html'
import { store } from '../../_store/store'
import { doubleRAF } from '../../_utils/doubleRAF'
export default {
oncreate() {
let lastContentWarningShown = false
this.observe('composeData', composeData => {
doubleRAF(() => {
this.fire('recalculateHeight')
let contentWarningShown = !!composeData.contentWarningShown
if (contentWarningShown !== lastContentWarningShown) {
// TODO: this animation lasts 333ms, hence need to recalculate again
setTimeout(() => {
requestAnimationFrame(() => {
this.fire('recalculateHeight')
})
}, 350)
}
lastContentWarningShown = contentWarningShown
})
}, {init: false})
},
components: {
ComposeBox
},
store: () => store,
computed: {
composeData: ($currentComposeData, originalStatusId) => $currentComposeData[originalStatusId] || {},
},
methods: {
onPostedStatus() {
requestAnimationFrame(() => {
let uuid = this.get('uuid')
let $repliesShown = this.store.get('repliesShown')
$repliesShown[uuid] = false
this.store.set({'repliesShown': $repliesShown})
this.fire('recalculateHeight')
})
}
}
}
</script>

View file

@ -1,8 +1,9 @@
<div class="status-toolbar {{isStatusInOwnThread ? 'status-in-own-thread' : ''}}"> <div class="status-toolbar {{isStatusInOwnThread ? 'status-in-own-thread' : ''}}">
<IconButton <IconButton
label="Reply" label="{{replyLabel}}"
pressable="true"
pressed="{{replyShown}}"
href="#fa-reply" href="#fa-reply"
disabled="{{disableReply}}"
delegateKey="{{replyKey}}" delegateKey="{{replyKey}}"
focusKey="{{replyKey}}" focusKey="{{replyKey}}"
/> />
@ -45,17 +46,16 @@
import { registerClickDelegate, unregisterClickDelegate } from '../../_utils/delegate' import { registerClickDelegate, unregisterClickDelegate } from '../../_utils/delegate'
import { setFavorited } from '../../_actions/favorite' import { setFavorited } from '../../_actions/favorite'
import { setReblogged } from '../../_actions/reblog' import { setReblogged } from '../../_actions/reblog'
import { goto } from 'sapper/runtime.js'
import { importDialogs } from '../../_utils/asyncModules' import { importDialogs } from '../../_utils/asyncModules'
import { updateProfileAndRelationship } from '../../_actions/accounts' import { updateProfileAndRelationship } from '../../_actions/accounts'
import { FAVORITE_ANIMATION, REBLOG_ANIMATION } from '../../_static/animations' import { FAVORITE_ANIMATION, REBLOG_ANIMATION } from '../../_static/animations'
export default { export default {
oncreate() { oncreate() {
registerClickDelegate(this.get('favoriteKey'), () => this.onFavoriteClick()) registerClickDelegate(this.get('favoriteKey'), (e) => this.onFavoriteClick(e))
registerClickDelegate(this.get('reblogKey'), () => this.onReblogClick()) registerClickDelegate(this.get('reblogKey'), (e) => this.onReblogClick(e))
registerClickDelegate(this.get('replyKey'), () => this.onReplyClick()) registerClickDelegate(this.get('replyKey'), (e) => this.onReplyClick(e))
registerClickDelegate(this.get('optionsKey'), () => this.onOptionsClick()) registerClickDelegate(this.get('optionsKey'), (e) => this.onOptionsClick(e))
}, },
ondestroy() { ondestroy() {
unregisterClickDelegate(this.get('favoriteKey')) unregisterClickDelegate(this.get('favoriteKey'))
@ -68,23 +68,36 @@
}, },
store: () => store, store: () => store,
methods: { methods: {
onFavoriteClick() { onFavoriteClick(e) {
e.preventDefault()
e.stopPropagation()
let originalStatusId = this.get('originalStatusId') let originalStatusId = this.get('originalStatusId')
let favorited = this.get('favorited') let favorited = this.get('favorited')
/* no await */ setFavorited(originalStatusId, !favorited) /* no await */ setFavorited(originalStatusId, !favorited)
this.set({animateFavorite: !favorited}) this.set({animateFavorite: !favorited})
}, },
onReblogClick() { onReblogClick(e) {
e.preventDefault()
e.stopPropagation()
let originalStatusId = this.get('originalStatusId') let originalStatusId = this.get('originalStatusId')
let reblogged = this.get('reblogged') let reblogged = this.get('reblogged')
/* no await */ setReblogged(originalStatusId, !reblogged) /* no await */ setReblogged(originalStatusId, !reblogged)
this.set({animateReblog: !reblogged}) this.set({animateReblog: !reblogged})
}, },
onReplyClick() { onReplyClick(e) {
let originalStatusId = this.get('originalStatusId') e.preventDefault()
goto(`/statuses/${originalStatusId}/reply`) e.stopPropagation()
requestAnimationFrame(() => {
let uuid = this.get('uuid')
let $repliesShown = this.store.get('repliesShown')
$repliesShown[uuid] = !$repliesShown[uuid]
this.store.set({'repliesShown': $repliesShown})
this.fire('recalculateHeight')
})
}, },
async onOptionsClick() { async onOptionsClick(e) {
e.preventDefault()
e.stopPropagation()
let originalStatusId = this.get('originalStatusId') let originalStatusId = this.get('originalStatusId')
let originalAccountId = this.get('originalAccountId') let originalAccountId = this.get('originalAccountId')
let updateRelationshipPromise = updateProfileAndRelationship(originalAccountId) let updateRelationshipPromise = updateProfileAndRelationship(originalAccountId)
@ -98,6 +111,7 @@
reblogAnimation: REBLOG_ANIMATION reblogAnimation: REBLOG_ANIMATION
}), }),
computed: { computed: {
replyLabel: (replyShown) => replyShown ? 'Close reply' : 'Reply',
reblogLabel: (visibility) => { reblogLabel: (visibility) => {
switch (visibility) { switch (visibility) {
case 'private': case 'private':

View file

@ -1,44 +0,0 @@
<DynamicPageBanner title=""/>
<div class="reply-container">
{{#if status}}
<Status index="0"
length="1"
timelineType="reply"
timelineValue="{{params.statusId}}"
:status
/>
<ComposeBox realm="{{params.statusId}}" autoFocus="true" />
{{else}}
<LoadingPage />
{{/if}}
</div>
<style>
.reply-container {
position: relative;
margin-top: 20px;
}
</style>
<script>
import { store } from '../../../_store/store.js'
import DynamicPageBanner from '../../../_components/DynamicPageBanner.html'
import LoadingPage from '../../../_components/LoadingPage.html'
import ComposeBox from '../../../_components/compose/ComposeBox.html'
import Status from '../../../_components/status/Status.html'
import { database } from '../../../_database/database'
export default {
async oncreate() {
let statusId = this.get('params').statusId
let instanceName = this.store.get('currentInstance')
let status = await database.getStatus(instanceName, statusId)
this.set({status})
},
store: () => store,
components: {
DynamicPageBanner,
LoadingPage,
ComposeBox,
Status
}
}
</script>

View file

@ -33,6 +33,7 @@ export const store = new PinaforeStore({
instanceThemes: {}, instanceThemes: {},
spoilersShown: {}, spoilersShown: {},
sensitivesShown: {}, sensitivesShown: {},
repliesShown: {},
autoplayGifs: false, autoplayGifs: false,
markMediaAsSensitive: false, markMediaAsSensitive: false,
reduceMotion: false, reduceMotion: false,

View file

@ -1,21 +0,0 @@
<:Head>
<title>Pinafore Reply</title>
</:Head>
<Layout page='reply' >
<LazyPage :pageComponent :params />
</Layout>
<script>
import Layout from '../../_components/Layout.html'
import LazyPage from '../../_components/LazyPage.html'
import pageComponent from '../../_pages/statuses/[statusId]/reply.html'
export default {
components: {
Layout,
LazyPage
},
data: () => ({
pageComponent
})
}
</script>

View file

@ -1,6 +1,6 @@
import { import {
getNthStatus, scrollToStatus, closeDialogButton, modalDialogContents, getActiveElementClass, goBack, getUrl, getNthStatus, scrollToStatus, closeDialogButton, modalDialogContents, getActiveElementClass, goBack, getUrl,
goBackButton, getActiveElementInnerText, getNthReplyButton, getActiveElementAriaLabel, getActiveElementInsideNthStatus goBackButton, getActiveElementInnerText, getNthReplyButton, getActiveElementInsideNthStatus
} from '../utils' } from '../utils'
import { foobarRole } from '../roles' import { foobarRole } from '../roles'
@ -81,12 +81,5 @@ test('thread preserves focus', async t => {
test('reply preserves focus and moves focus to the text input', async t => { test('reply preserves focus and moves focus to the text input', async t => {
await t.useRole(foobarRole) await t.useRole(foobarRole)
.click(getNthReplyButton(1)) .click(getNthReplyButton(1))
.expect(getUrl()).contains('/reply')
.expect(getActiveElementClass()).contains('compose-box-input') .expect(getActiveElementClass()).contains('compose-box-input')
.click(goBackButton)
.expect(getUrl()).eql('http://localhost:4002/')
.expect(getNthStatus(0).exists).ok()
.expect(getActiveElementClass()).contains('icon-button')
.expect(getActiveElementAriaLabel()).eql('Reply')
.expect(getActiveElementInsideNthStatus()).eql('1')
}) })

View file

@ -1,6 +1,7 @@
import { import {
composeInput, getNthReplyButton, composeInput,
getNthStatus, getUrl, goBack getNthComposeReplyInput, getNthReplyButton,
getNthStatus, getUrl, goBack, homeNavButton, notificationsNavButton
} from '../utils' } from '../utils'
import { foobarRole } from '../roles' import { foobarRole } from '../roles'
@ -10,34 +11,26 @@ fixture`017-compose-reply.js`
test('account handle populated correctly for replies', async t => { test('account handle populated correctly for replies', async t => {
await t.useRole(foobarRole) await t.useRole(foobarRole)
.click(getNthReplyButton(0)) .click(getNthReplyButton(0))
.expect(getUrl()).contains('/statuses') .expect(getNthComposeReplyInput(0).value).eql('@quux ')
.expect(composeInput.value).eql('@quux ') .typeText(getNthComposeReplyInput(0), 'hello quux', {paste: true})
.typeText(composeInput, 'hello quux', {paste: true}) .expect(getNthComposeReplyInput(0).value).eql('@quux hello quux')
.expect(composeInput.value).eql('@quux hello quux') .click(notificationsNavButton)
await goBack() .expect(getUrl()).contains('/notifications')
await t.click(getNthReplyButton(0)) .click(homeNavButton)
.expect(getUrl()).contains('/statuses') .expect(getUrl()).notContains('/notifications')
.expect(composeInput.value).eql('@quux hello quux') .expect(getNthComposeReplyInput(0).value).eql('@quux hello quux')
await goBack()
await t.expect(getUrl()).eql('http://localhost:4002/')
.expect(composeInput.value).eql('') .expect(composeInput.value).eql('')
await t.hover(getNthStatus(2)) .hover(getNthStatus(2))
.hover(getNthStatus(4)) .hover(getNthStatus(4))
.click(getNthReplyButton(4)) .click(getNthReplyButton(4))
.expect(getUrl()).contains('/statuses') .expect(getNthComposeReplyInput(4).value).eql('')
.expect(composeInput.value).eql('')
await goBack()
await t.expect(getUrl()).eql('http://localhost:4002/')
.expect(composeInput.value).eql('')
}) })
test('replying to posts with mentions', async t => { test('replying to posts with mentions', async t => {
await t.useRole(foobarRole) await t.useRole(foobarRole)
.click(getNthReplyButton(1)) .click(getNthReplyButton(1))
.expect(getUrl()).contains('/statuses') .expect(getNthComposeReplyInput(1).value).eql('@admin ')
.expect(composeInput.value).eql('@admin ')
.navigateTo('/accounts/4') .navigateTo('/accounts/4')
.click(getNthReplyButton(0)) .click(getNthReplyButton(0))
.expect(getUrl()).contains('/statuses') .expect(getNthComposeReplyInput(0).value).eql('@ExternalLinks @admin @quux ')
.expect(composeInput.value).eql('@ExternalLinks @admin @quux ')
}) })

View file

@ -1,6 +1,7 @@
import { foobarRole } from '../roles' import { foobarRole } from '../roles'
import { import {
composeInput, getNthReplyButton, getNthStatus, getUrl, homeNavButton, notificationsNavButton, composeInput, getNthComposeReplyButton, getNthComposeReplyInput, getNthReplyButton, getNthStatus, getUrl,
homeNavButton, notificationsNavButton,
postStatusButton postStatusButton
} from '../utils' } from '../utils'
@ -27,10 +28,9 @@ test('statuses in threads show up in right order', async t => {
.click(getNthStatus(2)) .click(getNthStatus(2))
.expect(getUrl()).contains('/statuses') .expect(getUrl()).contains('/statuses')
.click(getNthReplyButton(3)) .click(getNthReplyButton(3))
.expect(getUrl()).contains('/reply') .typeText(getNthComposeReplyInput(3), 'my reply!', {paste: true})
.typeText(composeInput, 'my reply!', {paste: true}) .click(getNthComposeReplyButton(3))
.click(postStatusButton) .expect(getNthComposeReplyInput(3).exists).notOk()
.expect(getUrl()).match(/statuses\/[^/]+$/)
.expect(getNthStatus(5).innerText).contains('@baz my reply!') .expect(getNthStatus(5).innerText).contains('@baz my reply!')
.navigateTo('/accounts/5') .navigateTo('/accounts/5')
.click(getNthStatus(2)) .click(getNthStatus(2))

View file

@ -96,6 +96,14 @@ export const uploadKittenImage = i => (exec(() => {
} }
})) }))
export function getNthComposeReplyInput (n) {
return getNthStatus(n).find('.compose-box-input')
}
export function getNthComposeReplyButton (n) {
return getNthStatus(n).find('.compose-box-button')
}
export function getNthAutosuggestionResult (n) { export function getNthAutosuggestionResult (n) {
return $(`.compose-autosuggest-list-item:nth-child(${n}) button`) return $(`.compose-autosuggest-list-item:nth-child(${n}) button`)
} }