damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit b1b032d9057ba91059a5bf6351283dec5791ac13
parent 7c805f7f2395c7f6d31b5cddc60f78d1b0b36e90
Author: Swift Coder <scoder1747@gmail.com>
Date:   Sun, 20 Oct 2024 17:58:52 -0400

postview: add hashtag suggestions

Closes: #2604
Changelog-Changes: Add hashtag suggestions to post view
Signed-off-by: Swift Coder <scoder1747@gmail.com>

Diffstat:
Mdamus/Views/PostView.swift | 27++++++++++++++++++++++++---
Mdamus/Views/SuggestedHashtagsView.swift | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mdamus/Views/TextViewWrapper.swift | 2+-
Mdamus/damusApp.swift | 1+
4 files changed, 159 insertions(+), 23 deletions(-)

diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -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) diff --git a/damus/Views/SuggestedHashtagsView.swift b/damus/Views/SuggestedHashtagsView.swift @@ -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() ?? "" +} diff --git a/damus/Views/TextViewWrapper.swift b/damus/Views/TextViewWrapper.swift @@ -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)?, diff --git a/damus/damusApp.swift b/damus/damusApp.swift @@ -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)