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 />
{{#if contentWarningShown}}
<div class="compose-content-warning-wrapper"
@ -13,10 +13,12 @@
<ComposeMedia :realm :media />
</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()" />
</div>
<div class="compose-box-border-bottom {{hideAndFadeIn}}"></div>
{{#if !hideBottomBorder}}
<div class="compose-box-border-bottom {{hideAndFadeIn}}"></div>
{{/if}}
<style>
.compose-box {
border-radius: 4px;
@ -35,7 +37,7 @@
max-width: calc(100vw - 40px);
}
.compose-box.dialog-size {
.compose-box.slim-size {
width: 540px;
max-width: calc(100vw - 60px);
}
@ -54,11 +56,14 @@
width: 100%;
display: flex;
justify-content: flex-end;
pointer-events: none;
}
.compose-box-button-wrapper.compose-button-sticky {
position: -webkit-sticky;
position: sticky;
top: 10px;
z-index: 1000;
pointer-events: none;
}
.compose-content-warning-wrapper {
@ -71,7 +76,7 @@
max-width: calc(100vw - 20px);
width: 580px;
}
.compose-box.dialog-size {
.compose-box.slim-size {
width: 560px;
max-width: calc(100vw - 40px);
}
@ -115,10 +120,6 @@
return
}
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})
},
ondestroy() {
@ -138,11 +139,11 @@
},
store: () => store,
computed: {
className: (overLimit, realm) => {
computedClassName: (overLimit, realm, size) => {
return classname(
'compose-box',
overLimit && 'over-char-limit',
realm === 'dialog' && 'dialog-size'
size === 'slim' && 'slim-size'
)
},
hideAndFadeIn: (hidden) => {

View file

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

View file

@ -35,7 +35,11 @@
<StatusDetails :originalStatus :originalStatusId />
{{/if}}
<StatusToolbar :originalStatus :originalStatusId :originalAccountId
:isStatusInOwnThread :uuid :visibility />
:isStatusInOwnThread :uuid :visibility :replyShown
on:recalculateHeight />
{{#if replyShown}}
<StatusComposeBox :originalStatusId :uuid on:recalculateHeight />
{{/if}}
</article>
<style>
@ -51,9 +55,10 @@
"sidebar spoiler-btn spoiler-btn spoiler-btn"
"sidebar content content content"
"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-rows: repeat(7, max-content);
grid-template-rows: repeat(8, max-content);
}
.status-article.status-in-timeline {
@ -74,9 +79,10 @@
"content content"
"media media"
"details details"
"toolbar toolbar";
"toolbar toolbar"
"compose compose";
grid-template-columns: min-content 1fr;
grid-template-rows: repeat(6, max-content);
grid-template-rows: repeat(7, max-content);
}
@media (max-width: 767px) {
@ -100,11 +106,14 @@
import StatusMediaAttachments from './StatusMediaAttachments.html'
import StatusContent from './StatusContent.html'
import StatusSpoiler from './StatusSpoiler.html'
import StatusComposeBox from './StatusComposeBox.html'
import { store } from '../../_store/store'
import { goto } from 'sapper/runtime.js'
import { registerClickDelegate, unregisterClickDelegate } from '../../_utils/delegate'
import { classname } from '../../_utils/classname'
const INPUT_TAGS = new Set(['a', 'button', 'input', 'textarea'])
export default {
oncreate() {
let delegateKey = this.get('delegateKey')
@ -129,7 +138,8 @@
StatusToolbar,
StatusMediaAttachments,
StatusContent,
StatusSpoiler
StatusSpoiler,
StatusComposeBox
},
store: () => store,
methods: {
@ -138,12 +148,9 @@
let { localName, parentElement } = e.target
if ((type === 'click' || (type === 'keydown' && keyCode === 13)) &&
localName !== 'a' &&
localName !== 'button' &&
parentElement.localName !== 'a' &&
parentElement.localName !== 'button' &&
parentElement.parentElement.localName !== 'a' &&
parentElement.parentElement.localName !== 'button') {
!INPUT_TAGS.has(localName) &&
!INPUT_TAGS.has(parentElement.localName) &&
!INPUT_TAGS.has(parentElement.parentElement.localName)) {
e.preventDefault()
e.stopPropagation()
goto(`/statuses/${this.get('originalStatusId')}`)
@ -174,6 +181,7 @@
notification.type !== 'mention' && notification.status.id === originalStatusId
},
spoilerShown: ($spoilersShown, uuid) => !!$spoilersShown[uuid],
replyShown: ($repliesShown, uuid) => !!$repliesShown[uuid],
ariaLabel: (originalAccount, originalStatus, visibility) => {
return (visibility === 'direct' ? 'Direct message' : 'Status') +
` 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' : ''}}">
<IconButton
label="Reply"
label="{{replyLabel}}"
pressable="true"
pressed="{{replyShown}}"
href="#fa-reply"
disabled="{{disableReply}}"
delegateKey="{{replyKey}}"
focusKey="{{replyKey}}"
/>
@ -45,17 +46,16 @@
import { registerClickDelegate, unregisterClickDelegate } from '../../_utils/delegate'
import { setFavorited } from '../../_actions/favorite'
import { setReblogged } from '../../_actions/reblog'
import { goto } from 'sapper/runtime.js'
import { importDialogs } from '../../_utils/asyncModules'
import { updateProfileAndRelationship } from '../../_actions/accounts'
import { FAVORITE_ANIMATION, REBLOG_ANIMATION } from '../../_static/animations'
export default {
oncreate() {
registerClickDelegate(this.get('favoriteKey'), () => this.onFavoriteClick())
registerClickDelegate(this.get('reblogKey'), () => this.onReblogClick())
registerClickDelegate(this.get('replyKey'), () => this.onReplyClick())
registerClickDelegate(this.get('optionsKey'), () => this.onOptionsClick())
registerClickDelegate(this.get('favoriteKey'), (e) => this.onFavoriteClick(e))
registerClickDelegate(this.get('reblogKey'), (e) => this.onReblogClick(e))
registerClickDelegate(this.get('replyKey'), (e) => this.onReplyClick(e))
registerClickDelegate(this.get('optionsKey'), (e) => this.onOptionsClick(e))
},
ondestroy() {
unregisterClickDelegate(this.get('favoriteKey'))
@ -68,23 +68,36 @@
},
store: () => store,
methods: {
onFavoriteClick() {
onFavoriteClick(e) {
e.preventDefault()
e.stopPropagation()
let originalStatusId = this.get('originalStatusId')
let favorited = this.get('favorited')
/* no await */ setFavorited(originalStatusId, !favorited)
this.set({animateFavorite: !favorited})
},
onReblogClick() {
onReblogClick(e) {
e.preventDefault()
e.stopPropagation()
let originalStatusId = this.get('originalStatusId')
let reblogged = this.get('reblogged')
/* no await */ setReblogged(originalStatusId, !reblogged)
this.set({animateReblog: !reblogged})
},
onReplyClick() {
let originalStatusId = this.get('originalStatusId')
goto(`/statuses/${originalStatusId}/reply`)
onReplyClick(e) {
e.preventDefault()
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 originalAccountId = this.get('originalAccountId')
let updateRelationshipPromise = updateProfileAndRelationship(originalAccountId)
@ -98,6 +111,7 @@
reblogAnimation: REBLOG_ANIMATION
}),
computed: {
replyLabel: (replyShown) => replyShown ? 'Close reply' : 'Reply',
reblogLabel: (visibility) => {
switch (visibility) {
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: {},
spoilersShown: {},
sensitivesShown: {},
repliesShown: {},
autoplayGifs: false,
markMediaAsSensitive: 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 {
getNthStatus, scrollToStatus, closeDialogButton, modalDialogContents, getActiveElementClass, goBack, getUrl,
goBackButton, getActiveElementInnerText, getNthReplyButton, getActiveElementAriaLabel, getActiveElementInsideNthStatus
goBackButton, getActiveElementInnerText, getNthReplyButton, getActiveElementInsideNthStatus
} from '../utils'
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 => {
await t.useRole(foobarRole)
.click(getNthReplyButton(1))
.expect(getUrl()).contains('/reply')
.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 {
composeInput, getNthReplyButton,
getNthStatus, getUrl, goBack
composeInput,
getNthComposeReplyInput, getNthReplyButton,
getNthStatus, getUrl, goBack, homeNavButton, notificationsNavButton
} from '../utils'
import { foobarRole } from '../roles'
@ -10,34 +11,26 @@ fixture`017-compose-reply.js`
test('account handle populated correctly for replies', async t => {
await t.useRole(foobarRole)
.click(getNthReplyButton(0))
.expect(getUrl()).contains('/statuses')
.expect(composeInput.value).eql('@quux ')
.typeText(composeInput, 'hello quux', {paste: true})
.expect(composeInput.value).eql('@quux hello quux')
await goBack()
await t.click(getNthReplyButton(0))
.expect(getUrl()).contains('/statuses')
.expect(composeInput.value).eql('@quux hello quux')
await goBack()
await t.expect(getUrl()).eql('http://localhost:4002/')
.expect(getNthComposeReplyInput(0).value).eql('@quux ')
.typeText(getNthComposeReplyInput(0), 'hello quux', {paste: true})
.expect(getNthComposeReplyInput(0).value).eql('@quux hello quux')
.click(notificationsNavButton)
.expect(getUrl()).contains('/notifications')
.click(homeNavButton)
.expect(getUrl()).notContains('/notifications')
.expect(getNthComposeReplyInput(0).value).eql('@quux hello quux')
.expect(composeInput.value).eql('')
await t.hover(getNthStatus(2))
.hover(getNthStatus(2))
.hover(getNthStatus(4))
.click(getNthReplyButton(4))
.expect(getUrl()).contains('/statuses')
.expect(composeInput.value).eql('')
await goBack()
await t.expect(getUrl()).eql('http://localhost:4002/')
.expect(composeInput.value).eql('')
.expect(getNthComposeReplyInput(4).value).eql('')
})
test('replying to posts with mentions', async t => {
await t.useRole(foobarRole)
.click(getNthReplyButton(1))
.expect(getUrl()).contains('/statuses')
.expect(composeInput.value).eql('@admin ')
.expect(getNthComposeReplyInput(1).value).eql('@admin ')
.navigateTo('/accounts/4')
.click(getNthReplyButton(0))
.expect(getUrl()).contains('/statuses')
.expect(composeInput.value).eql('@ExternalLinks @admin @quux ')
.expect(getNthComposeReplyInput(0).value).eql('@ExternalLinks @admin @quux ')
})

View file

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