mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 20:29:53 +00:00
ios: go to forwarded item or search result (#5679)
* ios: go to forwarded item or search result * react on touch * changes
This commit is contained in:
parent
b482d4d812
commit
fefddb3b5a
11 changed files with 228 additions and 64 deletions
|
@ -97,6 +97,22 @@ class ItemsModel: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) {
|
||||||
|
navigationTimeoutTask?.cancel()
|
||||||
|
loadChatTask?.cancel()
|
||||||
|
loadChatTask = Task {
|
||||||
|
// try? await Task.sleep(nanoseconds: 1000_000000)
|
||||||
|
await loadChat(chatId: chatId, openAroundItemId: openAroundItemId, clearItems: openAroundItemId == nil)
|
||||||
|
if !Task.isCancelled {
|
||||||
|
await MainActor.run {
|
||||||
|
if openAroundItemId == nil {
|
||||||
|
ChatModel.shared.chatId = chatId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChatTagsModel: ObservableObject {
|
class ChatTagsModel: ObservableObject {
|
||||||
|
@ -259,6 +275,7 @@ final class ChatModel: ObservableObject {
|
||||||
@Published var deletedChats: Set<String> = []
|
@Published var deletedChats: Set<String> = []
|
||||||
// current chat
|
// current chat
|
||||||
@Published var chatId: String?
|
@Published var chatId: String?
|
||||||
|
@Published var openAroundItemId: ChatItem.ID? = nil
|
||||||
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
|
||||||
@Published var chatToTop: String?
|
@Published var chatToTop: String?
|
||||||
@Published var groupMembers: [GMember] = []
|
@Published var groupMembers: [GMember] = []
|
||||||
|
|
|
@ -338,7 +338,7 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||||
await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems)
|
await loadChat(chatId: chat.chatInfo.id, search: search, clearItems: clearItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) async {
|
func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID? = nil, clearItems: Bool = true) async {
|
||||||
let m = ChatModel.shared
|
let m = ChatModel.shared
|
||||||
let im = ItemsModel.shared
|
let im = ItemsModel.shared
|
||||||
m.chatItemStatuses = [:]
|
m.chatItemStatuses = [:]
|
||||||
|
@ -348,7 +348,7 @@ func loadChat(chatId: ChatId, search: String = "", clearItems: Bool = true) asyn
|
||||||
ItemsModel.shared.chatItemsChangesListener.cleared()
|
ItemsModel.shared.chatItemsChangesListener.cleared()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await apiLoadMessages(chatId, search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage), im.chatState, search, { 0...0 })
|
await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
|
func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws -> ChatItemInfo {
|
||||||
|
|
|
@ -16,6 +16,7 @@ func apiLoadMessages(
|
||||||
_ pagination: ChatPagination,
|
_ pagination: ChatPagination,
|
||||||
_ chatState: ActiveChatState,
|
_ chatState: ActiveChatState,
|
||||||
_ search: String = "",
|
_ search: String = "",
|
||||||
|
_ openAroundItemId: ChatItem.ID? = nil,
|
||||||
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
|
_ visibleItemIndexesNonReversed: @MainActor () -> ClosedRange<Int> = { 0 ... 0 }
|
||||||
) async {
|
) async {
|
||||||
let chat: Chat
|
let chat: Chat
|
||||||
|
@ -32,7 +33,8 @@ func apiLoadMessages(
|
||||||
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
|
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
|
||||||
let paginationIsInitial = switch pagination { case .initial: true; default: false }
|
let paginationIsInitial = switch pagination { case .initial: true; default: false }
|
||||||
let paginationIsLast = switch pagination { case .last: true; default: false }
|
let paginationIsLast = switch pagination { case .last: true; default: false }
|
||||||
if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast) || Task.isCancelled {
|
// When openAroundItemId is provided, chatId can be different too
|
||||||
|
if ((chatModel.chatId != chat.id || chat.chatItems.isEmpty) && !paginationIsInitial && !paginationIsLast && openAroundItemId == nil) || Task.isCancelled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,8 +104,13 @@ func apiLoadMessages(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .around:
|
case .around:
|
||||||
newItems.append(contentsOf: oldItems)
|
let newSplits: [Int64]
|
||||||
let newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
|
if openAroundItemId == nil {
|
||||||
|
newItems.append(contentsOf: oldItems)
|
||||||
|
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
|
||||||
|
} else {
|
||||||
|
newSplits = []
|
||||||
|
}
|
||||||
// currently, items will always be added on top, which is index 0
|
// currently, items will always be added on top, which is index 0
|
||||||
newItems.insert(contentsOf: chat.chatItems, at: 0)
|
newItems.insert(contentsOf: chat.chatItems, at: 0)
|
||||||
let newReversed: [ChatItem] = newItems.reversed()
|
let newReversed: [ChatItem] = newItems.reversed()
|
||||||
|
@ -114,8 +121,15 @@ func apiLoadMessages(
|
||||||
chatState.totalAfter = navInfo.afterTotal
|
chatState.totalAfter = navInfo.afterTotal
|
||||||
chatState.unreadTotal = chat.chatStats.unreadCount
|
chatState.unreadTotal = chat.chatStats.unreadCount
|
||||||
chatState.unreadAfter = navInfo.afterUnread
|
chatState.unreadAfter = navInfo.afterUnread
|
||||||
// no need to set it, count will be wrong
|
|
||||||
// unreadAfterNewestLoaded.value = navInfo.afterUnread
|
if let openAroundItemId {
|
||||||
|
chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||||
|
ChatModel.shared.openAroundItemId = openAroundItemId
|
||||||
|
ChatModel.shared.chatId = chatId
|
||||||
|
} else {
|
||||||
|
// no need to set it, count will be wrong
|
||||||
|
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .last:
|
case .last:
|
||||||
newItems.append(contentsOf: oldItems)
|
newItems.append(contentsOf: oldItems)
|
||||||
|
|
|
@ -46,7 +46,7 @@ func preloadIfNeeded(
|
||||||
loadItems: @escaping (Bool, ChatPagination) async -> Bool
|
loadItems: @escaping (Bool, ChatPagination) async -> Bool
|
||||||
) {
|
) {
|
||||||
let state = PreloadState.shared
|
let state = PreloadState.shared
|
||||||
guard !listState.isScrolling,
|
guard !listState.isScrolling && !listState.isAnimatedScrolling,
|
||||||
state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count,
|
state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count,
|
||||||
!state.preloading,
|
!state.preloading,
|
||||||
listState.totalItemsCount > 0
|
listState.totalItemsCount > 0
|
||||||
|
|
|
@ -29,6 +29,7 @@ struct ChatView: View {
|
||||||
@State private var composeState = ComposeState()
|
@State private var composeState = ComposeState()
|
||||||
@State private var selectedRange = NSRange()
|
@State private var selectedRange = NSRange()
|
||||||
@State private var keyboardVisible = false
|
@State private var keyboardVisible = false
|
||||||
|
@State private var keyboardHiddenDate = Date.now
|
||||||
@State private var connectionStats: ConnectionStats?
|
@State private var connectionStats: ConnectionStats?
|
||||||
@State private var customUserProfile: Profile?
|
@State private var customUserProfile: Profile?
|
||||||
@State private var connectionCode: String?
|
@State private var connectionCode: String?
|
||||||
|
@ -37,7 +38,7 @@ struct ChatView: View {
|
||||||
@State private var requestedTopScroll = false
|
@State private var requestedTopScroll = false
|
||||||
@State private var loadingBottomItems = false
|
@State private var loadingBottomItems = false
|
||||||
@State private var requestedBottomScroll = false
|
@State private var requestedBottomScroll = false
|
||||||
@State private var searchMode = false
|
@State private var showSearch = false
|
||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
@FocusState private var searchFocussed
|
@FocusState private var searchFocussed
|
||||||
// opening GroupMemberInfoView on member icon
|
// opening GroupMemberInfoView on member icon
|
||||||
|
@ -54,11 +55,8 @@ struct ChatView: View {
|
||||||
@State private var allowLoadMoreItems: Bool = false
|
@State private var allowLoadMoreItems: Bool = false
|
||||||
@State private var ignoreLoadingRequests: Int64? = nil
|
@State private var ignoreLoadingRequests: Int64? = nil
|
||||||
@State private var animatedScrollingInProgress: Bool = false
|
@State private var animatedScrollingInProgress: Bool = false
|
||||||
@State private var updateMergedItemsTask: Task<Void, Never>? = nil
|
|
||||||
@State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel()
|
@State private var floatingButtonModel: FloatingButtonModel = FloatingButtonModel()
|
||||||
|
|
||||||
private let useItemsUpdateTask = false
|
|
||||||
|
|
||||||
@State private var scrollView: EndlessScrollView<MergedItem> = EndlessScrollView(frame: .zero)
|
@State private var scrollView: EndlessScrollView<MergedItem> = EndlessScrollView(frame: .zero)
|
||||||
|
|
||||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||||
|
@ -101,6 +99,7 @@ struct ChatView: View {
|
||||||
chat: chat,
|
chat: chat,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
keyboardVisible: $keyboardVisible,
|
keyboardVisible: $keyboardVisible,
|
||||||
|
keyboardHiddenDate: $keyboardHiddenDate,
|
||||||
selectedRange: $selectedRange
|
selectedRange: $selectedRange
|
||||||
)
|
)
|
||||||
.disabled(!cInfo.sendMsgEnabled)
|
.disabled(!cInfo.sendMsgEnabled)
|
||||||
|
@ -131,7 +130,7 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .top) {
|
.safeAreaInset(edge: .top) {
|
||||||
VStack(spacing: .zero) {
|
VStack(spacing: .zero) {
|
||||||
if searchMode { searchToolbar() }
|
if showSearch { searchToolbar() }
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
.background(ToolbarMaterial.material(toolbarMaterial))
|
.background(ToolbarMaterial.material(toolbarMaterial))
|
||||||
|
@ -232,15 +231,45 @@ struct ChatView: View {
|
||||||
scrollView.listState.onUpdateListener = onChatItemsUpdated
|
scrollView.listState.onUpdateListener = onChatItemsUpdated
|
||||||
initChatView()
|
initChatView()
|
||||||
theme = buildTheme()
|
theme = buildTheme()
|
||||||
if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) {
|
closeSearch()
|
||||||
|
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
|
||||||
|
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||||
|
|
||||||
|
if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] {
|
||||||
|
scrollView.scrollToItem(index)
|
||||||
|
} else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) {
|
||||||
scrollView.scrollToItem(unreadIndex)
|
scrollView.scrollToItem(unreadIndex)
|
||||||
} else {
|
} else {
|
||||||
scrollView.scrollToBottom()
|
scrollView.scrollToBottom()
|
||||||
}
|
}
|
||||||
|
if chatModel.openAroundItemId != nil {
|
||||||
|
chatModel.openAroundItemId = nil
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: chatModel.openAroundItemId) { openAround in
|
||||||
|
if let openAround {
|
||||||
|
closeSearch()
|
||||||
|
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
|
||||||
|
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||||
|
chatModel.openAroundItemId = nil
|
||||||
|
|
||||||
|
if let index = mergedItems.boxedValue.indexInParentItems[openAround] {
|
||||||
|
scrollView.scrollToItem(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// this may already being loading because of changed chat id (see .onChange(of: chat.id)
|
||||||
|
if !loadingBottomItems {
|
||||||
|
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
|
||||||
|
allowLoadMoreItems = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
allowLoadMoreItems = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
VideoPlayerView.players.removeAll()
|
VideoPlayerView.players.removeAll()
|
||||||
stopAudioPlayer()
|
stopAudioPlayer()
|
||||||
|
@ -429,9 +458,13 @@ struct ChatView: View {
|
||||||
index = mergedItems.boxedValue.indexInParentItems[itemId]
|
index = mergedItems.boxedValue.indexInParentItems[itemId]
|
||||||
}
|
}
|
||||||
if let index {
|
if let index {
|
||||||
await MainActor.run { animatedScrollingInProgress = true }
|
closeKeyboardAndRun {
|
||||||
await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index))
|
Task {
|
||||||
await MainActor.run { animatedScrollingInProgress = false }
|
await MainActor.run { animatedScrollingInProgress = true }
|
||||||
|
await scrollView.scrollToItemAnimated(min(ItemsModel.shared.reversedChatItems.count - 1, index))
|
||||||
|
await MainActor.run { animatedScrollingInProgress = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
logger.error("Error scrolling to item: \(error)")
|
logger.error("Error scrolling to item: \(error)")
|
||||||
|
@ -460,9 +493,7 @@ struct ChatView: View {
|
||||||
.cornerRadius(10.0)
|
.cornerRadius(10.0)
|
||||||
|
|
||||||
Button ("Cancel") {
|
Button ("Cancel") {
|
||||||
searchText = ""
|
closeSearch()
|
||||||
searchMode = false
|
|
||||||
searchFocussed = false
|
|
||||||
Task { await loadChat(chat: chat) }
|
Task { await loadChat(chat: chat) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -517,7 +548,9 @@ struct ChatView: View {
|
||||||
showChatInfoSheet: $showChatInfoSheet,
|
showChatInfoSheet: $showChatInfoSheet,
|
||||||
revealedItems: $revealedItems,
|
revealedItems: $revealedItems,
|
||||||
selectedChatItems: $selectedChatItems,
|
selectedChatItems: $selectedChatItems,
|
||||||
forwardedChatItems: $forwardedChatItems
|
forwardedChatItems: $forwardedChatItems,
|
||||||
|
searchText: $searchText,
|
||||||
|
closeKeyboardAndRun: closeKeyboardAndRun
|
||||||
)
|
)
|
||||||
// crashes on Cell size calculation without this line
|
// crashes on Cell size calculation without this line
|
||||||
.environmentObject(ChatModel.shared)
|
.environmentObject(ChatModel.shared)
|
||||||
|
@ -535,25 +568,10 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: im.reversedChatItems) { items in
|
.onChange(of: im.reversedChatItems) { items in
|
||||||
updateMergedItemsTask?.cancel()
|
mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
|
||||||
if useItemsUpdateTask {
|
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||||
updateMergedItemsTask = Task {
|
|
||||||
let items = MergedItems.create(items, revealedItems, im.chatState)
|
|
||||||
if Task.isCancelled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await MainActor.run {
|
|
||||||
mergedItems.boxedValue = items
|
|
||||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
|
|
||||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onChange(of: revealedItems) { revealed in
|
.onChange(of: revealedItems) { revealed in
|
||||||
updateMergedItemsTask?.cancel()
|
|
||||||
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState)
|
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState)
|
||||||
scrollView.updateItems(mergedItems.boxedValue.items)
|
scrollView.updateItems(mergedItems.boxedValue.items)
|
||||||
}
|
}
|
||||||
|
@ -567,6 +585,7 @@ struct ChatView: View {
|
||||||
.padding(.vertical, -100)
|
.padding(.vertical, -100)
|
||||||
.onTapGesture { hideKeyboard() }
|
.onTapGesture { hideKeyboard() }
|
||||||
.onChange(of: searchText) { s in
|
.onChange(of: searchText) { s in
|
||||||
|
guard showSearch else { return }
|
||||||
Task {
|
Task {
|
||||||
await loadChat(chat: chat, search: s)
|
await loadChat(chat: chat, search: s)
|
||||||
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
|
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
|
||||||
|
@ -880,11 +899,29 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func focusSearch() {
|
private func focusSearch() {
|
||||||
searchMode = true
|
showSearch = true
|
||||||
searchFocussed = true
|
searchFocussed = true
|
||||||
searchText = ""
|
searchText = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func closeSearch() {
|
||||||
|
showSearch = false
|
||||||
|
searchText = ""
|
||||||
|
searchFocussed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeKeyboardAndRun(_ action: @escaping () -> Void) {
|
||||||
|
var delay: TimeInterval = 0
|
||||||
|
if keyboardVisible || keyboardHiddenDate.timeIntervalSinceNow >= -1 || showSearch {
|
||||||
|
delay = 0.5
|
||||||
|
closeSearch()
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func addMembersButton() -> some View {
|
private func addMembersButton() -> some View {
|
||||||
Button {
|
Button {
|
||||||
if case let .group(gInfo) = chat.chatInfo {
|
if case let .group(gInfo) = chat.chatInfo {
|
||||||
|
@ -1079,6 +1116,7 @@ struct ChatView: View {
|
||||||
pagination,
|
pagination,
|
||||||
im.chatState,
|
im.chatState,
|
||||||
searchText,
|
searchText,
|
||||||
|
nil,
|
||||||
{ visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) }
|
{ visibleItemIndexesNonReversed(scrollView.listState, mergedItems.boxedValue) }
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
|
@ -1136,10 +1174,14 @@ struct ChatView: View {
|
||||||
@State private var showChatItemInfoSheet: Bool = false
|
@State private var showChatItemInfoSheet: Bool = false
|
||||||
@State private var chatItemInfo: ChatItemInfo?
|
@State private var chatItemInfo: ChatItemInfo?
|
||||||
@State private var msgWidth: CGFloat = 0
|
@State private var msgWidth: CGFloat = 0
|
||||||
|
@State private var touchInProgress: Bool = false
|
||||||
|
|
||||||
@Binding var selectedChatItems: Set<Int64>?
|
@Binding var selectedChatItems: Set<Int64>?
|
||||||
@Binding var forwardedChatItems: [ChatItem]
|
@Binding var forwardedChatItems: [ChatItem]
|
||||||
|
|
||||||
|
@Binding var searchText: String
|
||||||
|
var closeKeyboardAndRun: (@escaping () -> Void) -> Void
|
||||||
|
|
||||||
@State private var allowMenu: Bool = true
|
@State private var allowMenu: Bool = true
|
||||||
@State private var markedRead = false
|
@State private var markedRead = false
|
||||||
@State private var markReadTask: Task<Void, Never>? = nil
|
@State private var markReadTask: Task<Void, Never>? = nil
|
||||||
|
@ -1257,6 +1299,16 @@ struct ChatView: View {
|
||||||
markedRead = false
|
markedRead = false
|
||||||
}
|
}
|
||||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||||
|
// skip updating struct on touch if no need to show GoTo button
|
||||||
|
.if(touchInProgress || searchIsNotBlank || (chatItem.meta.itemForwarded != nil && chatItem.meta.itemForwarded != .unknown)) {
|
||||||
|
// long press listener steals taps from top-level listener, so repeating it's logic here as well
|
||||||
|
$0.onTapGesture {
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
.onLongPressGesture(minimumDuration: .infinity, perform: {}, onPressingChanged: { pressing in
|
||||||
|
touchInProgress = pressing
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func unreadItemIds(_ range: ClosedRange<Int>) -> ([ChatItem.ID], Int) {
|
private func unreadItemIds(_ range: ClosedRange<Int>) -> ([ChatItem.ID], Int) {
|
||||||
|
@ -1290,6 +1342,11 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var searchIsNotBlank: Bool {
|
||||||
|
get {
|
||||||
|
searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@available(iOS 16.0, *)
|
@available(iOS 16.0, *)
|
||||||
struct MemberLayout: Layout {
|
struct MemberLayout: Layout {
|
||||||
|
@ -1459,18 +1516,26 @@ struct ChatView: View {
|
||||||
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
|
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
|
||||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||||
VStack(alignment: alignment.horizontal, spacing: 3) {
|
VStack(alignment: alignment.horizontal, spacing: 3) {
|
||||||
ChatItemView(
|
HStack {
|
||||||
chat: chat,
|
if ci.chatDir.sent {
|
||||||
chatItem: ci,
|
goToItemButton(true)
|
||||||
scrollToItemId: scrollToItemId,
|
}
|
||||||
maxWidth: maxWidth,
|
ChatItemView(
|
||||||
allowMenu: $allowMenu
|
chat: chat,
|
||||||
)
|
chatItem: ci,
|
||||||
.environment(\.revealed, revealed)
|
scrollToItemId: scrollToItemId,
|
||||||
.environment(\.showTimestamp, itemSeparation.timestamp)
|
maxWidth: maxWidth,
|
||||||
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
|
allowMenu: $allowMenu
|
||||||
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
|
)
|
||||||
.accessibilityLabel("")
|
.environment(\.revealed, revealed)
|
||||||
|
.environment(\.showTimestamp, itemSeparation.timestamp)
|
||||||
|
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
|
||||||
|
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
|
||||||
|
.accessibilityLabel("")
|
||||||
|
if !ci.chatDir.sent {
|
||||||
|
goToItemButton(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||||
chatItemReactions(ci)
|
chatItemReactions(ci)
|
||||||
.padding(.bottom, 4)
|
.padding(.bottom, 4)
|
||||||
|
@ -2133,6 +2198,37 @@ struct ChatView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View {
|
||||||
|
Button {
|
||||||
|
onClick()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: image)
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 13, height: 13)
|
||||||
|
.padding([alignStart ? .trailing : .leading], 10)
|
||||||
|
.tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func goToItemButton(_ alignStart: Bool) -> some View {
|
||||||
|
let chatTypeApiIdMsgId = chatItem.meta.itemForwarded?.chatTypeApiIdMsgId
|
||||||
|
if searchIsNotBlank {
|
||||||
|
goToItemInnerButton(alignStart, "magnifyingglass", touchInProgress: touchInProgress) {
|
||||||
|
closeKeyboardAndRun {
|
||||||
|
ItemsModel.shared.loadOpenChatNoWait(chat.id, chatItem.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let chatTypeApiIdMsgId {
|
||||||
|
goToItemInnerButton(alignStart, "arrow.right", touchInProgress: touchInProgress) {
|
||||||
|
closeKeyboardAndRun {
|
||||||
|
let (chatType, apiId, msgId) = chatTypeApiIdMsgId
|
||||||
|
ItemsModel.shared.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct SelectedChatItem: View {
|
private struct SelectedChatItem: View {
|
||||||
@EnvironmentObject var theme: AppTheme
|
@EnvironmentObject var theme: AppTheme
|
||||||
var ciId: Int64
|
var ciId: Int64
|
||||||
|
|
|
@ -325,6 +325,7 @@ struct ComposeView: View {
|
||||||
@ObservedObject var chat: Chat
|
@ObservedObject var chat: Chat
|
||||||
@Binding var composeState: ComposeState
|
@Binding var composeState: ComposeState
|
||||||
@Binding var keyboardVisible: Bool
|
@Binding var keyboardVisible: Bool
|
||||||
|
@Binding var keyboardHiddenDate: Date
|
||||||
@Binding var selectedRange: NSRange
|
@Binding var selectedRange: NSRange
|
||||||
|
|
||||||
@State var linkUrl: URL? = nil
|
@State var linkUrl: URL? = nil
|
||||||
|
@ -434,6 +435,7 @@ struct ComposeView: View {
|
||||||
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
|
timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages),
|
||||||
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }},
|
||||||
keyboardVisible: $keyboardVisible,
|
keyboardVisible: $keyboardVisible,
|
||||||
|
keyboardHiddenDate: $keyboardHiddenDate,
|
||||||
sendButtonColor: chat.chatInfo.incognito
|
sendButtonColor: chat.chatInfo.incognito
|
||||||
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
? .indigo.opacity(colorScheme == .dark ? 1 : 0.7)
|
||||||
: theme.colors.primary
|
: theme.colors.primary
|
||||||
|
@ -1280,6 +1282,7 @@ struct ComposeView_Previews: PreviewProvider {
|
||||||
chat: chat,
|
chat: chat,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
keyboardVisible: Binding.constant(true),
|
keyboardVisible: Binding.constant(true),
|
||||||
|
keyboardHiddenDate: Binding.constant(Date.now),
|
||||||
selectedRange: $selectedRange
|
selectedRange: $selectedRange
|
||||||
)
|
)
|
||||||
.environmentObject(ChatModel())
|
.environmentObject(ChatModel())
|
||||||
|
@ -1287,6 +1290,7 @@ struct ComposeView_Previews: PreviewProvider {
|
||||||
chat: chat,
|
chat: chat,
|
||||||
composeState: $composeState,
|
composeState: $composeState,
|
||||||
keyboardVisible: Binding.constant(true),
|
keyboardVisible: Binding.constant(true),
|
||||||
|
keyboardHiddenDate: Binding.constant(Date.now),
|
||||||
selectedRange: $selectedRange
|
selectedRange: $selectedRange
|
||||||
)
|
)
|
||||||
.environmentObject(ChatModel())
|
.environmentObject(ChatModel())
|
||||||
|
|
|
@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||||
@Binding var disableEditing: Bool
|
@Binding var disableEditing: Bool
|
||||||
@Binding var height: CGFloat
|
@Binding var height: CGFloat
|
||||||
@Binding var focused: Bool
|
@Binding var focused: Bool
|
||||||
|
@Binding var lastUnfocusedDate: Date
|
||||||
@Binding var placeholder: String?
|
@Binding var placeholder: String?
|
||||||
@Binding var selectedRange: NSRange
|
@Binding var selectedRange: NSRange
|
||||||
let onImagesAdded: ([UploadContent]) -> Void
|
let onImagesAdded: ([UploadContent]) -> Void
|
||||||
|
@ -42,7 +43,12 @@ struct NativeTextEditor: UIViewRepresentable {
|
||||||
onImagesAdded(images)
|
onImagesAdded(images)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
field.setOnFocusChangedListener { focused = $0 }
|
field.setOnFocusChangedListener {
|
||||||
|
focused = $0
|
||||||
|
if !focused {
|
||||||
|
lastUnfocusedDate = .now
|
||||||
|
}
|
||||||
|
}
|
||||||
field.delegate = field
|
field.delegate = field
|
||||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||||
field.setPlaceholderView()
|
field.setPlaceholderView()
|
||||||
|
@ -266,6 +272,7 @@ struct NativeTextEditor_Previews: PreviewProvider{
|
||||||
disableEditing: Binding.constant(false),
|
disableEditing: Binding.constant(false),
|
||||||
height: Binding.constant(100),
|
height: Binding.constant(100),
|
||||||
focused: Binding.constant(false),
|
focused: Binding.constant(false),
|
||||||
|
lastUnfocusedDate: Binding.constant(.now),
|
||||||
placeholder: Binding.constant("Placeholder"),
|
placeholder: Binding.constant("Placeholder"),
|
||||||
selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
|
selectedRange: Binding.constant(NSRange(location: 0, length: 0)),
|
||||||
onImagesAdded: { _ in }
|
onImagesAdded: { _ in }
|
||||||
|
|
|
@ -32,6 +32,7 @@ struct SendMessageView: View {
|
||||||
@State private var holdingVMR = false
|
@State private var holdingVMR = false
|
||||||
@Namespace var namespace
|
@Namespace var namespace
|
||||||
@Binding var keyboardVisible: Bool
|
@Binding var keyboardVisible: Bool
|
||||||
|
@Binding var keyboardHiddenDate: Date
|
||||||
var sendButtonColor = Color.accentColor
|
var sendButtonColor = Color.accentColor
|
||||||
@State private var teHeight: CGFloat = NativeTextEditor.minHeight
|
@State private var teHeight: CGFloat = NativeTextEditor.minHeight
|
||||||
@State private var teFont: Font = .body
|
@State private var teFont: Font = .body
|
||||||
|
@ -61,6 +62,7 @@ struct SendMessageView: View {
|
||||||
disableEditing: $composeState.inProgress,
|
disableEditing: $composeState.inProgress,
|
||||||
height: $teHeight,
|
height: $teHeight,
|
||||||
focused: $keyboardVisible,
|
focused: $keyboardVisible,
|
||||||
|
lastUnfocusedDate: $keyboardHiddenDate,
|
||||||
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
|
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
|
||||||
selectedRange: $selectedRange,
|
selectedRange: $selectedRange,
|
||||||
onImagesAdded: onMediaAdded
|
onImagesAdded: onMediaAdded
|
||||||
|
@ -441,7 +443,8 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||||
selectedRange: $selectedRange,
|
selectedRange: $selectedRange,
|
||||||
sendMessage: { _ in },
|
sendMessage: { _ in },
|
||||||
onMediaAdded: { _ in },
|
onMediaAdded: { _ in },
|
||||||
keyboardVisible: Binding.constant(true)
|
keyboardVisible: Binding.constant(true),
|
||||||
|
keyboardHiddenDate: Binding.constant(Date.now)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
VStack {
|
VStack {
|
||||||
|
@ -452,7 +455,8 @@ struct SendMessageView_Previews: PreviewProvider {
|
||||||
selectedRange: $selectedRangeEditing,
|
selectedRange: $selectedRangeEditing,
|
||||||
sendMessage: { _ in },
|
sendMessage: { _ in },
|
||||||
onMediaAdded: { _ in },
|
onMediaAdded: { _ in },
|
||||||
keyboardVisible: Binding.constant(true)
|
keyboardVisible: Binding.constant(true),
|
||||||
|
keyboardHiddenDate: Binding.constant(Date.now)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -171,8 +171,10 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
visibleItems.last?.index ?? 0
|
visibleItems.last?.index ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether there is scroll to item in progress or not
|
/// Whether there is a non-animated scroll to item in progress or not
|
||||||
var isScrolling: Bool = false
|
var isScrolling: Bool = false
|
||||||
|
/// Whether there is an animated scroll to item in progress or not
|
||||||
|
var isAnimatedScrolling: Bool = false
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
|
@ -349,11 +351,11 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
if // there is auto scroll in progress and the first item has a higher offset than bottom part
|
if // there is auto scroll in progress and the first item has a higher offset than bottom part
|
||||||
// of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to
|
// of the screen. In order to make scrolling down & up equal in time, we treat this as a sign to
|
||||||
// re-make the first visible item
|
// re-make the first visible item
|
||||||
(listState.isScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) ||
|
(listState.isAnimatedScrolling && vis.view.frame.origin.y + vis.view.bounds.height < contentOffsetY + bounds.height) ||
|
||||||
// the fist visible item previously is hidden now, remove it and move on
|
// the fist visible item previously is hidden now, remove it and move on
|
||||||
!isVisible(vis.view) {
|
!isVisible(vis.view) {
|
||||||
let newIndex: Int
|
let newIndex: Int
|
||||||
if listState.isScrolling {
|
if listState.isAnimatedScrolling {
|
||||||
// skip many items to make the scrolling take less time
|
// skip many items to make the scrolling take less time
|
||||||
var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0
|
var indexDiff = !alreadyChangedIndexWhileScrolling ? Int(ceil(abs(offsetsDiff / averageItemHeight))) : 0
|
||||||
// if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled
|
// if index was already changed, no need to change it again. Otherwise, the scroll will overscoll and return back animated. Because it means the whole screen was scrolled
|
||||||
|
@ -471,7 +473,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollToItem(_ index: Int, top: Bool = true) {
|
func scrollToItem(_ index: Int, top: Bool = true) {
|
||||||
if index >= listState.items.count || listState.isScrolling {
|
if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if bounds.height == 0 {
|
if bounds.height == 0 {
|
||||||
|
@ -498,7 +500,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
|
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
|
||||||
|
|
||||||
var stepSlowdownMultiplier: CGFloat = 1
|
var stepSlowdownMultiplier: CGFloat = 1
|
||||||
while true {
|
while i < 200 {
|
||||||
let up = index > listState.firstVisibleItemIndex
|
let up = index > listState.firstVisibleItemIndex
|
||||||
if upPrev != up {
|
if upPrev != up {
|
||||||
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
|
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
|
||||||
|
@ -522,18 +524,22 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
|
contentOffset = CGPointMake(contentOffset.x, adjustedOffset)
|
||||||
|
adaptItems(listState.items, false)
|
||||||
|
snapToContent(animated: false)
|
||||||
i += 1
|
i += 1
|
||||||
}
|
}
|
||||||
|
adaptItems(listState.items, false)
|
||||||
|
snapToContent(animated: false)
|
||||||
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
|
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scrollToItemAnimated(_ index: Int, top: Bool = true) async {
|
func scrollToItemAnimated(_ index: Int, top: Bool = true) async {
|
||||||
if index >= listState.items.count || listState.isScrolling {
|
if index >= listState.items.count || listState.isScrolling || listState.isAnimatedScrolling {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
listState.isScrolling = true
|
listState.isAnimatedScrolling = true
|
||||||
defer {
|
defer {
|
||||||
listState.isScrolling = false
|
listState.isAnimatedScrolling = false
|
||||||
}
|
}
|
||||||
var adjustedOffset = self.contentOffset.y
|
var adjustedOffset = self.contentOffset.y
|
||||||
var i = 0
|
var i = 0
|
||||||
|
@ -543,7 +549,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
|
||||||
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
|
//let step: CGFloat = max(0.1, CGFloat(abs(index - firstOrLastIndex)) * scrollStepMultiplier)
|
||||||
|
|
||||||
var stepSlowdownMultiplier: CGFloat = 1
|
var stepSlowdownMultiplier: CGFloat = 1
|
||||||
while true {
|
while i < 200 {
|
||||||
let up = index > listState.firstVisibleItemIndex
|
let up = index > listState.firstVisibleItemIndex
|
||||||
if upPrev != up {
|
if upPrev != up {
|
||||||
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
|
stepSlowdownMultiplier = stepSlowdownMultiplier * 0.5
|
||||||
|
|
|
@ -20,6 +20,7 @@ struct TerminalView: View {
|
||||||
@State var composeState: ComposeState = ComposeState()
|
@State var composeState: ComposeState = ComposeState()
|
||||||
@State var selectedRange = NSRange()
|
@State var selectedRange = NSRange()
|
||||||
@State private var keyboardVisible = false
|
@State private var keyboardVisible = false
|
||||||
|
@State private var keyboardHiddenDate = Date.now
|
||||||
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
@State var authorized = !UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA)
|
||||||
@State private var terminalItem: TerminalItem?
|
@State private var terminalItem: TerminalItem?
|
||||||
@State private var scrolled = false
|
@State private var scrolled = false
|
||||||
|
@ -101,7 +102,8 @@ struct TerminalView: View {
|
||||||
sendMessage: { _ in consoleSendMessage() },
|
sendMessage: { _ in consoleSendMessage() },
|
||||||
showVoiceMessageButton: false,
|
showVoiceMessageButton: false,
|
||||||
onMediaAdded: { _ in },
|
onMediaAdded: { _ in },
|
||||||
keyboardVisible: $keyboardVisible
|
keyboardVisible: $keyboardVisible,
|
||||||
|
keyboardHiddenDate: $keyboardHiddenDate
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3261,6 +3261,20 @@ public enum CIForwardedFrom: Decodable, Hashable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var chatTypeApiIdMsgId: (ChatType, Int64, ChatItem.ID?)? {
|
||||||
|
switch self {
|
||||||
|
case .unknown: nil
|
||||||
|
case let .contact(_, _, contactId, msgId):
|
||||||
|
if let contactId {
|
||||||
|
(ChatType.direct, contactId, msgId)
|
||||||
|
} else { nil }
|
||||||
|
case let .group(_, _, groupId, msgId):
|
||||||
|
if let groupId {
|
||||||
|
(ChatType.group, groupId, msgId)
|
||||||
|
} else { nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func text(_ chatType: ChatType) -> LocalizedStringKey {
|
public func text(_ chatType: ChatType) -> LocalizedStringKey {
|
||||||
chatType == .local
|
chatType == .local
|
||||||
? (chatName == "" ? "saved" : "saved from \(chatName)")
|
? (chatName == "" ? "saved" : "saved from \(chatName)")
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue