Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85805340
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
26 KB
Referenced Files
None
Subscribers
None
View Options
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
Details
Attached
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)
Attached To
Mode
rPUFE pleroma-fe-upstream
Attached
Detach File
Event Timeline
Log In to Comment