Threads - first iteration (#5165)
* Initial threads support: parse `ThreadSummary`. Replace several `isThreaded` values with `EventThreadInfo`, which contains the info about the event either being the root of a thread or part of it. * Add `Threaded` timeline mode * Add a `liveTimeline` parameter to `TimelineController`'s constructor. This way we can customise which timeline will be used as the 'live' one. Also add `@LiveTimeline` DI qualifier for the actual live timeline of the room. * Create `ThreadedMessagesNode`. Allow opening a thread in a separate screen. * Add the callbacks for the list menu actions - even if they're the wrong ones and will send the data to the room instead * Send attachments and location in threads * Fix polls in threads, add support for sending voice messages in threads * Display thread summaries only when the feature flag is enabled * Use 'Reply' instead of 'Reply in thread' when in threaded timeline mode * Remove incorrect usage of `Timeline` in `MessageComposerPresenter`. This led to replies to threaded events not appearing as actual replies. --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
cc10ba41fd
commit
35928e3630
119 changed files with 1520 additions and 339 deletions
|
|
@ -14,6 +14,7 @@ import io.element.android.libraries.core.coroutine.childScope
|
|||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
|
|
@ -133,6 +134,7 @@ class RustMatrixClient(
|
|||
baseCacheDirectory: File,
|
||||
clock: SystemClock,
|
||||
timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : MatrixClient {
|
||||
override val sessionId: UserId = UserId(innerClient.userId())
|
||||
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
|
||||
|
|
@ -203,6 +205,7 @@ class RustMatrixClient(
|
|||
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
|
||||
roomMembershipObserver = roomMembershipObserver,
|
||||
roomInfoMapper = roomInfoMapper,
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
|
||||
override val mediaLoader: MatrixMediaLoader = RustMediaLoader(
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
baseCacheDirectory = cacheDirectory,
|
||||
clock = clock,
|
||||
timelineEventTypeFilterFactory = timelineEventTypeFilterFactory,
|
||||
featureFlagService = featureFlagService,
|
||||
).also {
|
||||
Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
|
||||
}
|
||||
|
|
@ -131,6 +132,7 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
)
|
||||
)
|
||||
.enableShareHistoryOnInvite(featureFlagService.isFeatureEnabled(FeatureFlags.EnableKeyShareOnInvite))
|
||||
.threadsEnabled(featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents), threadSubscriptions = false)
|
||||
.run {
|
||||
// Apply sliding sync version settings
|
||||
when (slidingSyncType) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
|||
import io.element.android.libraries.core.coroutine.childScope
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomAlias
|
||||
|
|
@ -83,6 +85,7 @@ class JoinedRustRoom(
|
|||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
private val systemClock: SystemClock,
|
||||
private val roomContentForwarder: RoomContentForwarder,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) : JoinedRoom, BaseRoom by baseRoom {
|
||||
// Create a dispatcher for all room methods...
|
||||
private val roomDispatcher = coroutineDispatchers.io.limitedParallelism(32)
|
||||
|
|
@ -132,7 +135,7 @@ class JoinedRustRoom(
|
|||
|
||||
override val roomNotificationSettingsStateFlow = MutableStateFlow<RoomNotificationSettingsState>(RoomNotificationSettingsState.Unknown)
|
||||
|
||||
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.LIVE) {
|
||||
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live) {
|
||||
syncUpdateFlow.value = systemClock.epochMillis()
|
||||
}
|
||||
|
||||
|
|
@ -153,22 +156,27 @@ class JoinedRustRoom(
|
|||
override suspend fun createTimeline(
|
||||
createTimelineParams: CreateTimelineParams,
|
||||
): Result<Timeline> = withContext(roomDispatcher) {
|
||||
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
|
||||
val focus = when (createTimelineParams) {
|
||||
is CreateTimelineParams.PinnedOnly -> TimelineFocus.PinnedEvents(
|
||||
maxEventsToLoad = 100u,
|
||||
maxConcurrentRequests = 10u,
|
||||
)
|
||||
is CreateTimelineParams.MediaOnly -> TimelineFocus.Live(hideThreadedEvents = false)
|
||||
is CreateTimelineParams.MediaOnly -> TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents)
|
||||
is CreateTimelineParams.Focused -> TimelineFocus.Event(
|
||||
eventId = createTimelineParams.focusedEventId.value,
|
||||
numContextEvents = 50u,
|
||||
hideThreadedEvents = false,
|
||||
hideThreadedEvents = hideThreadedEvents,
|
||||
)
|
||||
is CreateTimelineParams.MediaOnlyFocused -> TimelineFocus.Event(
|
||||
eventId = createTimelineParams.focusedEventId.value,
|
||||
numContextEvents = 50u,
|
||||
// Never hide threaded events in media focused timeline
|
||||
hideThreadedEvents = false,
|
||||
)
|
||||
is CreateTimelineParams.Threaded -> TimelineFocus.Thread(
|
||||
rootEventId = createTimelineParams.threadRootEventId.value,
|
||||
)
|
||||
}
|
||||
|
||||
val filter = when (createTimelineParams) {
|
||||
|
|
@ -182,7 +190,8 @@ class JoinedRustRoom(
|
|||
)
|
||||
)
|
||||
is CreateTimelineParams.Focused,
|
||||
CreateTimelineParams.PinnedOnly -> TimelineFilter.All
|
||||
CreateTimelineParams.PinnedOnly,
|
||||
is CreateTimelineParams.Threaded -> TimelineFilter.All
|
||||
}
|
||||
|
||||
val internalIdPrefix = when (createTimelineParams) {
|
||||
|
|
@ -190,6 +199,7 @@ class JoinedRustRoom(
|
|||
is CreateTimelineParams.Focused -> "focus_${createTimelineParams.focusedEventId}"
|
||||
is CreateTimelineParams.MediaOnly -> "MediaGallery_"
|
||||
is CreateTimelineParams.MediaOnlyFocused -> "MediaGallery_${createTimelineParams.focusedEventId}"
|
||||
is CreateTimelineParams.Threaded -> "Thread_${createTimelineParams.threadRootEventId}"
|
||||
}
|
||||
|
||||
// Note that for TimelineFilter.MediaOnlyFocused, the date separator will be filtered out,
|
||||
|
|
@ -198,7 +208,8 @@ class JoinedRustRoom(
|
|||
is CreateTimelineParams.MediaOnly,
|
||||
is CreateTimelineParams.MediaOnlyFocused -> DateDividerMode.MONTHLY
|
||||
is CreateTimelineParams.Focused,
|
||||
CreateTimelineParams.PinnedOnly -> DateDividerMode.DAILY
|
||||
CreateTimelineParams.PinnedOnly,
|
||||
is CreateTimelineParams.Threaded -> DateDividerMode.DAILY
|
||||
}
|
||||
|
||||
// Track read receipts only for focused timeline for performance optimization
|
||||
|
|
@ -216,17 +227,19 @@ class JoinedRustRoom(
|
|||
)
|
||||
).let { innerTimeline ->
|
||||
val mode = when (createTimelineParams) {
|
||||
is CreateTimelineParams.Focused -> Timeline.Mode.FOCUSED_ON_EVENT
|
||||
is CreateTimelineParams.MediaOnly -> Timeline.Mode.MEDIA
|
||||
is CreateTimelineParams.MediaOnlyFocused -> Timeline.Mode.FOCUSED_ON_EVENT
|
||||
CreateTimelineParams.PinnedOnly -> Timeline.Mode.PINNED_EVENTS
|
||||
is CreateTimelineParams.Focused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId)
|
||||
is CreateTimelineParams.MediaOnly -> Timeline.Mode.Media
|
||||
is CreateTimelineParams.MediaOnlyFocused -> Timeline.Mode.FocusedOnEvent(createTimelineParams.focusedEventId)
|
||||
CreateTimelineParams.PinnedOnly -> Timeline.Mode.PinnedEvents
|
||||
is CreateTimelineParams.Threaded -> Timeline.Mode.Thread(createTimelineParams.threadRootEventId)
|
||||
}
|
||||
innerTimeline.map(mode = mode)
|
||||
}
|
||||
}.mapFailure {
|
||||
when (createTimelineParams) {
|
||||
is CreateTimelineParams.Focused,
|
||||
is CreateTimelineParams.MediaOnlyFocused -> it.toFocusEventException()
|
||||
is CreateTimelineParams.MediaOnlyFocused,
|
||||
is CreateTimelineParams.Threaded -> it.toFocusEventException()
|
||||
CreateTimelineParams.MediaOnly,
|
||||
CreateTimelineParams.PinnedOnly -> it
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ package io.element.android.libraries.matrix.impl.room
|
|||
|
||||
import io.element.android.appconfig.TimelineConfig
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
|
@ -48,6 +50,7 @@ class RustRoomFactory(
|
|||
private val innerRoomListService: InnerRoomListService,
|
||||
private val roomSyncSubscriber: RoomSyncSubscriber,
|
||||
private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val roomMembershipObserver: RoomMembershipObserver,
|
||||
private val roomInfoMapper: RoomInfoMapper,
|
||||
) {
|
||||
|
|
@ -105,10 +108,11 @@ class RustRoomFactory(
|
|||
val sdkRoom = awaitRoomInRoomList(roomId) ?: return@withContext null
|
||||
|
||||
if (sdkRoom.membership() == Membership.JOINED) {
|
||||
val hideThreadedEvents = featureFlagService.isFeatureEnabled(FeatureFlags.HideThreadedEvents)
|
||||
// Init the live timeline in the SDK from the Room
|
||||
val timeline = sdkRoom.timelineWithConfiguration(
|
||||
TimelineConfiguration(
|
||||
focus = TimelineFocus.Live(hideThreadedEvents = false),
|
||||
focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents),
|
||||
filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All,
|
||||
internalIdPrefix = "live",
|
||||
dateDividerMode = DateDividerMode.DAILY,
|
||||
|
|
@ -125,6 +129,7 @@ class RustRoomFactory(
|
|||
liveInnerTimeline = timeline,
|
||||
coroutineDispatchers = dispatchers,
|
||||
systemClock = systemClock,
|
||||
featureFlagService = featureFlagService,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ private const val PAGINATION_SIZE = 50
|
|||
|
||||
class RustTimeline(
|
||||
private val inner: InnerTimeline,
|
||||
mode: Timeline.Mode,
|
||||
override val mode: Timeline.Mode,
|
||||
systemClock: SystemClock,
|
||||
private val joinedRoom: JoinedRoom,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
|
|
@ -118,19 +118,20 @@ class RustTimeline(
|
|||
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
|
||||
|
||||
override val backwardPaginationStatus = MutableStateFlow(
|
||||
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PINNED_EVENTS)
|
||||
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PinnedEvents)
|
||||
)
|
||||
|
||||
override val forwardPaginationStatus = MutableStateFlow(
|
||||
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode == Timeline.Mode.FOCUSED_ON_EVENT)
|
||||
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode !is Timeline.Mode.FocusedOnEvent)
|
||||
)
|
||||
|
||||
init {
|
||||
if (mode != Timeline.Mode.PINNED_EVENTS) {
|
||||
coroutineScope.fetchMembers()
|
||||
when (mode) {
|
||||
is Timeline.Mode.Live, is Timeline.Mode.FocusedOnEvent -> coroutineScope.fetchMembers()
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
if (mode == Timeline.Mode.LIVE) {
|
||||
if (mode == Timeline.Mode.Live) {
|
||||
// When timeline is live, we need to listen to the back pagination status as
|
||||
// sdk can automatically paginate backwards.
|
||||
coroutineScope.registerBackPaginationStatusListener()
|
||||
|
|
@ -219,6 +220,7 @@ class RustTimeline(
|
|||
items = items,
|
||||
hasMoreToLoadBackward = backwardPaginationStatus.hasMoreToLoad,
|
||||
hasMoreToLoadForward = forwardPaginationStatus.hasMoreToLoad,
|
||||
timelineMode = mode,
|
||||
)
|
||||
}
|
||||
.let { items ->
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
package io.element.android.libraries.matrix.impl.timeline.item.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
|
||||
|
|
@ -37,14 +38,14 @@ private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery"
|
|||
class EventMessageMapper {
|
||||
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) }
|
||||
|
||||
fun map(message: MsgLikeKind.Message, inReplyTo: InReplyToDetails?, isThreaded: Boolean): MessageContent = message.use {
|
||||
fun map(message: MsgLikeKind.Message, inReplyTo: InReplyToDetails?, threadInfo: EventThreadInfo): MessageContent = message.use {
|
||||
val type = it.content.msgType.use(this::mapMessageType)
|
||||
val inReplyToEvent: InReplyTo? = inReplyTo?.use(inReplyToMapper::map)
|
||||
MessageContent(
|
||||
body = it.content.body,
|
||||
inReplyTo = inReplyToEvent,
|
||||
isEdited = it.content.isEdited,
|
||||
isThreaded = isThreaded,
|
||||
threadInfo = threadInfo,
|
||||
type = type
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.timeline.item.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.TransactionId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
import org.matrix.rustcomponents.sdk.EventOrTransactionId as RustEventOrTransactionId
|
||||
|
||||
fun RustEventOrTransactionId.map(): EventOrTransactionId = when (this) {
|
||||
is RustEventOrTransactionId.EventId -> EventOrTransactionId.Event(EventId(eventId))
|
||||
is RustEventOrTransactionId.TransactionId -> EventOrTransactionId.Transaction(TransactionId(transactionId))
|
||||
}
|
||||
|
|
@ -7,7 +7,12 @@
|
|||
|
||||
package io.element.android.libraries.matrix.impl.timeline.item.event
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.ThreadId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EmbeddedEventInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo
|
||||
import io.element.android.libraries.matrix.api.timeline.item.ThreadSummary
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
|
||||
|
|
@ -27,6 +32,7 @@ import io.element.android.libraries.matrix.impl.media.map
|
|||
import io.element.android.libraries.matrix.impl.poll.map
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import org.matrix.rustcomponents.sdk.EmbeddedEventDetails
|
||||
import org.matrix.rustcomponents.sdk.MsgLikeKind
|
||||
import org.matrix.rustcomponents.sdk.TimelineItemContent
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
|
|
@ -59,8 +65,35 @@ class TimelineEventContentMapper(
|
|||
when (val kind = it.content.kind) {
|
||||
is MsgLikeKind.Message -> {
|
||||
val inReplyTo = it.content.inReplyTo
|
||||
val isThreaded = it.content.threadRoot != null
|
||||
eventMessageMapper.map(kind, inReplyTo, isThreaded)
|
||||
val threadSummary = it.content.threadSummary?.use { summary ->
|
||||
val numberOfReplies = summary.numReplies().toLong()
|
||||
val latestEvent = summary.latestEvent()
|
||||
val details = when (latestEvent) {
|
||||
is EmbeddedEventDetails.Unavailable -> AsyncData.Uninitialized
|
||||
is EmbeddedEventDetails.Pending -> AsyncData.Loading()
|
||||
is EmbeddedEventDetails.Error -> AsyncData.Failure(IllegalStateException(latestEvent.message))
|
||||
is EmbeddedEventDetails.Ready -> {
|
||||
AsyncData.Success(
|
||||
EmbeddedEventInfo(
|
||||
eventOrTransactionId = latestEvent.eventOrTransactionId.map(),
|
||||
content = map(latestEvent.content),
|
||||
senderId = UserId(latestEvent.sender),
|
||||
senderProfile = latestEvent.senderProfile.map(),
|
||||
timestamp = latestEvent.timestamp.toLong()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
ThreadSummary(
|
||||
latestEvent = details,
|
||||
numberOfReplies = numberOfReplies,
|
||||
)
|
||||
}
|
||||
val threadInfo = EventThreadInfo(
|
||||
threadRootId = it.content.threadRoot?.let(::ThreadId),
|
||||
threadSummary = threadSummary,
|
||||
)
|
||||
eventMessageMapper.map(kind, inReplyTo, threadInfo)
|
||||
}
|
||||
is MsgLikeKind.Redacted -> {
|
||||
RedactedContent
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class LastForwardIndicatorsPostProcessor(
|
|||
items: List<MatrixTimelineItem>,
|
||||
): List<MatrixTimelineItem> {
|
||||
// We don't need to add the last forward indicator if we are not in the FOCUSED_ON_EVENT mode
|
||||
if (mode != Timeline.Mode.FOCUSED_ON_EVENT) {
|
||||
if (mode !is Timeline.Mode.FocusedOnEvent) {
|
||||
return items
|
||||
} else {
|
||||
return buildList {
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ class LoadingIndicatorsPostProcessor(private val systemClock: SystemClock) {
|
|||
items: List<MatrixTimelineItem>,
|
||||
hasMoreToLoadBackward: Boolean,
|
||||
hasMoreToLoadForward: Boolean,
|
||||
timelineMode: Timeline.Mode,
|
||||
): List<MatrixTimelineItem> {
|
||||
val shouldAddForwardLoadingIndicator = hasMoreToLoadForward && items.isNotEmpty()
|
||||
val shouldAddForwardLoadingIndicator = timelineMode is Timeline.Mode.Live && hasMoreToLoadForward && items.isNotEmpty()
|
||||
val currentTimestamp = systemClock.epochMillis()
|
||||
return buildList {
|
||||
if (hasMoreToLoadBackward) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
|
|||
): List<MatrixTimelineItem> {
|
||||
return when {
|
||||
items.isEmpty() -> items
|
||||
mode == Timeline.Mode.PINNED_EVENTS -> items
|
||||
mode == Timeline.Mode.PinnedEvents -> items
|
||||
isDm -> processForDM(items, roomCreator)
|
||||
hasMoreToLoadBackwards -> items
|
||||
else -> processForRoom(items)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime
|
|||
*/
|
||||
class TypingNotificationPostProcessor(private val mode: Timeline.Mode) {
|
||||
fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||
return if (mode == Timeline.Mode.LIVE) {
|
||||
return if (mode is Timeline.Mode.Live) {
|
||||
buildList {
|
||||
addAll(items)
|
||||
add(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue