Files
damus/damus/Features/Events/NoteContentView.swift
T
alltheseas a0cecdc8ad Fix missing profile names and pictures due to stream timing
When a view subscribes to profile updates via streamProfile() or
streamProfiles(), the stream now immediately yields any existing
profile data from NostrDB before waiting for network updates.

Previously, subscribers had to wait up to ~1 second for the
subscriptionSwitcherTask to restart the profile listener before
receiving any data. During this window, views would display
abbreviated pubkeys (e.g., "npub1abc...") or robohash placeholders
instead of the cached profile name and picture.

The fix adds a simple NDB lookup when creating the stream. This has
negligible performance impact since:
- It's a one-time operation per subscription (not per update)
- The same lookup was already happening in view bodies anyway
- NDB lookups are fast local queries

A new `yieldCached` parameter (default: true) allows callers to opt
out of the initial cached emission. NoteContentView uses this to
avoid redundant artifact re-renders — it only needs network updates
since its initial render already uses cached profile data.

Furthermore, when a profile has no metadata, the display name now shows
"npub1yrse...q9ye" instead of "1yrsedhw:8q0pq9ye" for a better UX.

Closes: https://github.com/damus-io/damus/issues/3454
Closes: https://github.com/damus-io/damus/issues/3455
Changelog-Changed: Changed abbreviated pubkey format to npub1...xyz for better readability
Changelog-Fixed: Fixed instances where a profile would not display profile name and picture for a few seconds
Signed-off-by: alltheseas <64376233+alltheseas@users.noreply.github.com>
Co-authored-by: Daniel D’Aquino <daniel@daquino.me>
2025-12-19 17:21:49 -08:00

624 lines
22 KiB
Swift

