postview: add hashtag suggestions
Closes: #2604 Changelog-Changes: Add hashtag suggestions to post view Signed-off-by: Swift Coder <scoder1747@gmail.com>
This commit is contained in:
committed by
William Casarin
parent
7c805f7f23
commit
b1b032d905
@@ -401,7 +401,7 @@ struct PostView: View {
|
|||||||
GeometryReader { (deviceSize: GeometryProxy) in
|
GeometryReader { (deviceSize: GeometryProxy) in
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
let searching = get_searching_string(focusWordAttributes.0)
|
let searching = get_searching_string(focusWordAttributes.0)
|
||||||
|
let searchingHashTag = get_searching_hashTag(focusWordAttributes.0)
|
||||||
TopBar
|
TopBar
|
||||||
|
|
||||||
ScrollViewReader { scroller in
|
ScrollViewReader { scroller in
|
||||||
@@ -415,7 +415,7 @@ struct PostView: View {
|
|||||||
.padding(.top, 5)
|
.padding(.top, 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxHeight: searching == nil ? deviceSize.size.height : 70)
|
.frame(maxHeight: searching == nil && searchingHashTag == nil ? deviceSize.size.height : 70)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top)
|
scroll_to_event(scroller: scroller, id: "post", delay: 1.0, animate: true, anchor: .top)
|
||||||
}
|
}
|
||||||
@@ -426,7 +426,17 @@ struct PostView: View {
|
|||||||
UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post)
|
UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post)
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.environmentObject(tagModel)
|
.environmentObject(tagModel)
|
||||||
} else {
|
// This else observes '#' for hash-tag suggestions and creates SuggestedHashtagsView
|
||||||
|
} else if let searchingHashTag {
|
||||||
|
SuggestedHashtagsView(damus_state: damus_state,
|
||||||
|
events: SearchHomeModel(damus_state: damus_state).events,
|
||||||
|
isFromPostView: true,
|
||||||
|
queryHashTag: searchingHashTag,
|
||||||
|
focusWordAttributes: $focusWordAttributes,
|
||||||
|
newCursorIndex: $newCursorIndex,
|
||||||
|
post: $post)
|
||||||
|
.environmentObject(tagModel)
|
||||||
|
} else {
|
||||||
Divider()
|
Divider()
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
AttachmentBar
|
AttachmentBar
|
||||||
@@ -526,6 +536,17 @@ func get_searching_string(_ word: String?) -> String? {
|
|||||||
return String(word.dropFirst())
|
return String(word.dropFirst())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate func get_searching_hashTag(_ word: String?) -> String? {
|
||||||
|
guard let word,
|
||||||
|
word.count >= 2,
|
||||||
|
let first_char = word.first,
|
||||||
|
first_char == "#" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(word.dropFirst())
|
||||||
|
}
|
||||||
|
|
||||||
struct PostView_Previews: PreviewProvider {
|
struct PostView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
PostView(action: .posting(.none), damus_state: test_damus_state)
|
PostView(action: .posting(.none), damus_state: test_damus_state)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ struct SuggestedHashtagsView: View {
|
|||||||
.sorted(by: { a, b in
|
.sorted(by: { a, b in
|
||||||
a.count > b.count
|
a.count > b.count
|
||||||
})
|
})
|
||||||
|
SuggestedHashtagsView.lastRefresh_hashtags = all_items // Collecting recent hash-tag data from Search-page
|
||||||
guard let item_limit else {
|
guard let item_limit else {
|
||||||
return all_items
|
return all_items
|
||||||
}
|
}
|
||||||
@@ -46,10 +47,55 @@ struct SuggestedHashtagsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(damus_state: DamusState, suggested_hashtags: [String]? = nil, max_items item_limit: Int? = nil, events: EventHolder) {
|
|
||||||
|
static var lastRefresh_hashtags: [HashtagWithUserCount] = [] // Holds hash-tag data for PostView
|
||||||
|
var isFromPostView: Bool
|
||||||
|
var queryHashTag: String
|
||||||
|
|
||||||
|
var filteredSuggestedHashtags: [HashtagWithUserCount] {
|
||||||
|
let val = SuggestedHashtagsView.lastRefresh_hashtags.filter {$0.hashtag.hasPrefix(returnFirstWordOnly(hashTag: queryHashTag))}
|
||||||
|
if val.isEmpty {
|
||||||
|
if SuggestedHashtagsView.lastRefresh_hashtags.isEmpty {
|
||||||
|
// This is special case when user goes directly to PostView without opening Search-page previously.
|
||||||
|
var val = hashtags_with_count_to_display // retrieves default hash-tage values
|
||||||
|
// if not-found, put query hash tag at top
|
||||||
|
val.insert(HashtagWithUserCount(hashtag: returnFirstWordOnly(hashTag: queryHashTag), count: 0), at: 0)
|
||||||
|
return val
|
||||||
|
} else {
|
||||||
|
// if not-found, put query hash tag at top
|
||||||
|
var val = SuggestedHashtagsView.lastRefresh_hashtags
|
||||||
|
val.insert(HashtagWithUserCount(hashtag: returnFirstWordOnly(hashTag: queryHashTag), count: 0), at: 0)
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Binding var focusWordAttributes: (String?, NSRange?)
|
||||||
|
@Binding var newCursorIndex: Int?
|
||||||
|
@Binding var post: NSMutableAttributedString
|
||||||
|
@EnvironmentObject var tagModel: TagModel
|
||||||
|
|
||||||
|
init(damus_state: DamusState,
|
||||||
|
suggested_hashtags: [String]? = nil,
|
||||||
|
max_items item_limit: Int? = nil,
|
||||||
|
events: EventHolder,
|
||||||
|
isFromPostView: Bool = false,
|
||||||
|
queryHashTag: String = "",
|
||||||
|
focusWordAttributes: Binding<(String?, NSRange?)> = .constant((nil, nil)),
|
||||||
|
newCursorIndex: Binding<Int?> = .constant(nil),
|
||||||
|
post: Binding<NSMutableAttributedString> = .constant(NSMutableAttributedString(string: ""))) {
|
||||||
self.damus_state = damus_state
|
self.damus_state = damus_state
|
||||||
self.suggested_hashtags = suggested_hashtags ?? DEFAULT_SUGGESTED_HASHTAGS
|
self.suggested_hashtags = suggested_hashtags ?? DEFAULT_SUGGESTED_HASHTAGS
|
||||||
self.item_limit = item_limit
|
self.item_limit = item_limit
|
||||||
|
|
||||||
|
self.isFromPostView = isFromPostView
|
||||||
|
self.queryHashTag = queryHashTag
|
||||||
|
self._focusWordAttributes = focusWordAttributes
|
||||||
|
self._newCursorIndex = newCursorIndex
|
||||||
|
self._post = post
|
||||||
|
|
||||||
_events = StateObject.init(wrappedValue: events)
|
_events = StateObject.init(wrappedValue: events)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,24 +105,43 @@ struct SuggestedHashtagsView: View {
|
|||||||
Image(systemName: "sparkles")
|
Image(systemName: "sparkles")
|
||||||
Text("Suggested hashtags", comment: "A label indicating that the items below it are suggested hashtags")
|
Text("Suggested hashtags", comment: "A label indicating that the items below it are suggested hashtags")
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
// Don't show suggestion expand/contract button when user is in PostView
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
if !isFromPostView {
|
||||||
show_suggested_hashtags.toggle()
|
Button(action: {
|
||||||
}
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
}) {
|
show_suggested_hashtags.toggle()
|
||||||
if show_suggested_hashtags {
|
}
|
||||||
Image(systemName: "rectangle.compress.vertical")
|
}) {
|
||||||
.foregroundStyle(PinkGradient)
|
if show_suggested_hashtags {
|
||||||
} else {
|
Image(systemName: "rectangle.compress.vertical")
|
||||||
Image(systemName: "rectangle.expand.vertical")
|
.foregroundStyle(PinkGradient)
|
||||||
.foregroundStyle(PinkGradient)
|
} else {
|
||||||
|
Image(systemName: "rectangle.expand.vertical")
|
||||||
|
.foregroundStyle(PinkGradient)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
|
|
||||||
if show_suggested_hashtags {
|
if isFromPostView {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack {
|
||||||
|
ForEach(filteredSuggestedHashtags,
|
||||||
|
id: \.self) { hashtag_with_count in
|
||||||
|
SuggestedHashtagView(damus_state: damus_state,
|
||||||
|
hashtag: hashtag_with_count.hashtag,
|
||||||
|
count: hashtag_with_count.count,
|
||||||
|
isFromPostView: true,
|
||||||
|
focusWordAttributes: $focusWordAttributes,
|
||||||
|
newCursorIndex: $newCursorIndex,
|
||||||
|
post: $post)
|
||||||
|
.environmentObject(tagModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if show_suggested_hashtags {
|
||||||
ForEach(hashtags_with_count_to_display,
|
ForEach(hashtags_with_count_to_display,
|
||||||
id: \.self) { hashtag_with_count in
|
id: \.self) { hashtag_with_count in
|
||||||
SuggestedHashtagView(damus_state: damus_state, hashtag: hashtag_with_count.hashtag, count: hashtag_with_count.count)
|
SuggestedHashtagView(damus_state: damus_state, hashtag: hashtag_with_count.hashtag, count: hashtag_with_count.count)
|
||||||
@@ -91,10 +156,26 @@ struct SuggestedHashtagsView: View {
|
|||||||
let hashtag: String
|
let hashtag: String
|
||||||
let count: Int
|
let count: Int
|
||||||
|
|
||||||
init(damus_state: DamusState, hashtag: String, count: Int) {
|
let isFromPostView: Bool
|
||||||
|
@Binding var focusWordAttributes: (String?, NSRange?)
|
||||||
|
@Binding var newCursorIndex: Int?
|
||||||
|
@Binding var post: NSMutableAttributedString
|
||||||
|
@EnvironmentObject var tagModel: TagModel
|
||||||
|
|
||||||
|
init(damus_state: DamusState,
|
||||||
|
hashtag: String,
|
||||||
|
count: Int,
|
||||||
|
isFromPostView: Bool = false,
|
||||||
|
focusWordAttributes: Binding<(String?, NSRange?)> = .constant((nil, nil)),
|
||||||
|
newCursorIndex: Binding<Int?> = .constant(nil),
|
||||||
|
post: Binding<NSMutableAttributedString> = .constant(NSMutableAttributedString(string: ""))) {
|
||||||
self.damus_state = damus_state
|
self.damus_state = damus_state
|
||||||
self.hashtag = hashtag
|
self.hashtag = hashtag
|
||||||
self.count = count
|
self.count = count
|
||||||
|
self.isFromPostView = isFromPostView
|
||||||
|
self._focusWordAttributes = focusWordAttributes
|
||||||
|
self._newCursorIndex = newCursorIndex
|
||||||
|
self._post = post
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -105,18 +186,48 @@ struct SuggestedHashtagsView: View {
|
|||||||
Text(verbatim: "#\(hashtag)")
|
Text(verbatim: "#\(hashtag)")
|
||||||
.bold()
|
.bold()
|
||||||
|
|
||||||
let pluralizedString = pluralizedString(key: "users_talking_about_it", count: self.count)
|
// Don't show user-talking label from PostView when the count is 0
|
||||||
Text(pluralizedString)
|
if isFromPostView {
|
||||||
.foregroundStyle(.secondary)
|
if count != 0 {
|
||||||
|
let pluralizedString = pluralizedString(key: "users_talking_about_it", count: self.count)
|
||||||
|
Text(pluralizedString)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let pluralizedString = pluralizedString(key: "users_talking_about_it", count: self.count)
|
||||||
|
Text(pluralizedString)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.contentShape(Rectangle()) // make the entire row/rectangle tappable
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
let search_model = SearchModel(state: damus_state, search: NostrFilter.init(hashtag: [hashtag]))
|
if isFromPostView {
|
||||||
damus_state.nav.push(route: Route.Search(search: search_model))
|
let hashTag = NSMutableAttributedString(string: "#\(returnFirstWordOnly(hashTag: hashtag))",
|
||||||
|
attributes: [
|
||||||
|
NSAttributedString.Key.foregroundColor: UIColor.black,
|
||||||
|
NSAttributedString.Key.link: "#\(hashtag)"
|
||||||
|
])
|
||||||
|
appendHashTag(withTag: hashTag)
|
||||||
|
} else {
|
||||||
|
let search_model = SearchModel(state: damus_state, search: NostrFilter.init(hashtag: [hashtag]))
|
||||||
|
damus_state.nav.push(route: Route.Search(search: search_model))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Current working-code similar to UserSearch/appendUserTag
|
||||||
|
private func appendHashTag(withTag tag: NSMutableAttributedString) {
|
||||||
|
guard let wordRange = focusWordAttributes.1 else { return }
|
||||||
|
let appended = append_user_tag(tag: tag, post: post, word_range: wordRange)
|
||||||
|
self.post = appended.post
|
||||||
|
// adjust cursor position appropriately: ('diff' used in TextViewWrapper / updateUIView after below update of 'post')
|
||||||
|
tagModel.diff = appended.tag.length - wordRange.length
|
||||||
|
focusWordAttributes = (nil, nil)
|
||||||
|
newCursorIndex = wordRange.location + appended.tag.length
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func users_talking_about(hashtag: Hashtag) -> Int {
|
func users_talking_about(hashtag: Hashtag) -> Int {
|
||||||
@@ -147,3 +258,6 @@ struct SuggestedHashtagsView_Previews: PreviewProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate func returnFirstWordOnly(hashTag: String) -> String {
|
||||||
|
return hashTag.components(separatedBy: " ").first?.lowercased() ?? ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ struct TextViewWrapper: UIViewRepresentable {
|
|||||||
let updateCursorPosition: ((Int) -> Void)
|
let updateCursorPosition: ((Int) -> Void)
|
||||||
let initialTextSuffix: String?
|
let initialTextSuffix: String?
|
||||||
var initialTextSuffixWasAdded: Bool = false
|
var initialTextSuffixWasAdded: Bool = false
|
||||||
static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; "]
|
static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; ", "#"]
|
||||||
|
|
||||||
init(attributedText: Binding<NSMutableAttributedString>,
|
init(attributedText: Binding<NSMutableAttributedString>,
|
||||||
getFocusWordForMention: ((String?, NSRange?) -> Void)?,
|
getFocusWordForMention: ((String?, NSRange?) -> Void)?,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ struct MainView: View {
|
|||||||
.onReceive(handle_notify(.logout)) { () in
|
.onReceive(handle_notify(.logout)) { () in
|
||||||
try? clear_keypair()
|
try? clear_keypair()
|
||||||
keypair = nil
|
keypair = nil
|
||||||
|
SuggestedHashtagsView.lastRefresh_hashtags.removeAll()
|
||||||
// We need to disconnect and reconnect to all relays when the user signs out
|
// We need to disconnect and reconnect to all relays when the user signs out
|
||||||
// This is to conform to NIP-42 and ensure we aren't persisting old connections
|
// This is to conform to NIP-42 and ensure we aren't persisting old connections
|
||||||
notify(.disconnect_relays)
|
notify(.disconnect_relays)
|
||||||
|
|||||||
Reference in New Issue
Block a user