Page MenuHomePhorge

No OneTemporary

Size
26 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/chat_view/chat_view.js b/src/components/chat_view/chat_view.js
index c34d56ac44..cbc54157eb 100644
--- a/src/components/chat_view/chat_view.js
+++ b/src/components/chat_view/chat_view.js
@@ -1,256 +1,256 @@
import { storeToRefs } from 'pinia'
import {
computed,
inject,
onUnmounted,
provide,
ref,
toRefs,
useTemplateRef,
watch,
watchEffect,
} from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useClientRectSize } from 'src/composables/useClientRectSize.js'
import { useConversation } from 'src/composables/useConversation.js'
import { usePleromaChat } from 'src/composables/usePleromaChat.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown)
const MARK_AS_READ_DELAY = 1500
const Chat = {
components: {
ChatMessageList,
ChatTitle,
PostStatusForm,
},
props: {
statusId: {
type: String,
default: null,
},
chatUserId: {
type: String,
default: null,
},
},
setup(props) {
const { statusId, chatUserId } = toRefs(props)
const isConversation = computed(() => !!statusId.value)
const isChat = computed(() => !!chatUserId.value)
const isValid = computed(() => !!isChat.value !== !!isConversation.value) // Logical XOR
watch(isValid, (value) => {
if (!value)
throw TypeError(
'Chat view must have either statusId or chatUserId, but not both',
)
})
// # Conversation stuff
const {
conversation: conversationMessages,
loadError: conversationError,
focusedId,
setFocused,
fetchConversation,
} = useConversation(statusId, ref(true))
watch(
isConversation,
(value) => {
if (!value) return
fetchConversation()
},
{ immediate: true },
)
// # Chat stuff
const {
activate: chatActivate,
deactivate: chatDeactivate,
markAsRead: chatMarkRead,
fetchOlder: chatFetchOlder,
messages: chatMessages,
pendingMessages: chatPendingMessages,
newMessagesCount: chatNewMessagesCount,
fetchError: chatError,
ready: chatReady,
recipient: chatRecipient,
sendMessage,
deleteChatMessage,
} = usePleromaChat(chatUserId)
provide('deleteChatMessage', deleteChatMessage)
watch(
chatUserId,
(neu, old) => {
if (old) {
chatDeactivate()
}
if (neu) {
chatActivate()
}
},
{ immediate: true },
)
onUnmounted(() => {
if (chatUserId.value) {
chatDeactivate()
}
})
// # Forks
const messages = computed(() => {
if (isConversation.value) {
return conversationMessages.value
} else {
return chatMessages.value
}
})
const pendingMessages = computed(() => {
if (isConversation.value) {
return [] // Not implemented yet
} else {
return chatPendingMessages.value
}
})
const newMessagesCount = computed(() => {
if (isConversation.value) {
return 0 // Not implemented yet
} else {
return chatNewMessagesCount.value
}
})
const error = computed(() => {
if (isConversation.value) {
return conversationError.value
} else {
return chatError.value
}
})
const fetchOlder = () => {
if (isChat.value) {
chatFetchOlder()
}
// Unsupported in conversations
}
const markAsRead = () => {
if (isChat.value) {
chatMarkRead()
}
// Unsupported in conversations
}
// Reply-to
const explicitReply = ref(null)
const lastMessage = computed(() => messages.value?.at(-1))
const replyTo = computed(() => explicitReply.value ?? lastMessage.value)
const scroller = inject('bodyScrollPosition')
const rootElement = useTemplateRef('root')
const postForm = useTemplateRef('postStatusForm')
// Post form stuff
watch(replyTo, () => {
if (isConversation.value) postForm.value.update()
})
// # Scroll stuff
const { hasReachedBottom } = scroller
const postFormElement = computed(() => postForm.value?.$el)
const { height: postFormHeight } = useClientRectSize(postFormElement)
const { scrollBy } = scroller
// ### Post form size compensation
watch(postFormHeight, (neu, old) => {
scrollBy(0, neu - old)
})
// ## Load / Read
const { shouldLoadTop } = scroller
watchEffect(() => {
if (shouldLoadTop.value && messages.value.length > 0) fetchOlder()
})
watch(hasReachedBottom, (value) => {
if (!value) return
window.setTimeout(() => {
if (
newMessagesCount.value > 0 &&
rootElement.value &&
hasReachedBottom.value
) {
markAsRead()
}
}, MARK_AS_READ_DELAY)
})
// Misc UI stuff
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
const jumpToBottomButtonVisible = computed(() => !hasReachedBottom.value)
const router = useRouter()
const { t } = useI18n()
const formPlaceholder = computed(() => {
- if (chatRecipient) {
+ if (chatRecipient.value) {
return t('chats.message_user', {
nickname: chatRecipient.value?.screen_name_ui,
})
} else {
return ''
}
})
const onPosted = (data) => {
explicitReply.value = null
if (isConversation.value) {
router.push({
name: 'conversation2',
params: { statusId: data.id },
})
}
}
return {
messages,
pendingMessages,
newMessagesCount,
error,
replyTo,
explicitReply,
// Conversation-exclusive
isConversation,
setFocused,
focusedId,
// Chats-exclusive
isChat,
sendMessage,
chatReady,
chatRecipient,
// Misc
onPosted,
mobileLayout,
jumpToBottomButtonVisible,
formPlaceholder,
}
},
}
export default Chat
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 93acb4b9a4..8a6ad7d864 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,275 +1,279 @@
import { debounce, throttle } from 'lodash-es'
import { storeToRefs } from 'pinia'
import {
computed,
inject,
nextTick,
onMounted,
onUnmounted,
ref,
toRefs,
useTemplateRef,
watch,
} from 'vue'
import { useI18n } from 'vue-i18n'
import Conversation from 'src/components/conversation/conversation.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button.vue'
import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useStatusesStore } from 'src/stores/statuses.js'
import { useTimelinesStore } from 'src/stores/timelines.js'
import { useDocumentFocus } from 'src/composables/useDocumentFocus.js'
import { useInterfaceSizes } from 'src/composables/useInterfaceSizes.js'
import { useVirtualScrolling } from 'src/composables/useVirtualScrolling.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCheck,
faCircleNotch,
faCirclePlus,
faCog,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = {
props: {
timelineRef: Object,
footerSlipgate: Object, // reference to an element where we should put our footer
embedded: Boolean,
skipPinned: Boolean,
hideEmpty: Boolean,
},
components: {
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
setup(props, ctx) {
const { t } = useI18n()
const { focused } = useDocumentFocus()
const unfocused = computed(() => !focused.value)
// Timeline
const { timelineRef } = toRefs(props)
const timeline = computed(() => useTimelinesStore()[timelineRef.value.name])
const { skipPinned } = toRefs(props)
const filteredVisibleStatuses = computed(() => {
return timeline.value.order
.filter((id) => timeline.value.visibleStatusIds.has(id))
.map((id) => useStatusesStore().allStatuses.get(id))
.filter(({ pinned }) => (skipPinned.value ? !pinned : true))
})
// Scroll position
const scroller = inject('bodyScrollPosition')
const { hasReachedTop, shouldLoadBottom, top: scrollY } = scroller
// Virtual scrolling
const { fontSize, navbarSize } = useInterfaceSizes()
// Placeholder heights.
const mutedStatusHeight = computed(() => fontSize.value * 1.5)
const normalStatusHeight = computed(() => fontSize.value * 10)
const getPlaceholderHeight = (id) =>
filteredVisibleStatuses.value.find((item) => item.id === id)?.muted
? mutedStatusHeight
: normalStatusHeight
const body = useTemplateRef('timeline')
const offset = computed(() => {
if (embedded.value) {
// The fontsize after navbar is the little gap between navbar and content
return navbarSize.value + fontSize.value
} else {
return 0
}
})
const { heightChart, changeSuspendState, updateVirtualHeight } =
useVirtualScrolling({
name: 'Timeline',
enabled: ref(true),
list: filteredVisibleStatuses,
body,
offset,
scrollPositionInstance: scroller,
scrollCompensation: false,
getPlaceholderHeight,
})
// Counter
const count = computed(() => timeline.value.order.length)
const newStatusCount = computed(() => timeline.value.newStatusCount)
const showLoadButton = computed(
() => timeline.value.newStatusCount > 0 || timeline.value.reloadNeeded,
)
// Showing new
const paused = ref(false)
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const unfocusedPause = computed(
() => unfocused.value && mergedConfig.value.pauseOnUnfocused,
)
const showNewAutomatically = computed(
() => useMergedConfigStore().mergedConfig.streaming,
)
watch(newStatusCount, (count) => {
if (!showNewAutomatically.value) return
if (count <= 0) return
// only 'stream' them when you're scrolled to the top
if (hasReachedTop.value && !paused.value && !unfocusedPause.value) {
showNewStatuses(true)
} else {
paused.value = true
}
})
const showNewStatuses = async (fast = false) => {
if (timeline.value.reloadNeeded) {
useTimelinesStore().clearTimeline(timelineRef.value.name)
fetchOlderStatuses()
} else {
blockClicksTemporarily()
useTimelinesStore().showNewStatuses(timelineRef.value.name)
}
paused.value = false
await nextTick()
scroller.scrollToPriority({
top: 0,
behavior: fast ? 'instant' : 'smooth',
})
}
const fetchOlderStatuses = throttle(() => {
timeline.value.fetcher.fetchOlder()
}, 1000)
// Timeline change
const timelineChange = (newTimeline, oldTimeline) => {
const sameName = newTimeline?.name === oldTimeline?.name
const sameArgument = newTimeline?.argument === oldTimeline?.argument
if (sameName && sameArgument) return
if (oldTimeline) {
useTimelinesStore().deactivate(oldTimeline.name)
}
if (newTimeline) {
useTimelinesStore().activate(newTimeline.name, newTimeline.argument)
}
}
watch(timelineRef, timelineChange, { immediate: true })
onUnmounted(() => {
timelineChange(null, timelineRef.value)
})
// Misclick prevention
const blockingClicks = ref(false)
const stopBlockingClicks = debounce(() => {
blockingClicks.value = false
}, 1000)
const blockClicksTemporarily = () => {
if (!blockingClicks.value) {
blockingClicks.value = true
}
stopBlockingClicks()
}
// Shortcuts
const handleShortKey = (e) => {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') showNewStatuses()
}
onMounted(() => {
window.addEventListener('keydown', handleShortKey)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleShortKey)
})
+ const infiniteLoadWatcher = debounce((value) => {
+ if (!value) return
+ fetchOlderStatuses()
+ }, 100)
// Scroll
watch(
[shouldLoadBottom, scrollY],
- debounce(([value]) => {
- if (!value) return
- fetchOlderStatuses()
- }, 100),
+ infiniteLoadWatcher,
)
+ onUnmounted(() => {
+ infiniteLoadWatcher.cancel()
+ })
// Misc UI things
const classes = computed(() => {
let rootClasses = !embedded.value
? ['panel', 'panel-default']
: ['-embedded']
if (blockingClicks.value)
rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
header: ['timeline-heading'].concat(
!embedded.value ? ['panel-heading', '-sticky'] : ['panel-body'],
),
body: ['timeline-body'].concat(
!embedded.value ? ['panel-body'] : ['panel-body'],
),
footer: ['timeline-footer'].concat(
!embedded.value ? ['panel-footer'] : ['panel-body'],
),
}
})
const loadButtonString = computed(() => {
if (timeline.value.reloadNeeded) {
return t('timeline.reload')
} else {
return `${t('timeline.show_new')} (${newStatusCount.value})`
}
})
const mobileLoadButtonString = computed(() => {
if (timeline.value.reloadNeeded) {
return '+'
} else {
return newStatusCount.value > 99 ? '∞' : newStatusCount.value
}
})
const { layoutType } = storeToRefs(useInterfaceStore())
const mobileLayout = computed(() => layoutType.value === 'mobile')
const { footerSlipgate, embedded, hideEmpty } = toRefs(props)
return {
timelineRef,
timeline,
filteredVisibleStatuses,
heightChart,
updateVirtualHeight,
changeSuspendState,
count,
showLoadButton,
showNewStatuses,
fetchOlderStatuses,
classes,
loadButtonString,
mobileLoadButtonString,
mobileLayout,
footerSlipgate,
embedded,
hideEmpty,
}
},
}
export default Timeline
diff --git a/src/composables/usePleromaChat.js b/src/composables/usePleromaChat.js
index 045f9ca011..22d0bcb353 100644
--- a/src/composables/usePleromaChat.js
+++ b/src/composables/usePleromaChat.js
@@ -1,361 +1,360 @@
import { maxBy, minBy } from 'lodash-es'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useChatsStore } from 'src/stores/chats.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useStreamingStore } from 'src/stores/streaming.js'
import { useUsersStore } from 'src/stores/users.js'
import {
deleteChatMessage as apiDeleteChatMessage,
chatMessages,
getOrCreateChat,
readChat,
sendChatMessage,
} from 'src/api/chats.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { buildFakeMessage } from 'src/services/chat_utils/chat_utils.js'
import { promiseInterval } from 'src/services/promise_interval/promise_interval.js'
const MAX_RETRIES = 10
export function usePleromaChat(userId) {
const { mergedConfig } = storeToRefs(useMergedConfigStore())
const { state: mastoUserSocketStatus } = storeToRefs(useStreamingStore())
const streamingEnabled = computed(
() =>
mergedConfig.value.useStreamingApi &&
mastoUserSocketStatus.value === WSConnectionStatus.JOINED,
)
const chat = ref(null)
const socket = ref(null)
const streaming = ref(false)
const fetcher = ref(null)
const fetching = ref(false)
const minId = ref(undefined)
const maxId = ref(undefined)
const idempotencyKeyIndex = ref(new Map())
const messages = ref([])
const messagesIndex = ref(new Map())
const pendingMessages = ref([])
const pendingMessagesIndex = ref(new Map())
const messageRetriers = ref(new Map())
const lastReadMessageId = ref(null)
const newMessagesCount = ref(0)
// # Posting & Optimism
const { currentUser } = storeToRefs(useUsersStore())
const sendMessage = async ({ status, media, idempotencyKey }) => {
const params = {
id: chat.value.id,
content: status,
idempotencyKey,
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: chat.value.id,
content: status,
userId: currentUser.value.id,
idempotencyKey,
})
pendingMessages.value.push(fakeMessage)
pendingMessagesIndex.value.set(idempotencyKey, fakeMessage)
return doSendMessage({
params,
retriesLeft: MAX_RETRIES,
})
}
const doSendMessage = async ({ params, retriesLeft = MAX_RETRIES }) => {
if (retriesLeft <= 0) return
- const handleMessageError = ({ idempotencyKey, isRetry }) => {
+ const handleMessageError = ({ idempotencyKey }) => {
const fakeMessage = pendingMessagesIndex.value.get(idempotencyKey)
if (fakeMessage) {
fakeMessage.error = true
fakeMessage.pending = false
}
}
try {
const { data } = await sendChatMessage({
...params,
credentials: useOAuthStore().token,
})
addMessages([{ ...data }])
} catch (error) {
if (
error.name !== 'StatusCodeError' ||
error.message === 'Failed to fetch'
) {
throw error
}
console.error('Error sending message', error)
handleMessageError({
chatId: chat.value.id,
idempotencyKey: params.idempotencyKey,
- isRetry: retriesLeft !== MAX_RETRIES,
})
const error5xx = error.statusCode >= 500 && error.statusCode < 600
if (error5xx || error.message === 'Failed to fetch') {
messageRetriers.value.set(
params.idempotencyKey,
setTimeout(
() => {
doSendMessage({
params,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
),
)
}
}
}
// # Poll & Push
const startFetching = (reason) => {
console.debug('[Pleroma Chat] Started fetching', 'Reason:', reason)
fetchOlder()
fetcher.value = promiseInterval(() => fetchChat({ latest: true }), 5000)
fetching.value = true
}
const stopFetching = (reason) => {
console.debug('[Pleroma Chat] Stopped fetching', 'Reason:', reason)
if (!fetching.value) return
fetcher.value.stop()
fetcher.value = null
fetching.value = false
}
const onStreamConnect = () => {
streaming.value = true
stopFetching('Socket connected')
}
const onStreamDisconnect = (closeEvent) => {
streaming.value = false
startFetching('Socket disconnected')
}
const onChatUpdate = ({ data: { chatUpdate } }) => {
const messages = [chatUpdate.lastMessage]
addMessages(messages)
}
// # Actions
const markAsRead = async () => {
if (!maxId.value || document.hidden) {
return
}
const lastReadId = maxId.value
const isNewMessage = lastReadMessageId.value !== lastReadId
if (!isNewMessage) return
await readChat({
id: chat.value.id,
lastReadId,
credentials: useOAuthStore().token,
})
useChatsStore().readChat(chat.value.id)
lastReadMessageId.value = maxId.value
newMessagesCount.value = 0
}
const deleteChatMessage = async ({ chatId, messageId }) => {
await apiDeleteChatMessage({
chatId,
messageId,
credentials: useOAuthStore().token,
})
messages.value = messages.value.filter((m) => m.id !== messageId)
messagesIndex.value.delete(messageId)
if (maxId.value === messageId) {
const lastMessage = maxBy(messages.value, 'id')
maxId.value = lastMessage?.id
}
if (minId.value === messageId) {
const firstMessage = minBy(messages.value, 'id')
minId.value = firstMessage?.id
}
}
const fetchError = ref(null)
const fetchChat = async ({ older = false, latest = false }) => {
if (!older && streamingEnabled.value) {
return
}
try {
const { data: messages } = await chatMessages({
id: chat.value.id,
maxId: older ? minId.value : null,
sinceId: older ? null : maxId.value,
credentials: useOAuthStore().token,
})
addMessages(messages, true)
fetchError.value = null
} catch (e) {
console.error('Error fetching chat', e)
fetchError.value = e
}
}
const fetchOlder = () => {
fetchChat({ older: true })
}
// # Message list forming
const addMessages = (newMessages, prepend = false) => {
for (let message of newMessages) {
// Chats we're getting from streaming might belong to someone else instead
if (message.chat_id !== chat.value.id) {
- return
+ continue
}
// Clear any known pending messages
if (message.idempotency_key) {
if (pendingMessagesIndex.value.has(message.idempotency_key)) {
pendingMessagesIndex.value.delete(message.idempotency_key)
pendingMessages.value = pendingMessages.value.filter(
({ idempotency_key }) =>
idempotency_key !== message.idempotency_key,
)
}
}
if (!minId.value || (!message.pending && message.id < minId.value)) {
minId.value = message.id
}
if (!maxId.value || message.id > maxId.value) {
maxId.value = message.id
}
const isConfirmation = (message) => {
if (!message.idempotency_key) return
return idempotencyKeyIndex.value.has(message.idempotency_key)
}
if (!messagesIndex.value.has(message.id) && !isConfirmation(message)) {
if (lastReadMessageId.value < message.id) {
newMessagesCount.value++
}
messagesIndex.value.set(message.id, message)
if (prepend) {
messages.value.unshift(messagesIndex.value.get(message.id))
} else {
messages.value.push(messagesIndex.value.get(message.id))
}
idempotencyKeyIndex.value.set(message.idempotency_key, true)
}
}
}
const clear = () => {
messages.value = messages.value.filter((m) => m.error)
messagesIndex.value = messages.value.reduce((acc, m) => {
acc.set(m.id, m)
return acc
}, new Map())
newMessagesCount.value = 0
lastReadMessageId.value = null
minId.value = undefined
maxId.value = undefined
messageRetriers.value.values().forEach((timeout) => clearTimeout(timeout))
}
const attachSocket = () => {
const et = new EventTarget()
const newSocket = {
name: 'chatview',
et,
}
et.addEventListener('pleroma:chat_update', onChatUpdate)
et.addEventListener('open', onStreamConnect)
et.addEventListener('close', onStreamDisconnect)
socket.value = newSocket
useStreamingStore().addSubscriber(socket.value)
}
const detachSocket = () => {
const { et } = socket.value
et.removeEventListener('pleroma:chat_update', onChatUpdate)
et.removeEventListener('open', onStreamConnect)
et.removeEventListener('close', onStreamDisconnect)
useStreamingStore().removeSubscriber(socket.value)
}
const activate = async () => {
try {
attachSocket()
const result = await getOrCreateChat({
accountId: userId.value,
credentials: useOAuthStore().token,
})
const { data } = result
newMessagesCount.value = data.unread
useUsersStore().addNewUsers({ ...result, data: data.account })
data.account = useUsersStore().findUser(data.account.id)
chat.value = data
startFetching('Chat activated', true)
} catch (e) {
console.error('Error creating or getting a chat', e)
fetchError.value = e
}
}
const deactivate = () => {
if (fetching.value) {
stopFetching('Chat deactivated')
}
clear()
detachSocket()
}
const ready = computed(() => !!chat.value)
const recipient = computed(() => chat.value?.account)
return {
activate,
deactivate,
sendMessage,
newMessagesCount,
markAsRead,
deleteChatMessage,
messages,
pendingMessages,
fetchError,
fetchOlder,
ready,
recipient,
_test: {
chat,
addMessages,
minId,
maxId,
lastReadMessageId,
},
}
}
diff --git a/src/composables/useWindowScroll.js b/src/composables/useWindowScroll.js
index 1f2b8bd9d0..1746596b02 100644
--- a/src/composables/useWindowScroll.js
+++ b/src/composables/useWindowScroll.js
@@ -1,24 +1,24 @@
import { onMounted, onUnmounted, ref } from 'vue'
export function useWindowScroll() {
const x = ref(0)
const y = ref(0)
const update = (e) => {
x.value = window.scrollX
y.value = window.scrollY
}
onMounted(() => {
window.addEventListener('scroll', update)
update()
})
onUnmounted(() => {
window.removeEventListener('scroll', update)
})
- const scrollBy = window.scrollBy
- const scrollTo = window.scrollTo
+ const scrollBy = (...args) => window.scrollBy(...args)
+ const scrollTo = (...args) => window.scrollTo(...args)
return { x, y, scrollBy, scrollTo }
}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Oct 11, 8:21 AM (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1785672
Default Alt Text
(26 KB)

Event Timeline