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:
Swift Coder
2024-10-20 17:58:52 -04:00
committed by William Casarin
parent 7c805f7f23
commit b1b032d905
4 changed files with 159 additions and 23 deletions

View File

@@ -401,7 +401,7 @@ struct PostView: View {
GeometryReader { (deviceSize: GeometryProxy) in
VStack(alignment: .leading, spacing: 0) {
let searching = get_searching_string(focusWordAttributes.0)
let searchingHashTag = get_searching_hashTag(focusWordAttributes.0)
TopBar
ScrollViewReader { scroller in
@@ -415,7 +415,7 @@ struct PostView: View {
.padding(.top, 5)
}
}
.frame(maxHeight: searching == nil ? deviceSize.size.height : 70)
.frame(maxHeight: searching == nil && searchingHashTag == nil ? deviceSize.size.height : 70)
.onAppear {
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)
.frame(maxHeight: .infinity)
.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()
VStack(alignment: .leading) {
AttachmentBar
@@ -526,6 +536,17 @@ func get_searching_string(_ word: String?) -> String? {
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 {
static var previews: some View {
PostView(action: .posting(.none), damus_state: test_damus_state)

View File

@@ -39,6 +39,7 @@ struct SuggestedHashtagsView: View {
.sorted(by: { a, b in
a.count > b.count
})
SuggestedHashtagsView.lastRefresh_hashtags = all_items // Collecting recent hash-tag data from Search-page
guard let item_limit else {
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.suggested_hashtags = suggested_hashtags ?? DEFAULT_SUGGESTED_HASHTAGS
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)
}
@@ -59,24 +105,43 @@ struct SuggestedHashtagsView: View {
Image(systemName: "sparkles")
Text("Suggested hashtags", comment: "A label indicating that the items below it are suggested hashtags")
Spacer()
Button(action: {
withAnimation(.easeOut(duration: 0.2)) {
show_suggested_hashtags.toggle()
}
}) {
if show_suggested_hashtags {
Image(systemName: "rectangle.compress.vertical")
.foregroundStyle(PinkGradient)
} else {
Image(systemName: "rectangle.expand.vertical")
.foregroundStyle(PinkGradient)
// Don't show suggestion expand/contract button when user is in PostView
if !isFromPostView {
Button(action: {
withAnimation(.easeOut(duration: 0.2)) {
show_suggested_hashtags.toggle()
}
}) {
if show_suggested_hashtags {
Image(systemName: "rectangle.compress.vertical")
.foregroundStyle(PinkGradient)
} else {
Image(systemName: "rectangle.expand.vertical")
.foregroundStyle(PinkGradient)
}
}
}
}
.foregroundColor(.secondary)
.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,
id: \.self) { hashtag_with_count in
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 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.hashtag = hashtag
self.count = count
self.isFromPostView = isFromPostView
self._focusWordAttributes = focusWordAttributes
self._newCursorIndex = newCursorIndex
self._post = post
}
var body: some View {
@@ -105,18 +186,48 @@ struct SuggestedHashtagsView: View {
Text(verbatim: "#\(hashtag)")
.bold()
let pluralizedString = pluralizedString(key: "users_talking_about_it", count: self.count)
Text(pluralizedString)
.foregroundStyle(.secondary)
// Don't show user-talking label from PostView when the count is 0
if isFromPostView {
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()
}
.contentShape(Rectangle()) // make the entire row/rectangle tappable
.onTapGesture {
let search_model = SearchModel(state: damus_state, search: NostrFilter.init(hashtag: [hashtag]))
damus_state.nav.push(route: Route.Search(search: search_model))
if isFromPostView {
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 {
@@ -147,3 +258,6 @@ struct SuggestedHashtagsView_Previews: PreviewProvider {
}
}
fileprivate func returnFirstWordOnly(hashTag: String) -> String {
return hashTag.components(separatedBy: " ").first?.lowercased() ?? ""
}

View File

@@ -93,7 +93,7 @@ struct TextViewWrapper: UIViewRepresentable {
let updateCursorPosition: ((Int) -> Void)
let initialTextSuffix: String?
var initialTextSuffixWasAdded: Bool = false
static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; "]
static let ESCAPE_SEQUENCES = ["\n", "@", " ", ", ", ". ", "! ", "? ", "; ", "#"]
init(attributedText: Binding<NSMutableAttributedString>,
getFocusWordForMention: ((String?, NSRange?) -> Void)?,

View File

@@ -44,6 +44,7 @@ struct MainView: View {
.onReceive(handle_notify(.logout)) { () in
try? clear_keypair()
keypair = nil
SuggestedHashtagsView.lastRefresh_hashtags.removeAll()
// 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
notify(.disconnect_relays)