Merge branch 'chat-refactor' into shigusegubu-themes3

This commit is contained in:
Henry Jameson 2026-07-24 18:24:53 +03:00
commit 4a2c157daf
17 changed files with 389 additions and 302 deletions

View file

@ -65,6 +65,16 @@ export default (store) => {
component: ConversationPage, component: ConversationPage,
meta: { dontScroll: true }, meta: { dontScroll: true },
}, },
{
name: 'conversation2',
path: '/conversation/:statusId',
component: defineAsyncComponent(
() => import('src/components/chat_view/chat_view.vue'),
),
props: true,
meta: { dontScroll: true },
beforeEnter: validateAuthenticatedRoute,
},
{ name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline }, { name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline },
{ {
name: 'remote-user-profile-acct', name: 'remote-user-profile-acct',

View file

@ -75,14 +75,19 @@ const ChatMessage = {
// ChatMessage only has account_id while Status has full user data // ChatMessage only has account_id while Status has full user data
return !!this.message.user return !!this.message.user
}, },
author() { authorId() {
const accountId = this.isStatus return this.isStatus
? this.message.user.id ? this.message.user.id
: this.message.account_id : this.message.account_id
},
return this.$store.getters.findUser(accountId) author() {
return this.$store.getters.findUser(this.authorId)
}, },
isCurrentUser() { isCurrentUser() {
// mini-hack/optimizaiton:
// - current user would always be in memory so if user is missing it's obviously not us
// - if anon views page then "us" pretty much doesn't exist
if (!this.author || !this.currentUser) return false
return this.author.id === this.currentUser.id return this.author.id === this.currentUser.id
}, },
@ -179,11 +184,6 @@ const ChatMessage = {
menuOpened: false, menuOpened: false,
} }
}, },
watch: {
focused: function (value) {
this.scrollIfFocused(value)
},
},
methods: { methods: {
onHover(bool) { onHover(bool) {
this.$emit('hover', { this.$emit('hover', {
@ -219,22 +219,6 @@ const ChatMessage = {
this.hovered = false this.hovered = false
this.menuOpened = false this.menuOpened = false
}, },
scrollIfFocused(focused) {
if (this.$el.getBoundingClientRect == null) return
if (focused) {
const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= window.innerHeight - 50) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
}, },
} }

View file

@ -3,6 +3,7 @@
v-if="isMessage" v-if="isMessage"
class="chat-message-wrapper" class="chat-message-wrapper"
:class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]" :class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]"
:id="`chatmessage-${message.id}`"
@mouseover="onHover(true)" @mouseover="onHover(true)"
@mouseleave="onHover(false)" @mouseleave="onHover(false)"
> >
@ -74,9 +75,10 @@
> >
<UserPopover <UserPopover
v-if="chatItem.isHead" v-if="chatItem.isHead"
:user-id="author.id" :user-id="authorId"
> >
<UserAvatar <UserAvatar
v-if="author"
:compact="true" :compact="true"
:user="author" :user="author"
/> />
@ -202,7 +204,7 @@
{{ ' ' }} {{ ' ' }}
<router-link <router-link
class="timeago faint" class="timeago faint"
:to="{ name: 'conversation', params: { id: message.id } }" :to="{ name: 'conversation2', params: { statusId: message.id } }"
> >
<Timeago <Timeago
:time="message.created_at" :time="message.created_at"

View file

@ -1,4 +1,4 @@
import { maxBy, minBy, sortBy, throttle } from 'lodash' import { get, maxBy, minBy, sortBy, throttle } from 'lodash'
import { mapState as mapPiniaState } from 'pinia' import { mapState as mapPiniaState } from 'pinia'
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { mapState } from 'vuex' import { mapState } from 'vuex'
@ -28,6 +28,7 @@ import {
sendChatMessage, sendChatMessage,
} from 'src/api/chats.js' } from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.js' import { WSConnectionStatus } from 'src/api/websocket.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons' import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
@ -52,6 +53,7 @@ const Chat = {
PostStatusForm, PostStatusForm,
}, },
props: { props: {
statusId: String,
testMode: Boolean, testMode: Boolean,
}, },
data() { data() {
@ -65,6 +67,9 @@ const Chat = {
minId: undefined, minId: undefined,
maxId: undefined, maxId: undefined,
// Conversation stuff
explicitReplyStatus: null,
// Unread stuff // Unread stuff
newMessageCount: 0, newMessageCount: 0,
lastReadMessageId: null, lastReadMessageId: null,
@ -81,9 +86,9 @@ const Chat = {
created() { created() {
if (this.testMode) return if (this.testMode) return
this.startFetching() this.startFetching()
window.addEventListener('resize', this.handleResize)
}, },
mounted() { mounted() {
window.addEventListener('resize', this.handleResize)
window.addEventListener('scroll', this.handleScroll) window.addEventListener('scroll', this.handleScroll)
if (typeof document.hidden !== 'undefined') { if (typeof document.hidden !== 'undefined') {
document.addEventListener( document.addEventListener(
@ -108,12 +113,20 @@ const Chat = {
) )
}, },
computed: { computed: {
conversationId() {
const status = this.$store.state.statuses.allStatusesObject[this.statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
isConversation() {
return this.statusId !== null
},
recipient() { recipient() {
return this.chat?.account return this.chat?.account
}, },
recipientId() {
return this.$route.params.recipient_id
},
formPlaceholder() { formPlaceholder() {
if (this.recipient) { if (this.recipient) {
return this.$t('chats.message_user', { return this.$t('chats.message_user', {
@ -123,7 +136,18 @@ const Chat = {
return '' return ''
} }
}, },
// Conversation stuff
lastStatus() {
return this.messages[this.messages.length - 1]
},
replyStatus() {
return this.explicitReplyStatus ?? this.lastStatus
},
// Global Stuff
streamingEnabled() { streamingEnabled() {
if (this.isConversation) return false // Unsupported
return ( return (
this.mergedConfig.useStreamingApi && this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED this.mastoUserSocketStatus === WSConnectionStatus.JOINED
@ -139,17 +163,53 @@ const Chat = {
}), }),
}, },
watch: { watch: {
messages() { messages(old, neu) {
if (old.length === neu.length) return
// We don't want to scroll to the bottom on a new message when the user is viewing older messages. // We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update. // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET) const bottomedOutBeforeUpdate = isBottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => { this.$nextTick(() => {
if (bottomedOutBeforeUpdate) { if (bottomedOutBeforeUpdate) {
this.scrollDown() this.scrollDown()
} }
}) })
}, },
$route: function () { async replyStatus(newVal) {
await nextTick() // wait for changes to propagate to postStatusForm
this.$refs.postStatusForm.update()
},
$route: async function (newVal) {
if (this.messagesIndex[newVal.params.statusId]) {
const focused = document.getElementById(`chatmessage-${this.$route.params.statusId}`)
if (focused?.getBoundingClientRect == null) return
const bottomBoundary = window.innerHeight - this.$refs.footer.clientHeight
const topBoundary = this.$refs.header.clientHeight + document.getElementById('nav').clientHeight
const margin = Number(window.getComputedStyle(this.$refs.messageList.$el).gap.replace('px',''))
const rect = focused.getBoundingClientRect()
const scrollAmount = (() => {
if (rect.top < topBoundary) {
// Post is above screen, match its top to screen top
return rect.top - topBoundary - margin
} else if (rect.height >= bottomBoundary) {
// Post we want to see is taller than screen so match its top to screen top
return rect.top - topBoundary - margin
} else if (rect.bottom > bottomBoundary) {
// Post is below screen, match its bottom to screen bottom
return rect.bottom - bottomBoundary + margin
} else {
return 0
}
})()
if (scrollAmount !== 0) {
window.scrollBy(0, scrollAmount)
}
return
}
this.clear()
this.startFetching() this.startFetching()
}, },
mastoUserSocketStatus(newValue) { mastoUserSocketStatus(newValue) {
@ -159,53 +219,9 @@ const Chat = {
}, },
}, },
methods: { methods: {
onFilesDropped() { // Actions
this.$nextTick(() => {
this.handleResize()
})
},
handleVisibilityChange() {
this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
handleResize(opts = {}) {
const { delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
this.$nextTick(() => {
const { offsetHeight = undefined } = getScrollPosition()
const diff = offsetHeight - this.lastScrollPosition.offsetHeight
if (diff !== 0 && !this.bottomedOut()) {
this.$nextTick(() => {
window.scrollBy({ top: -Math.trunc(diff) })
})
}
this.lastScrollPosition = getScrollPosition()
})
},
scrollDown(options = {}) {
const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior,
})
})
if (forceRead) {
this.readChat()
}
},
async readChat() { async readChat() {
if (this.conversationId) return // Unsupported
if (!this.maxId || document.hidden) { if (!this.maxId || document.hidden) {
return return
} }
@ -225,11 +241,17 @@ const Chat = {
this.lastReadMessageId = this.maxId this.lastReadMessageId = this.maxId
this.newMessageCount = 0 this.newMessageCount = 0
}, },
bottomedOut(offset) { scrollDown(options = {}) {
return isBottomedOut(offset) const { behavior = 'auto', forceRead = false } = options
}, this.$nextTick(() => {
reachedTop() { window.scrollTo({
return window.scrollY <= 0 top: document.documentElement.scrollHeight,
behavior,
})
})
if (forceRead) {
this.readChat()
}
}, },
cullOlder() { cullOlder() {
const maxIndex = this.messages.length const maxIndex = this.messages.length
@ -248,44 +270,6 @@ const Chat = {
this.messages = this.messages.slice(minIndex, maxIndex) this.messages = this.messages.slice(minIndex, maxIndex)
}, },
cullOlderCheck() {
window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.cullOlder()
}
}, 5000)
},
handleScroll: throttle(function () {
if (!this.chat) {
return
}
this.lastScrollPosition = getScrollPosition()
if (this.reachedTop()) {
this.fetchChat({ maxId: this.minId })
} else if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
this.cullOlderCheck()
if (this.newMessageCount > 0) {
// Use a delay before marking as read to prevent situation where new messages
// arrive just as you're leaving the view and messages that you didn't actually
// get to see get marked as read.
window.setTimeout(() => {
// Don't mark as read if the element doesn't exist, user has left chat view
if (this.$el) this.readChat()
}, MARK_AS_READ_DELAY)
}
} else {
this.jumpToBottomButtonVisible = true
}
}, 200),
handleScrollUp(positionBeforeLoading) {
const positionAfterLoading = getScrollPosition()
window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
})
},
clear() { clear() {
this.messages = this.messages.filter((m) => m.error) this.messages = this.messages.filter((m) => m.error)
this.messagesIndex = this.messages.reduce( this.messagesIndex = this.messages.reduce(
@ -305,12 +289,31 @@ const Chat = {
return return
} }
const { data: messages } = await chatMessages({ let messages
id: this.chat.id, if (this.isConversation) {
maxId, const [
sinceId: fetchLatest ? this.maxId : null, { data: status },
credentials: useOAuthStore().token, { data: { ancestors, descendants } }
}) ] = await Promise.all([
fetchStatus({
id: this.statusId,
credentials: useOAuthStore().token,
}),
fetchConversation({
id: this.statusId,
credentials: useOAuthStore().token,
})
])
messages = [...ancestors, status, ...descendants]
} else {
const { data } = await chatMessages({
id: this.chat.id,
maxId,
sinceId: fetchLatest ? this.maxId : null,
credentials: useOAuthStore().token,
})
messages = data
}
// Clear the current chat in case we're recovering from a ws connection loss. // Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) { if (isFirstFetch) {
@ -321,6 +324,9 @@ const Chat = {
this.addMessages({ messages }) this.addMessages({ messages })
await nextTick() await nextTick()
if (isFirstFetch) {
this.scrollDown()
}
const fetchOlderMessages = !!maxId const fetchOlderMessages = !!maxId
if (fetchOlderMessages) { if (fetchOlderMessages) {
@ -338,20 +344,22 @@ const Chat = {
} }
}, },
async startFetching() { async startFetching() {
try { if (!this.isConversation) {
const { data } = await getOrCreateChat({ try {
accountId: this.recipientId, const { data } = await getOrCreateChat({
credentials: useOAuthStore().token, accountId: this.recipientId,
}) credentials: useOAuthStore().token,
this.$store.commit('addNewUsers', [data.account]) })
data.account = this.$store.getters.findUser(data.account.id) this.$store.commit('addNewUsers', [data.account])
this.chat = data data.account = this.$store.getters.findUser(data.account.id)
} catch (e) { this.chat = data
console.error('Error creating or getting a chat', e) } catch (e) {
this.errorLoadingChat = true console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
} }
if (this.chat) { if (this.isConversation || this.chat) {
this.$nextTick(() => { this.$nextTick(() => {
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: true })
}) })
@ -365,33 +373,12 @@ const Chat = {
) )
this.fetchChat({ isFirstFetch: true }) this.fetchChat({ isFirstFetch: true })
}, },
async deleteChatMessage({ chatId, messageId }) {
if (!this.testMode)
await deleteChatMessage({
chatId,
messageId,
credentials: useOAuthStore().token,
})
this.messages = this.messages.filter((m) => m.id !== messageId)
delete this.messagesIndex[messageId]
if (this.maxId === messageId) {
const lastMessage = maxBy(this.messages, 'id')
this.maxId = lastMessage.id
}
if (this.minId === messageId) {
const firstMessage = minBy(this.messages, 'id')
this.minId = firstMessage.id
}
},
addMessages({ messages: newMessages }) { addMessages({ messages: newMessages }) {
for (let i = 0; i < newMessages.length; i++) { for (let i = 0; i < newMessages.length; i++) {
const message = newMessages[i] const message = newMessages[i]
// Sanity check // Sanity check
if (message.chat_id !== this.chat.id) { if (!this.isConversation && (message.chat_id !== this.chat.id)) {
console.warn( console.warn(
`Chat message doesn't belong to current chat (id: ${this.chat.id})!!`, `Chat message doesn't belong to current chat (id: ${this.chat.id})!!`,
message, message,
@ -428,14 +415,11 @@ const Chat = {
} }
} }
}, },
handleAttachmentPosting() { goBack() {
this.$nextTick(() => { this.$router.back()
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
// to account for the potential delay in the DOM update.
this.scrollDown({ forceRead: true })
})
}, },
// Optimistic posting (chats only)
async sendMessage({ status, media, idempotencyKey }) { async sendMessage({ status, media, idempotencyKey }) {
const params = { const params = {
id: this.chat.id, id: this.chat.id,
@ -515,12 +499,122 @@ const Chat = {
fakeMessage.pending = false fakeMessage.pending = false
} }
}, },
goBack() {
this.$router.push({ // Checks
name: 'chats', hasReachedTop() {
params: { username: this.currentUser.screen_name }, return window.scrollY <= 0
},
cullOlderCheck() {
if (this.conversationId) return
window.setTimeout(() => {
if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.cullOlder()
}
}, 5000)
},
// Event handlers
onPosted(data) {
this.explicitReplyStatus = null
this.$router.push({ name: 'conversation2', params: { statusId: data.id } })
},
handleVisibilityChange() {
this.$nextTick(() => {
if (!document.hidden && isBottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
}) })
}, },
onFilesDropped() {
this.$nextTick(() => {
this.handleResize()
})
},
handleResize(opts = {}) {
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
const { delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
this.$nextTick(() => {
const { offsetHeight = undefined } = getScrollPosition()
const diff = offsetHeight - this.lastScrollPosition.offsetHeight
if (diff !== 0 && !isBottomedOut()) {
this.$nextTick(() => {
window.scrollBy({ top: -Math.trunc(diff) })
})
}
this.lastScrollPosition = getScrollPosition()
})
},
handleScroll: throttle(function () {
if (!this.chat) {
return
}
this.lastScrollPosition = getScrollPosition()
if (this.hasReachedTop()) {
this.fetchChat({ maxId: this.minId })
} else if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
this.cullOlderCheck()
if (this.newMessageCount > 0) {
// Use a delay before marking as read to prevent situation where new messages
// arrive just as you're leaving the view and messages that you didn't actually
// get to see get marked as read.
window.setTimeout(() => {
// Don't mark as read if the element doesn't exist, user has left chat view
if (this.$el) this.readChat()
}, MARK_AS_READ_DELAY)
}
} else {
this.jumpToBottomButtonVisible = true
}
}, 200),
handleScrollUp(positionBeforeLoading) {
const positionAfterLoading = getScrollPosition()
window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
})
},
handleAttachmentPosting() {
this.$nextTick(() => {
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
// to account for the potential delay in the DOM update.
this.scrollDown({ forceRead: true })
})
},
// Ugly
// TODO move to ChatMessage
async deleteChatMessage({ chatId, messageId }) {
if (!this.testMode)
await deleteChatMessage({
chatId,
messageId,
credentials: useOAuthStore().token,
})
this.messages = this.messages.filter((m) => m.id !== messageId)
delete this.messagesIndex[messageId]
if (this.maxId === messageId) {
const lastMessage = maxBy(this.messages, 'id')
this.maxId = lastMessage.id
}
if (this.minId === messageId) {
const firstMessage = minBy(this.messages, 'id')
this.minId = firstMessage.id
}
},
}, },
} }

View file

@ -1,6 +1,18 @@
.chat-view { .chat-view {
display: flex; display: flex;
height: 100%; margin-bottom: -1em;
margin-top: -1em;
.chat-list-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.top-spacer {
flex: 1 1 0;
min-height: 0;
}
.chat-view-inner { .chat-view-inner {
height: auto; height: auto;
@ -36,6 +48,10 @@
.footer { .footer {
position: sticky; position: sticky;
display: flex;
align-items: stretch;
flex-direction: column;
padding: 0;
bottom: 0; bottom: 0;
z-index: 1; z-index: 1;
} }
@ -95,4 +111,11 @@
} }
} }
} }
.reply-to-text {
text-align: center;
line-height: 1.2;
padding-top: 0.5em;
margin-bottom: -0.5em;
}
} }

View file

@ -18,22 +18,39 @@
icon="chevron-left" icon="chevron-left"
/> />
</button> </button>
<div class="title text-center"> <div class="title">
<template v-if="isConversation">
<RichContent
v-if="messages[0]?.summary"
:html="messages[0].summary"
/>
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</template>
<ChatTitle <ChatTitle
v-else
:user="recipient" :user="recipient"
:with-avatar="true" :with-avatar="true"
/> />
</div> </div>
</div> </div>
<ChatMessageList <div class="chat-list-wrapper panel-body">
header-date <div class="top-spacer" />
:messages="messages" <ChatMessageList
:pending-messages="pendingMessages" ref="messageList"
@message-delete="deleteChatMessage" header-date
/> :messages="messages"
:pending-messages="pendingMessages"
:replied-id="replyStatus?.id"
:focused-id="statusId"
@message-delete="deleteChatMessage"
@reply-requested="e => explicitReplyStatus = e"
/>
</div>
<div <div
ref="footer" ref="footer"
class="panel-body footer" class="panel-footer -flexible-height footer"
> >
<div <div
class="jump-to-bottom-button" class="jump-to-bottom-button"
@ -50,27 +67,52 @@
</div> </div>
</span> </span>
</div> </div>
<div class="auto-reply-to-section">
<div class="reply-to-text">
{{ explicitReplyStatus ? $t('status.reply_to_selected') : $t('status.reply_to_last') }}
<button
v-if="explicitReplyStatus"
class="button-default"
@click="explicitReplyStatus = null"
>
<FAIcon icon="times" />
{{ $t('general.cancel') }}
</button>
</div>
</div>
<PostStatusForm <PostStatusForm
:disable-subject="true" ref="postStatusForm"
:disable-scope-selector="true" :reply-to="replyStatus?.id"
:disable-notice="true" :mentions-line="isConversation"
:disable-lock-warning="true" mentions-line-read-only
:disable-polls="true" :attentions="replyStatus?.attentions"
:disable-quotes="true" :replied-user="replyStatus?.user"
:disable-sensitivity-checkbox="true" :replied-subject="replyStatus?.summary"
:disable-submit="errorLoadingChat || !chat" :replied-scope="replyStatus?.visibility"
:disable-preview="true"
:disable-draft="true" disable-quotes
:optimistic-posting="true" disable-notice
:post-handler="sendMessage" disable-lock-warning
:disable-subject="!isConversation"
:disable-scope-selector="!isConversation"
:disable-polls="!isConversation"
:disable-sensitivity-checkbox="!isConversation"
:disable-preview="!isConversation"
:disable-draft="!isConversation"
:disable-submit="isConversation ? false : (errorLoadingChat || !chat)"
:optimistic-posting="!isConversation"
:submit-on-enter="!mobileLayout" :submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout" :preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout" :auto-focus="!mobileLayout"
:placeholder="formPlaceholder" :placeholder="formPlaceholder"
:file-limit="1" :file-limit="isConversation ? null : 1"
max-height="160" :max-height="160"
emoji-picker-placement="top" emoji-picker-placement="top"
:post-handler="isConversation ? null : sendMessage"
@resize="handleResize" @resize="handleResize"
@posted="onPosted"
/> />
</div> </div>
</div> </div>

View file

@ -114,7 +114,6 @@ const conversation = {
inlineDivePosition: null, inlineDivePosition: null,
loadStatusError: null, loadStatusError: null,
unsuspendibleIds: new Set(), unsuspendibleIds: new Set(),
explicitReplyStatus: null,
} }
}, },
created() { created() {
@ -130,12 +129,6 @@ const conversation = {
const maxDepth = this.mergedConfig.maxDepthInThread - 2 const maxDepth = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1 return maxDepth >= 1 ? maxDepth : 1
}, },
lastStatus() {
return this.conversation[this.conversation.length - 1]
},
replyStatus() {
return this.explicitReplyStatus ?? this.lastStatus
},
streamingEnabled() { streamingEnabled() {
return ( return (
this.mergedConfig.useStreamingApi && this.mergedConfig.useStreamingApi &&
@ -152,10 +145,7 @@ const conversation = {
return this.displayStyle === 'tree' return this.displayStyle === 'tree'
}, },
isLinearView() { isLinearView() {
return this.displayStyle === 'linear' return this.displayStyle !== 'tree'
},
isChatView() {
return this.displayStyle === 'chat'
}, },
shouldFadeAncestors() { shouldFadeAncestors() {
return this.mergedConfig.conversationTreeFadeAncestors return this.mergedConfig.conversationTreeFadeAncestors
@ -636,7 +626,6 @@ const conversation = {
} }
}, },
onPosted(data) { onPosted(data) {
this.explicitReplyStatus = null
if (this.isPage) { if (this.isPage) {
this.$router.push({ name: 'conversation', params: { id: data.id } }) this.$router.push({ name: 'conversation', params: { id: data.id } })
} }

View file

@ -92,19 +92,4 @@
backdrop-filter: var(--__panel-backdrop-filter); backdrop-filter: var(--__panel-backdrop-filter);
} }
} }
.chat-view-reply-form {
position: sticky;
display: flex;
flex-direction: column;
align-items: stretch;
bottom: 0;
padding: 0;
}
.reply-to-text {
line-height: 1.2;
padding-top: 0.5em;
margin-bottom: -0.5em;
}
} }

View file

@ -176,7 +176,7 @@
/> />
</div> </div>
<div <div
v-else-if="isLinearView || (isChatView && !isExpanded)" v-else-if="isLinearView"
class="thread-body" class="thread-body"
> >
<article> <article>
@ -202,52 +202,6 @@
/> />
</article> </article>
</div> </div>
<div
v-else-if="isChatView"
class="chat-view"
>
<ChatMessageList
:messages="conversation"
:replied-id="replyStatus?.id"
:focused-id="maybeFocused"
@reply-requested="e => explicitReplyStatus = e"
/>
</div>
</div>
<div
v-if="isChatView && isExpanded && replyStatus"
class="chat-view-reply-form panel-footer -flexible-height"
>
<div class="auto-reply-to-section">
<center class="reply-to-text">
{{ explicitReplyStatus ? $t('status.reply_to_selected') : $t('status.reply_to_last') }}
<button
v-if="explicitReplyStatus"
class="button-default"
@click="explicitReplyStatus = null"
>
<FAIcon icon="times" />
{{ $t('general.cancel') }}
</button>
</center>
</div>
<PostStatusForm
class="reply-form"
:reply-to="replyStatus.id"
:attentions="replyStatus.attentions"
:replied-user="replyStatus.user"
:replied-sibject="replyStatus.summary"
:replied-scope="replyStatus.visibility"
:submit-on-enter="!mobileLayout"
:preserve-focus="!mobileLayout"
:auto-focus="!mobileLayout"
disable-quotes
disable-lock-warning
disable-notice
mentions-line
mentions-line-read-only
@posted="onPosted"
/>
</div> </div>
</div> </div>
<div <div

View file

@ -588,6 +588,12 @@ const PostStatusForm = {
}, },
methods: { methods: {
// Composing // Composing
update() {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
if (key === 'status') return
this.newStatus[key] = value
})
},
onMentionsLineUpdate(e) { onMentionsLineUpdate(e) {
if (this.mentionsLineReadOnly) return if (this.mentionsLineReadOnly) return
// TODO // TODO

View file

@ -231,11 +231,12 @@
> >
<scope-selector <scope-selector
v-if="!disableVisibilitySelector" v-if="!disableVisibilitySelector"
ref="scopeSelector"
:show-all="showAllScopes" :show-all="showAllScopes"
:user-default="userDefaultScope" :user-default="userDefaultScope"
:original-scope="repliedScope" :original-scope="newStatus.visibility"
:initial-scope="newStatus.visibility" :initial-scope="newStatus.visibility"
:on-scope-change="changeVis" @change="changeVis"
/> />
<div <div

View file

@ -12,11 +12,10 @@ import {
faBars, faBars,
faFolderTree, faFolderTree,
faList, faList,
faMessage,
faWrench, faWrench,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add(faList, faFolderTree, faBars, faWrench, faMessage) library.add(faList, faFolderTree, faBars, faWrench)
const QuickViewSettings = { const QuickViewSettings = {
props: { props: {

View file

@ -57,24 +57,6 @@
/> {{ $t('settings.conversation_display_linear_quick') }} /> {{ $t('settings.conversation_display_linear_quick') }}
</button> </button>
</div> </div>
<div class="menu-item dropdown-item -icon-double">
<button
class="main-button"
:aria-checked="conversationDisplay === 'chat'"
role="menuitemradio"
@click="conversationDisplay = 'chat'"
>
<span
class="input menu-checkbox -radio"
:class="{ 'menu-checkbox-checked': conversationDisplay === 'chat' }"
:aria-hidden="true"
/><FAIcon
icon="message"
:aria-hidden="true"
fixed-width
/> {{ $t('settings.conversation_display_chat_quick') }}
</button>
</div>
</div> </div>
<div <div
role="separator" role="separator"

View file

@ -26,16 +26,13 @@ const ScopeSelector = {
required: false, required: false,
type: String, type: String,
}, },
onScopeChange: {
required: true,
type: Function,
},
unstyled: { unstyled: {
required: false, required: false,
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}, },
emits: ['change'],
data() { data() {
return { return {
currentScope: this.initialScope, currentScope: this.initialScope,
@ -84,9 +81,14 @@ const ScopeSelector = {
}, },
changeVis(scope) { changeVis(scope) {
this.currentScope = scope this.currentScope = scope
this.onScopeChange && this.onScopeChange(scope) this.$emit('change', scope)
}, }
}, },
watch: {
originalScope(newVal) {
this.currentScope = newVal
}
}
} }
export default ScopeSelector export default ScopeSelector

View file

@ -28,8 +28,10 @@ import {
faShareAlt, faShareAlt,
faStar, faStar,
faThumbtack, faThumbtack,
faPencil,
faTimes, faTimes,
faWrench, faWrench,
faComments,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
library.add( library.add(
@ -53,7 +55,9 @@ library.add(
faEyeSlash, faEyeSlash,
faEye, faEye,
faThumbtack, faThumbtack,
faPencil,
faShareAlt, faShareAlt,
faComments,
faExternalLinkAlt, faExternalLinkAlt,
faHistory, faHistory,
) )

View file

@ -218,6 +218,17 @@ export const BUTTONS = [
) )
}, },
}, },
{
// =========
// OPEN IN CHAT VIEW
// =========
name: 'edit',
icon: 'comments',
label: 'status.open_in_chat_view',
action({ router, status }) {
router.push({ name: 'conversation2', params: { statusId: status.id } })
},
},
{ {
// ========= // =========
// DELETE // DELETE

View file

@ -801,8 +801,6 @@
"tree_fade_ancestors": "Display ancestors of the current status in faint text", "tree_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style", "conversation_display_linear": "Linear-style",
"conversation_display_linear_quick": "Linear view", "conversation_display_linear_quick": "Linear view",
"conversation_display_chat": "Chat-style",
"conversation_display_chat_quick": "Chat view",
"conversation_other_replies_button": "Show the \"other replies\" button", "conversation_other_replies_button": "Show the \"other replies\" button",
"conversation_other_replies_button_below": "Below statuses", "conversation_other_replies_button_below": "Below statuses",
"conversation_other_replies_button_inside": "Inside statuses", "conversation_other_replies_button_inside": "Inside statuses",
@ -1649,6 +1647,7 @@
"delete_error": "Error deleting status: {0}", "delete_error": "Error deleting status: {0}",
"edit": "Edit status", "edit": "Edit status",
"edited_at": "(last edited {time})", "edited_at": "(last edited {time})",
"open_in_chat_view": "Open in chat view",
"pin": "Pin on profile", "pin": "Pin on profile",
"unpin": "Unpin from profile", "unpin": "Unpin from profile",
"pinned": "Pinned", "pinned": "Pinned",