//
// NoteContentView.swift
// damus
//
// Created by William Casarin on 2022-05-04.
//
import SwiftUI
import LinkPresentation
import NaturalLanguage
import MarkdownUI
import Translation
import UIKit
struct Blur: UIViewRepresentable {
var style: UIBlurEffect.Style = .systemUltraThinMaterial
func makeUIView(context: Context) -> UIVisualEffectView {
return UIVisualEffectView(effect: UIBlurEffect(style: style))
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: style)
}
}
extension bech32_nprofile {
func matches_pubkey(pk: Pubkey) -> Bool {
pk.id.withUnsafeBytes { bytes in
memcmp(self.pubkey, bytes, 32) == 0
}
}
}
extension bech32_npub {
func matches_pubkey(pk: Pubkey) -> Bool {
pk.id.withUnsafeBytes { bytes in
memcmp(self.pubkey, bytes, 32) == 0
}
}
}
struct NoteContentView: View {
let damus_state: DamusState
let event: NostrEvent
@State var blur_images: Bool
@State var load_media: Bool = false
let size: EventViewKind
let preview_height: CGFloat?
let options: EventViewOptions
let highlightTerms: [String]
@State var isAppleTranslationPopoverPresented: Bool = false
@ObservedObject var artifacts_model: NoteArtifactsModel
@ObservedObject var preview_model: PreviewModel
@ObservedObject var settings: UserSettingsStore
var note_artifacts: NoteArtifacts {
if damus_state.settings.undistractMode {
return .separated(.just_content(Undistractor.makeGibberish(text: event.get_content(damus_state.keypair))))
}
return self.artifacts_model.state.artifacts ?? .separated(.just_content(event.get_content(damus_state.keypair)))
}
init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions, highlightTerms: [String] = []) {
self.damus_state = damus_state
self.event = event
self.blur_images = blur_images
self.size = size
self.options = options
self.highlightTerms = highlightTerms
self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id)
let cached = damus_state.events.get_cache_data(event.id)
self._preview_model = ObservedObject(wrappedValue: cached.preview_model)
self._artifacts_model = ObservedObject(wrappedValue: cached.artifacts_model)
self._settings = ObservedObject(wrappedValue: damus_state.settings)
}
var truncate: Bool {
return options.contains(.truncate_content)
}
var truncate_very_short: Bool {
return options.contains(.truncate_content_very_short)
}
var with_padding: Bool {
return options.contains(.wide)
}
var preview: LinkViewRepresentable? {
guard case .loaded(let preview) = preview_model.state,
case .value(let cached) = preview else {
return nil
}
// If either
// (1) the blur images setting is enabled
// (2) the media previews setting is disabled
// (3) this note content view does not display media
// then do not show media in the link preview.
if blur_images || !damus_state.settings.media_previews || self.options.contains(.no_media) {
return linkPreviewWithNoMedia(cached)
}
// If media is already being shown, do not show media in the link preview
// to avoid taking up additional screen space.
if case let .separated(separated) = note_artifacts, !separated.media.isEmpty && !self.options.contains(.no_media) {
return linkPreviewWithNoMedia(cached)
}
return LinkViewRepresentable(meta: .linkmeta(cached))
}
// Creates a LinkViewRepresentable without media previews.
func linkPreviewWithNoMedia(_ cached: CachedMetadata) -> LinkViewRepresentable? {
let linkMetadata = LPLinkMetadata()
linkMetadata.originalURL = cached.meta.originalURL
linkMetadata.title = cached.meta.title
linkMetadata.url = cached.meta.url
return LinkViewRepresentable(meta: .linkmeta(CachedMetadata(meta: linkMetadata)))
}
func truncatedText(content: CompatibleText) -> some View {
Group {
if truncate_very_short {
TruncatedText(text: content, maxChars: 140, show_show_more_button: !options.contains(.no_show_more))
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
else if truncate {
TruncatedText(text: content, show_show_more_button: !options.contains(.no_show_more))
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
} else {
content.text
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
}
}
func invoicesView(invoices: [Invoice]) -> some View {
InvoicesView(our_pubkey: damus_state.keypair.pubkey, invoices: invoices, settings: damus_state.settings)
}
var translateView: some View {
TranslateView(damus_state: damus_state, event: event, size: self.size, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
}
func previewView(links: [URL]) -> some View {
Group {
if let preview = self.preview {
if let preview_height {
preview
.frame(height: preview_height)
} else {
preview
}
} else if let link = links.first {
LinkViewRepresentable(meta: .url(link))
.frame(height: 50)
}
}
}
func fullscreen_preview(dismiss: @escaping () -> Void) -> some View {
EmptyView()
}
func MainContent(artifacts: NoteArtifactsSeparated) -> some View {
let contentToRender = highlightedContent(artifacts.content)
return VStack(alignment: .leading) {
if size == .selected {
if with_padding {
SelectableText(damus_state: damus_state, event: self.event, attributedString: contentToRender.attributed, size: self.size)
.padding(.horizontal)
} else {
SelectableText(damus_state: damus_state, event: self.event, attributedString: contentToRender.attributed, size: self.size)
}
} else {
if with_padding {
truncatedText(content: contentToRender)
.padding(.horizontal)
} else {
truncatedText(content: contentToRender)
}
}
if !options.contains(.no_translate) && (size == .selected || TranslationService.isAppleTranslationPopoverSupported || damus_state.settings.auto_translate) {
if with_padding {
translateView
.padding(.horizontal)
} else {
translateView
}
}
if artifacts.media.count > 0 {
if (self.options.contains(.no_media)) {
EmptyView()
} else if !damus_state.settings.media_previews && !load_media {
loadMediaButton(artifacts: artifacts)
} else if !blur_images || (!blur_images && !damus_state.settings.media_previews && load_media) {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
fullscreen_preview(dismiss: dismiss)
}
} else if blur_images || (blur_images && !damus_state.settings.media_previews && load_media) {
ZStack {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
fullscreen_preview(dismiss: dismiss)
}
BlurOverlayView(blur_images: $blur_images, artifacts: artifacts, size: size, damus_state: damus_state, parentView: .noteContentView)
}
}
}
if artifacts.invoices.count > 0 {
if with_padding {
invoicesView(invoices: artifacts.invoices)
.padding(.horizontal)
} else {
invoicesView(invoices: artifacts.invoices)
}
}
if has_previews {
if with_padding {
previewView(links: artifacts.links).padding(.horizontal)
} else {
previewView(links: artifacts.links)
}
}
}
}
var has_previews: Bool {
!options.contains(.no_previews)
}
func loadMediaButton(artifacts: NoteArtifactsSeparated) -> some View {
Button(action: {
load_media = true
}, label: {
VStack(alignment: .leading) {
HStack {
Image("images")
Text("Load media", comment: "Button to show media in note.")
.fontWeight(.bold)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
ForEach(artifacts.media.indices, id: \.self) { index in
Divider()
.frame(height: 1)
switch artifacts.media[index] {
case .image(let url), .video(let url):
Text(abbreviateURL(url))
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
.foregroundStyle(DamusColors.neutral6)
.multilineTextAlignment(.leading)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10))
}
}
}
.background(DamusColors.neutral1)
.frame(minWidth: nil, maxWidth: .infinity, alignment: .center)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
})
.padding(.horizontal)
}
@concurrent
func streamProfiles() async throws {
var mentionPubkeys: Set<Pubkey> = []
let event = await self.event.clone()
try await NdbBlockGroup.borrowBlockGroup(event: event, using: damus_state.ndb, and: damus_state.keypair, borrow: { blockGroup in
blockGroup.forEachBlock({ _, block in
guard let pubkey = block.mentionPubkey(tags: event.tags) else {
return .loopContinue
}
mentionPubkeys.insert(pubkey)
return .loopContinue
})
})
if mentionPubkeys.isEmpty {
return
}
// Only re-render on network updates, not cached profiles.
// Initial render already uses cached profile data via the view hierarchy.
for await profile in await damus_state.nostrNetwork.profilesManager.streamProfiles(pubkeys: mentionPubkeys, yieldCached: false) {
await load(force_artifacts: true)
}
}
func load(force_artifacts: Bool = false) {
if case .loading = damus_state.events.get_cache_data(event.id).artifacts_model.state {
return
}
// always reload artifacts on load
let plan = get_preload_plan(ndb: damus_state.ndb, evcache: damus_state.events, ev: event, our_keypair: damus_state.keypair, settings: damus_state.settings)
// TODO: make this cleaner
Task {
// this is surprisingly slow
let rel = format_relative_time(event.created_at)
Task { @MainActor in
self.damus_state.events.get_cache_data(event.id).relative_time.value = rel
}
if var plan {
if force_artifacts {
plan.load_artifacts = true
}
await preload_event(plan: plan, state: damus_state)
} else if force_artifacts {
let arts = await ContentRenderer().render_note_content(ndb: damus_state.ndb, ev: event, profiles: damus_state.profiles, keypair: damus_state.keypair)
self.artifacts_model.state = .loaded(arts)
}
}
}
func artifactPartsView(_ parts: [ArtifactPart]) -> some View {
LazyVStack(alignment: .leading) {
ForEach(parts.indices, id: \.self) { ind in
let part = parts[ind]
switch part {
case .text(let txt):
if with_padding {
txt.padding(.horizontal)
} else {
txt
}
case .invoice(let inv):
if with_padding {
InvoiceView(our_pubkey: damus_state.pubkey, invoice: inv, settings: damus_state.settings)
.padding(.horizontal)
} else {
InvoiceView(our_pubkey: damus_state.pubkey, invoice: inv, settings: damus_state.settings)
}
case .media(let media):
Text(verbatim: "media \(media.url.absoluteString)")
}
}
}
}
var ArtifactContent: some View {
Group {
switch self.note_artifacts {
case .longform(let md):
Markdown(md.markdown)
.padding([.leading, .trailing, .top])
case .separated(let separated):
if #available(iOS 17.4, macOS 14.4, *) {
MainContent(artifacts: separated)
#if !targetEnvironment(macCatalyst)
.translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
#endif
} else {
MainContent(artifacts: separated)
}
}
}
.fixedSize(horizontal: false, vertical: true)
}
var normalizedHighlightTerms: [String] {
var output: [String] = []
var seen = Set<String>()
let preparedTerms = highlightTerms
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.flatMap { term -> [String] in
if term.hasPrefix("#") {
let stripped = String(term.dropFirst())
return [term, stripped]
}
return [term]
}
for term in preparedTerms {
let lower = term.lowercased()
if !lower.isEmpty && seen.insert(lower).inserted {
output.append(lower)
}
}
return output
}
func highlightedContent(_ content: CompatibleText) -> CompatibleText {
guard !normalizedHighlightTerms.isEmpty else { return content }
var attributed = content.attributed
highlightAttributedString(&attributed)
return CompatibleText(attributed: attributed)
}
func highlightAttributedString(_ attributed: inout AttributedString) {
for term in normalizedHighlightTerms {
var searchStart = attributed.startIndex
while let range = attributed[searchStart...].range(of: term, options: .caseInsensitive) {
attributed[range].backgroundColor = DamusColors.highlight
searchStart = range.upperBound
}
}
}
var body: some View {
ArtifactContent
.task {
try? await streamProfiles()
}
.onAppear {
load()
}
}
}
class NoteArtifactsParts {
var parts: [ArtifactPart]
var words: Int
init(parts: [ArtifactPart], words: Int) {
self.parts = parts
self.words = words
}
}
enum ArtifactPart {
case text(Text)
case media(MediaUrl)
case invoice(Invoice)
var is_text: Bool {
switch self {
case .text: return true
case .media: return false
case .invoice: return false
}
}
}
fileprivate func artifact_part_last_text_ind(parts: [ArtifactPart]) -> (Int, Text)? {
let ind = parts.count - 1
if ind < 0 {
return nil
}
guard case .text(let txt) = parts[safe: ind] else {
return nil
}
return (ind, txt)
}
func lookup_cached_preview_size(previews: PreviewCache, evid: NoteId) -> CGFloat? {
guard case .value(let cached) = previews.lookup(evid) else {
return nil
}
guard let height = cached.intrinsic_height else {
return nil
}
return height
}
struct BlurOverlayView: View {
@Binding var blur_images: Bool
let artifacts: NoteArtifactsSeparated?
let size: EventViewKind?
let damus_state: DamusState?
let parentView: ParentViewType
var body: some View {
ZStack {
Color.black
.opacity(0.54)
Blur()
VStack(alignment: .center) {
Image(systemName: "eye.slash")
.foregroundStyle(.white)
.bold()
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
Text(NSLocalizedString("Media from someone you don't follow", comment: "Label on the image blur mask"))
.multilineTextAlignment(.center)
.foregroundStyle(Color.white)
.font(.title2)
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
Button(NSLocalizedString("Tap to load", comment: "Label for button that allows user to dismiss media content warning and unblur the image")) {
blur_images = false
}
.buttonStyle(.bordered)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
if parentView == .noteContentView,
let artifacts = artifacts,
let size = size,
let damus_state = damus_state
{
switch artifacts.media[0] {
case .image(let url), .video(let url):
Text(abbreviateURL(url, maxLength: 30))
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size * 0.8))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.padding(EdgeInsets(top: 20, leading: 10, bottom: 5, trailing: 10))
}
}
}
}
.onTapGesture {
blur_images = false
}
}
enum ParentViewType {
case noteContentView, longFormView
}
}
struct NoteContentView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
let state2 = test_damus_state
Group {
VStack {
NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .normal, options: [])
}
.previewDisplayName("Short note")
VStack {
NoteContentView(damus_state: state, event: test_super_short_note, blur_images: true, size: .normal, options: [])
}
.previewDisplayName("Super short note")
VStack {
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: true, size: .normal, options: [])
}
.previewDisplayName("Note with image")
VStack {
NoteContentView(damus_state: state2, event: test_longform_event.event, blur_images: false, size: .normal, options: [.wide])
.border(Color.red)
}
.previewDisplayName("Long-form note")
VStack {
NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .small, options: [.no_previews, .no_action_bar, .truncate_content_very_short, .no_show_more])
.font(.callout)
.foregroundColor(.secondary)
.lineLimit(1)
}
.previewDisplayName("Small single-line note")
}
}
}
func separate_images(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
let urlBlocks: [URL] = (blockGroup.reduce(initialResult: Array<URL>()) { index, urls, block in
switch block {
case .url(let url):
guard let parsed_url = URL(string: url.as_str()) else {
return .loopContinue
}
if classify_url(parsed_url).is_img != nil {
return .loopReturn(urls + [parsed_url])
}
default:
break
}
return .loopContinue
}) ?? []
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls
})
}
extension NdbBlock {
func mentionPubkey(tags: Tags) -> Pubkey? {
switch self {
case .mention(let mentionBlock):
guard let mention = MentionRef(block: mentionBlock) else {
return nil
}
return mention.pubkey
case .mention_index(let mentionIndex):
let tagPosition = Int(mentionIndex)
guard tagPosition >= 0, tagPosition < tags.count else {
return nil
}
guard let mention = MentionRef.from_tag(tag: tags[tagPosition]) else {
return nil
}
return mention.pubkey
default:
return nil
}
}
}