Merge Highlighter into release_1.10

Daniel D’Aquino (7):
      Add convenience functions
      Simplify SelectableText state management
      Add support for rendering highlights with comments
      Add support for adding comments when creating a highlight
      Add highlighter extension
      Fix highlight tag ambiguity with specifiers
      Improve handling of NostrDB when switching apps

William Casarin (4):
      lmdb: patch semaphore names to use group container prefix
      notifications: add extended virtual addressing entitlement
      highlighter: add extended virtual addressing entitlement
This commit is contained in:
William Casarin
2024-09-01 07:00:40 -07:00
43 changed files with 2272 additions and 243 deletions
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1540"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D703D7162C66E47100A400EA"
BuildableName = "HighlighterActionExtension.appex"
BlueprintName = "HighlighterActionExtension"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<RemoteRunnable
runnableDebuggingMode = "1"
BundleIdentifier = "com.apple.mobilesafari"
RemotePath = "/Library/Developer/CoreSimulator/Volumes/iOS_21F79/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.5.simruntime/Contents/Resources/RuntimeRoot/Applications/MobileSafari.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+4 -9
View File
@@ -94,8 +94,8 @@ enum OpenWalletError: Error {
} }
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws { func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) { if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
UIApplication.shared.open(url) this_app.open(url)
} else { } else {
guard let store_link = wallet.appStoreLink else { guard let store_link = wallet.appStoreLink else {
throw OpenWalletError.no_wallet_to_open throw OpenWalletError.no_wallet_to_open
@@ -105,11 +105,11 @@ func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
throw OpenWalletError.store_link_invalid throw OpenWalletError.store_link_invalid
} }
guard UIApplication.shared.canOpenURL(url) else { guard this_app.canOpenURL(url) else {
throw OpenWalletError.system_cannot_open_store_link throw OpenWalletError.system_cannot_open_store_link
} }
UIApplication.shared.open(url) this_app.open(url)
} }
} }
@@ -122,8 +122,3 @@ struct InvoiceView_Previews: PreviewProvider {
.frame(width: 300, height: 200) .frame(width: 300, height: 200)
} }
} }
func present_sheet(_ sheet: Sheets) {
notify(.present_sheet(sheet))
}
+43 -18
View File
@@ -13,8 +13,7 @@ struct SelectableText: View {
let event: NostrEvent? let event: NostrEvent?
let attributedString: AttributedString let attributedString: AttributedString
let textAlignment: NSTextAlignment let textAlignment: NSTextAlignment
@State private var showHighlightPost = false @State private var highlightPostingState: HighlightPostingState = .hide
@State private var selectedText = ""
@State private var selectedTextHeight: CGFloat = .zero @State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero @State private var selectedTextWidth: CGFloat = .zero
@@ -37,8 +36,9 @@ struct SelectableText: View {
fixedWidth: selectedTextWidth, fixedWidth: selectedTextWidth,
textAlignment: self.textAlignment, textAlignment: self.textAlignment,
enableHighlighting: self.enableHighlighting(), enableHighlighting: self.enableHighlighting(),
showHighlightPost: $showHighlightPost, postHighlight: { selectedText in
selectedText: $selectedText, self.highlightPostingState = .show_post_view(highlighted_text: selectedText)
},
height: $selectedTextHeight height: $selectedTextHeight
) )
.padding([.leading, .trailing], -1.0) .padding([.leading, .trailing], -1.0)
@@ -53,11 +53,18 @@ struct SelectableText: View {
self.selectedTextWidth = newSize.width self.selectedTextWidth = newSize.width
} }
} }
.sheet(isPresented: $showHighlightPost) { .sheet(isPresented: Binding(get: {
if let event { return self.highlightPostingState.show()
HighlightPostView(damus_state: damus_state, event: event, selectedText: $selectedText) }, set: { newValue in
self.highlightPostingState = newValue ? .show_post_view(highlighted_text: self.highlightPostingState.highlighted_text() ?? "") : .hide
})) {
if let event, case .show_post_view(let highlighted_text) = self.highlightPostingState {
PostView(
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
damus_state: damus_state
)
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
.presentationDetents([.height(selectedTextHeight + 150), .medium, .large]) .presentationDetents([.height(selectedTextHeight + 450), .medium, .large])
} }
} }
.frame(height: selectedTextHeight) .frame(height: selectedTextHeight)
@@ -66,15 +73,34 @@ struct SelectableText: View {
func enableHighlighting() -> Bool { func enableHighlighting() -> Bool {
self.event != nil self.event != nil
} }
enum HighlightPostingState {
case hide
case show_post_view(highlighted_text: String)
func show() -> Bool {
if case .show_post_view(let highlighted_text) = self {
return true
}
return false
}
func highlighted_text() -> String? {
switch self {
case .hide:
return nil
case .show_post_view(highlighted_text: let highlighted_text):
return highlighted_text
}
}
}
} }
fileprivate class TextView: UITextView { fileprivate class TextView: UITextView {
@Binding var showHighlightPost: Bool var postHighlight: (String) -> Void
@Binding var selectedText: String
init(frame: CGRect, textContainer: NSTextContainer?, showHighlightPost: Binding<Bool>, selectedText: Binding<String>) { init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void) {
self._showHighlightPost = showHighlightPost self.postHighlight = postHighlight
self._selectedText = selectedText
super.init(frame: frame, textContainer: textContainer) super.init(frame: frame, textContainer: textContainer)
} }
@@ -91,8 +117,8 @@ fileprivate class TextView: UITextView {
@objc public func highlightText(_ sender: Any?) { @objc public func highlightText(_ sender: Any?) {
guard let selectedRange = self.selectedTextRange else { return } guard let selectedRange = self.selectedTextRange else { return }
selectedText = self.text(in: selectedRange) ?? "" guard let selectedText = self.text(in: selectedRange) else { return }
showHighlightPost.toggle() self.postHighlight(selectedText)
} }
} }
@@ -105,12 +131,11 @@ fileprivate class TextView: UITextView {
let fixedWidth: CGFloat let fixedWidth: CGFloat
let textAlignment: NSTextAlignment let textAlignment: NSTextAlignment
let enableHighlighting: Bool let enableHighlighting: Bool
@Binding var showHighlightPost: Bool let postHighlight: (String) -> Void
@Binding var selectedText: String
@Binding var height: CGFloat @Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView { func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
let view = TextView(frame: .zero, textContainer: nil, showHighlightPost: $showHighlightPost, selectedText: $selectedText) let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight)
view.isEditable = false view.isEditable = false
view.dataDetectorTypes = .all view.dataDetectorTypes = .all
view.isSelectable = true view.isSelectable = true
+1 -1
View File
@@ -12,7 +12,7 @@ enum NoteContent {
case content(String, TagsSequence?) case content(String, TagsSequence?)
init(note: NostrEvent, keypair: Keypair) { init(note: NostrEvent, keypair: Keypair) {
if note.known_kind == .dm { if note.known_kind == .dm || note.known_kind == .highlight {
self = .content(note.get_content(keypair), note.tags) self = .content(note.get_content(keypair), note.tags)
} else { } else {
self = .note(note) self = .note(note)
+7 -3
View File
@@ -57,6 +57,10 @@ enum Sheets: Identifiable {
} }
} }
func present_sheet(_ sheet: Sheets) {
notify(.present_sheet(sheet))
}
struct ContentView: View { struct ContentView: View {
let keypair: Keypair let keypair: Keypair
let appDelegate: AppDelegate? let appDelegate: AppDelegate?
@@ -877,7 +881,7 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos
func setup_notifications() { func setup_notifications() {
UIApplication.shared.registerForRemoteNotifications() this_app.registerForRemoteNotifications()
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in center.getNotificationSettings { settings in
@@ -1109,7 +1113,7 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
//let post = tup.0 //let post = tup.0
//let to_relays = tup.1 //let to_relays = tup.1
print("post \(post.content)") print("post \(post.content)")
guard let new_ev = post_to_event(post: post, keypair: keypair) else { guard let new_ev = post.to_event(keypair: keypair) else {
return false return false
} }
postbox.send(new_ev) postbox.send(new_ev)
@@ -1170,7 +1174,7 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
} }
case .hashtag(let ht): case .hashtag(let ht):
result(.filter(.filter_hashtag([ht.hashtag]))) result(.filter(.filter_hashtag([ht.hashtag])))
case .param, .quote: case .param, .quote, .reference:
// doesn't really make sense here // doesn't really make sense here
break break
case .naddr(let naddr): case .naddr(let naddr):
+1 -1
View File
@@ -151,7 +151,7 @@ public class CameraService: NSObject, Identifiable {
DispatchQueue.main.async { DispatchQueue.main.async {
self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: { self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, this_app.open(URL(string: UIApplication.openSettingsURLString)!,
options: [:], completionHandler: nil) options: [:], completionHandler: nil)
}, secondaryAction: nil) }, secondaryAction: nil)
+23
View File
@@ -0,0 +1,23 @@
//
// CommentItem.swift
// damus
//
// Created by Daniel DAquino on 2024-08-14.
//
import Foundation
struct CommentItem: TagConvertible {
static let TAG_KEY: String = "comment"
let content: String
var tag: [String] {
return [Self.TAG_KEY, content]
}
static func from_tag(tag: TagSequence) -> CommentItem? {
guard tag.count == 2 else { return nil }
guard tag[0].string() == Self.TAG_KEY else { return nil }
return CommentItem(content: tag[1].string())
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
case let (.pubkey(pk), .pubkey(follow_pk)): case let (.pubkey(pk), .pubkey(follow_pk)):
return pk == follow_pk return pk == follow_pk
case (.hashtag, .pubkey), (.pubkey, .hashtag), case (.hashtag, .pubkey), (.pubkey, .hashtag),
(.event, _), (.quote, _), (.param, _), (.naddr, _): (.event, _), (.quote, _), (.param, _), (.naddr, _), (.reference(_), _):
return false return false
} }
} }
+73
View File
@@ -75,6 +75,79 @@ class DamusState: HeadlessDamusState {
self.emoji_provider = emoji_provider self.emoji_provider = emoji_provider
} }
@MainActor
convenience init?(keypair: Keypair) {
// nostrdb
var mndb = Ndb()
if mndb == nil {
// try recovery
print("DB ISSUE! RECOVERING")
mndb = Ndb.safemode()
// out of space or something?? maybe we need a in-memory fallback
if mndb == nil {
logout(nil)
return nil
}
}
let navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
let home: HomeModel = HomeModel()
let sub_id = UUID().uuidString
guard let ndb = mndb else { return nil }
let pubkey = keypair.pubkey
let pool = RelayPool(ndb: ndb, keypair: keypair)
let model_cache = RelayModelCache()
let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays {
let descriptor = RelayDescriptor(url: relay, info: .rw)
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
}
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
if let nwc_str = settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: nwc_str) {
try? pool.add_relay(.nwc(url: nwc.relay))
}
self.init(
pool: pool,
keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey),
mutelist_manager: MutelistManager(user_keypair: keypair),
profiles: Profiles(ndb: ndb),
dms: home.dms,
previews: PreviewCache(),
zaps: Zaps(our_pubkey: pubkey),
lnurls: LNUrls(),
settings: settings,
relay_filters: relay_filters,
relay_model_cache: model_cache,
drafts: Drafts(),
events: EventCache(ndb: ndb),
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
wallet: WalletModel(settings: settings),
nav: navigationCoordinator,
music: MusicController(onChange: { _ in }),
video: VideoController(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
}
@discardableResult @discardableResult
func add_zap(zap: Zapping) -> Bool { func add_zap(zap: Zapping) -> Bool {
// store generic zap mapping // store generic zap mapping
+1
View File
@@ -28,4 +28,5 @@ class Drafts: ObservableObject {
@Published var post: DraftArtifacts? = nil @Published var post: DraftArtifacts? = nil
@Published var replies: [NostrEvent: DraftArtifacts] = [:] @Published var replies: [NostrEvent: DraftArtifacts] = [:]
@Published var quotes: [NostrEvent: DraftArtifacts] = [:] @Published var quotes: [NostrEvent: DraftArtifacts] = [:]
@Published var highlights: [HighlightSource: DraftArtifacts] = [:]
} }
+182 -1
View File
@@ -14,21 +14,202 @@ struct HighlightEvent {
var url_ref: URL? = nil var url_ref: URL? = nil
var context: String? = nil var context: String? = nil
// MARK: - Initializers and parsers
static func parse(from ev: NostrEvent) -> HighlightEvent { static func parse(from ev: NostrEvent) -> HighlightEvent {
var highlight = HighlightEvent(event: ev) var highlight = HighlightEvent(event: ev)
var best_url_source: (url: URL, tagged_as_source: Bool)? = nil
for tag in ev.tags { for tag in ev.tags {
guard tag.count >= 2 else { continue } guard tag.count >= 2 else { continue }
switch tag[0].string() { switch tag[0].string() {
case "e": highlight.event_ref = tag[1].string() case "e": highlight.event_ref = tag[1].string()
case "a": highlight.event_ref = tag[1].string() case "a": highlight.event_ref = tag[1].string()
case "r": highlight.url_ref = URL(string: tag[1].string()) case "r":
if tag.count >= 3,
tag[2].string() == HighlightSource.TAG_SOURCE_ELEMENT,
let url = URL(string: tag[1].string()) {
// URL marked as source. Very good candidate
best_url_source = (url: url, tagged_as_source: true)
}
else if tag.count >= 3 && tag[2].string() != HighlightSource.TAG_SOURCE_ELEMENT {
// URL marked as something else (not source). Not the source we are after
}
else if let url = URL(string: tag[1].string()), tag.count == 2 {
// Unmarked URL. This might be what we are after (For NIP-84 backwards compatibility)
if (best_url_source?.tagged_as_source ?? false) == false {
// No URL candidates marked as the source. Mark this as the best option we have
best_url_source = (url: url, tagged_as_source: false)
}
}
case "context": highlight.context = tag[1].string() case "context": highlight.context = tag[1].string()
default: default:
break break
} }
} }
if let best_url_source {
highlight.url_ref = best_url_source.url
}
return highlight return highlight
} }
// MARK: - Getting information about source
func source_description_info(highlighted_event: NostrEvent?) -> ReplyDesc {
var others_count = 0
var highlighted_authors: [Pubkey] = []
var i = event.tags.count
if let highlighted_event {
highlighted_authors.append(highlighted_event.pubkey)
}
for tag in event.tags {
if let pubkey_with_role = PubkeyWithRole.from_tag(tag: tag) {
others_count += 1
if highlighted_authors.count < 2 {
if let highlighted_event, pubkey_with_role.pubkey == highlighted_event.pubkey {
continue
} else {
switch pubkey_with_role.role {
case .author:
highlighted_authors.append(pubkey_with_role.pubkey)
default:
break
}
}
}
}
i -= 1
}
return ReplyDesc(pubkeys: highlighted_authors, others: others_count)
}
func source_description_text(ndb: Ndb, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
let description_info = self.source_description_info(highlighted_event: highlighted_event)
let pubkeys = description_info.pubkeys
let bundle = bundleForLocale(locale: locale)
if pubkeys.count == 0 {
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
}
guard let profile_txn = NdbTxn(ndb: ndb) else {
return ""
}
let names: [String] = pubkeys.map { pk in
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
}
let uniqueNames: [String] = Array(Set(names))
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
}
}
// MARK: - Helper structures
extension HighlightEvent {
struct PubkeyWithRole: TagKey, TagConvertible {
let pubkey: Pubkey
let role: Role
var tag: [String] {
if let role_text = self.role.rawValue {
return [keychar.description, self.pubkey.hex(), role_text]
}
else {
return [keychar.description, self.pubkey.hex()]
}
}
var keychar: AsciiCharacter { "p" }
static func from_tag(tag: TagSequence) -> HighlightEvent.PubkeyWithRole? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
key == "p",
let t1 = i.next(),
let pubkey = t1.id().map(Pubkey.init)
else { return nil }
let t3: String? = i.next()?.string()
let role = Role(rawValue: t3)
return PubkeyWithRole(pubkey: pubkey, role: role)
}
enum Role: RawRepresentable {
case author
case editor
case mention
case other(String)
case no_role
typealias RawValue = String?
var rawValue: String? {
switch self {
case .author: "author"
case .editor: "editor"
case .mention: "mention"
case .other(let role): role
case .no_role: nil
}
}
init(rawValue: String?) {
switch rawValue {
case "author": self = .author
case "editor": self = .editor
case "mention": self = .mention
default:
if let rawValue {
self = .other(rawValue)
}
else {
self = .no_role
}
}
}
}
}
}
struct HighlightContentDraft: Hashable {
let selected_text: String
let source: HighlightSource
}
enum HighlightSource: Hashable {
static let TAG_SOURCE_ELEMENT = "source"
case event(NostrEvent)
case external_url(URL)
func tags() -> [[String]] {
switch self {
case .event(let event):
return [ ["e", "\(event.id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
case .external_url(let url):
return [ ["r", "\(url)", HighlightSource.TAG_SOURCE_ELEMENT] ]
}
}
func ref() -> RefId {
switch self {
case .event(let event):
return .event(event.id)
case .external_url(let url):
return .reference(url.absoluteString)
}
}
} }
-43
View File
@@ -256,46 +256,3 @@ func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
return nil return nil
} }
struct PostTags {
let blocks: [Block]
let tags: [[String]]
}
/// Convert
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
var new_tags = tags
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
switch(mention.ref) {
case .note, .nevent:
continue
default:
break
}
new_tags.append(mention.ref.tag)
case .hashtag(let hashtag):
new_tags.append(["t", hashtag.lowercased()])
case .text: break
case .invoice: break
case .relay: break
case .url(let url):
new_tags.append(["r", url.absoluteString])
break
}
}
return PostTags(blocks: post_blocks, tags: new_tags)
}
func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? {
let post_blocks = parse_post_blocks(content: post.content)
let post_tags = make_post_tags(post_blocks: post_blocks, tags: post.tags)
let content = post_tags.blocks
.map(\.asString)
.joined(separator: "")
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: post.kind.rawValue, tags: post_tags.tags)
}
+75 -1
View File
@@ -17,10 +17,84 @@ struct NostrPost {
self.kind = kind self.kind = kind
self.tags = tags self.tags = tags
} }
func to_event(keypair: FullKeypair) -> NostrEvent? {
let post_blocks = self.parse_blocks()
let post_tags = self.make_post_tags(post_blocks: post_blocks, tags: self.tags)
let content = post_tags.blocks
.map(\.asString)
.joined(separator: "")
if self.kind == .highlight {
var new_tags = post_tags.tags.filter({ $0[safe: 0] != "comment" })
if content.count > 0 {
new_tags.append(["comment", content])
}
return NostrEvent(content: self.content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: new_tags)
} }
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: post_tags.tags)
}
func parse_blocks() -> [Block] {
guard let content_for_parsing = self.default_content_for_block_parsing() else { return [] }
return parse_post_blocks(content: content_for_parsing)
}
private func default_content_for_block_parsing() -> String? {
switch kind {
case .highlight:
return tags.filter({ $0[safe: 0] == "comment" }).first?[safe: 1]
default:
return self.content
}
}
/// Parse the post's contents to find more tags to apply to the final nostr event
private func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
var new_tags = tags
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
switch(mention.ref) {
case .note, .nevent:
continue
default:
break
}
if self.kind == .highlight, case .pubkey(_) = mention.ref {
var new_tag = mention.ref.tag
new_tag.append("mention")
new_tags.append(new_tag)
}
else {
new_tags.append(mention.ref.tag)
}
case .hashtag(let hashtag):
new_tags.append(["t", hashtag.lowercased()])
case .text: break
case .invoice: break
case .relay: break
case .url(let url):
new_tags.append(self.kind == .highlight ? ["r", url.absoluteString, "mention"] : ["r", url.absoluteString])
break
}
}
return PostTags(blocks: post_blocks, tags: new_tags)
}
}
// MARK: - Helper structures and functions
/// A struct used for temporarily holding tag information that was parsed from a post contents to aid in building a nostr event
fileprivate struct PostTags {
let blocks: [Block]
let tags: [[String]]
}
/// Return a list of tags
func parse_post_blocks(content: String) -> [Block] { func parse_post_blocks(content: String) -> [Block] {
return parse_note_content(content: .content(content, nil)).blocks return parse_note_content(content: .content(content, nil)).blocks
} }
+6 -1
View File
@@ -122,6 +122,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case hashtag(Hashtag) case hashtag(Hashtag)
case param(TagElem) case param(TagElem)
case naddr(NAddr) case naddr(NAddr)
case reference(String)
var key: RefKey { var key: RefKey {
switch self { switch self {
@@ -131,11 +132,12 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case .hashtag: return .t case .hashtag: return .t
case .param: return .d case .param: return .d
case .naddr: return .a case .naddr: return .a
case .reference: return .r
} }
} }
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible { enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
case e, p, t, d, q, a case e, p, t, d, q, a, r
var keychar: AsciiCharacter { var keychar: AsciiCharacter {
self.rawValue self.rawValue
@@ -159,6 +161,8 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case .param(let string): return string.string() case .param(let string): return string.string()
case .naddr(let naddr): case .naddr(let naddr):
return naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier return naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier
case .reference(let string):
return string
} }
} }
@@ -179,6 +183,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case .t: return .hashtag(Hashtag(hashtag: t1.string())) case .t: return .hashtag(Hashtag(hashtag: t1.string()))
case .d: return .param(t1) case .d: return .param(t1)
case .a: return .naddr(NAddr(identifier: "", author: Pubkey(Data()), relays: [], kind: 0)) case .a: return .naddr(NAddr(identifier: "", author: Pubkey(Data()), relays: [], kind: 0))
case .r: return .reference(t1.string())
} }
} }
} }
+11
View File
@@ -0,0 +1,11 @@
//
// DamusAliases.swift
// damus
//
// Created by Daniel DAquino on 2024-08-12.
//
import Foundation
import UIKit
let this_app: UIApplication = UIApplication.shared
+1 -1
View File
@@ -30,7 +30,7 @@ public struct DismissKeyboardOnTap: ViewModifier {
} }
public func end_editing() { public func end_editing() {
UIApplication.shared.connectedScenes this_app.connectedScenes
.filter {$0.activationState == .foregroundActive} .filter {$0.activationState == .foregroundActive}
.map {$0 as? UIWindowScene} .map {$0 as? UIWindowScene}
.compactMap({$0}) .compactMap({$0})
+1 -2
View File
@@ -11,8 +11,7 @@ import UIKit
class Theme { class Theme {
static var safeAreaInsets: UIEdgeInsets? { static var safeAreaInsets: UIEdgeInsets? {
return UIApplication return this_app
.shared
.connectedScenes .connectedScenes
.flatMap { ($0 as? UIWindowScene)?.windows ?? [] } .flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
.first { $0.isKeyWindow }?.safeAreaInsets .first { $0.isKeyWindow }?.safeAreaInsets
+1 -1
View File
@@ -116,7 +116,7 @@ struct AddRelayView: View {
} }
new_relay = "" new_relay = ""
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
dismiss() dismiss()
}) { }) {
+10
View File
@@ -73,6 +73,16 @@ func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: Nos
return true return true
} }
// blame the porn bots for this code too
func should_blur_images(damus_state: DamusState, ev: NostrEvent) -> Bool {
return should_blur_images(
settings: damus_state.settings,
contacts: damus_state.contacts,
ev: ev,
our_pubkey: damus_state.pubkey
)
}
func format_relative_time(_ created_at: UInt32) -> String func format_relative_time(_ created_at: UInt32) -> String
{ {
return time_ago_since(Date(timeIntervalSince1970: Double(created_at))) return time_ago_since(Date(timeIntervalSince1970: Double(created_at)))
@@ -17,7 +17,8 @@ struct ReplyPart: View {
Group { Group {
if event.known_kind == .highlight { if event.known_kind == .highlight {
let highlighted_note = event.highlighted_note_id().flatMap { events.lookup($0) } let highlighted_note = event.highlighted_note_id().flatMap { events.lookup($0) }
HighlightDescription(event: event, highlighted_event: highlighted_note, ndb: ndb) let highlight_note = HighlightEvent.parse(from: event)
HighlightDescription(highlight_event: highlight_note, highlighted_event: highlighted_note, ndb: ndb)
} else if let reply_ref = event.thread_reply()?.reply { } else if let reply_ref = event.thread_reply()?.reply {
let replying_to = events.lookup(reply_ref.note_id) let replying_to = events.lookup(reply_ref.note_id)
ReplyDescription(event: event, replying_to: replying_to, ndb: ndb) ReplyDescription(event: event, replying_to: replying_to, ndb: ndb)
@@ -9,12 +9,12 @@ import SwiftUI
// Modified from Reply Description // Modified from Reply Description
struct HighlightDescription: View { struct HighlightDescription: View {
let event: NostrEvent let highlight_event: HighlightEvent
let highlighted_event: NostrEvent? let highlighted_event: NostrEvent?
let ndb: Ndb let ndb: Ndb
var body: some View { var body: some View {
(Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_desc(ndb: ndb, event: event, highlighted_event: highlighted_event))")) (Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_event.source_description_text(ndb: ndb, highlighted_event: highlighted_event))"))
.font(.footnote) .font(.footnote)
.foregroundColor(.gray) .foregroundColor(.gray)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -24,30 +24,6 @@ struct HighlightDescription: View {
struct HighlightDescription_Previews: PreviewProvider { struct HighlightDescription_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
HighlightDescription(event: test_note, highlighted_event: test_note, ndb: test_damus_state.ndb) HighlightDescription(highlight_event: HighlightEvent.parse(from: test_note), highlighted_event: nil, ndb: test_damus_state.ndb)
} }
} }
func highlight_desc(ndb: Ndb, event: NostrEvent, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
let desc = make_reply_description(event, replying_to: highlighted_event)
let pubkeys = desc.pubkeys
let bundle = bundleForLocale(locale: locale)
if pubkeys.count == 0 {
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
}
guard let profile_txn = NdbTxn(ndb: ndb) else {
return ""
}
let names: [String] = pubkeys.map { pk in
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
}
let uniqueNames: [String] = Array(Set(names))
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
}
@@ -0,0 +1,42 @@
//
// HighlightDraftContentView.swift
// damus
//
// Created by eric on 5/26/24.
//
import SwiftUI
struct HighlightDraftContentView: View {
let draft: HighlightContentDraft
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
var attributedString: AttributedString {
var attributedString = AttributedString(draft.selected_text)
if let range = attributedString.range(of: draft.selected_text) {
attributedString[range].backgroundColor = DamusColors.highlight
}
return attributedString
}
Text(attributedString)
.lineSpacing(5)
.padding(10)
}
.overlay(
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
alignment: .leading
)
if case .external_url(let url) = draft.source {
LinkViewRepresentable(meta: .url(url))
.frame(height: 50)
}
}
}
}
@@ -1,77 +0,0 @@
//
// HighlightPostView.swift
// damus
//
// Created by eric on 5/26/24.
//
import SwiftUI
struct HighlightPostView: View {
let damus_state: DamusState
let event: NostrEvent
@Binding var selectedText: String
@Environment(\.dismiss) var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 0) {
VStack {
HStack(spacing: 5.0) {
Button(action: {
dismiss()
}, label: {
Text("Cancel", comment: "Button to cancel out of highlighting a note.")
.padding(10)
})
.buttonStyle(NeutralButtonStyle())
Spacer()
Button(NSLocalizedString("Post", comment: "Button to post a highlight.")) {
let tags: [[String]] = [ ["e", "\(self.event.id)"] ]
let kind = NostrKind.highlight.rawValue
guard let ev = NostrEvent(content: selectedText, keypair: damus_state.keypair, kind: kind, tags: tags) else {
return
}
damus_state.postbox.send(ev)
dismiss()
}
.bold()
.buttonStyle(GradientButtonStyle(padding: 10))
}
Divider()
.foregroundColor(DamusColors.neutral3)
.padding(.top, 5)
}
.frame(height: 30)
.padding()
.padding(.top, 15)
HStack {
var attributedString: AttributedString {
var attributedString = AttributedString(selectedText)
if let range = attributedString.range(of: selectedText) {
attributedString[range].backgroundColor = DamusColors.highlight
}
return attributedString
}
Text(attributedString)
.lineSpacing(5)
.padding(10)
}
.overlay(
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
alignment: .leading
)
.padding()
Spacer()
}
}
}
@@ -59,9 +59,9 @@ struct HighlightBodyView: View {
var body: some View { var body: some View {
Group { Group {
if options.contains(.wide) { if options.contains(.wide) {
Main.padding(.horizontal)
} else {
Main Main
} else {
Main.padding(.horizontal)
} }
} }
} }
@@ -92,6 +92,18 @@ struct HighlightBodyView: View {
var Main: some View { var Main: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
if self.event.event.referenced_comment_items.first?.content != nil {
let all_options = options.union(.no_action_bar)
NoteContentView(
damus_state: self.state,
event: self.event.event,
blur_images: should_blur_images(damus_state: self.state, ev: self.event.event),
size: .normal,
options: all_options
).padding(.vertical, 10)
}
HStack { HStack {
var attributedString: AttributedString { var attributedString: AttributedString {
var attributedString: AttributedString = "" var attributedString: AttributedString = ""
@@ -119,14 +131,17 @@ struct HighlightBodyView: View {
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4), RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
alignment: .leading alignment: .leading
) )
.padding(.horizontal)
.padding(.bottom, 10) .padding(.bottom, 10)
if let url = event.url_ref { if let url = event.url_ref {
HighlightLink(state: state, url: url, content: event.event.content) HighlightLink(state: state, url: url, content: event.event.content)
.padding(.horizontal)
} else { } else {
if let evRef = event.event_ref { if let evRef = event.event_ref {
if let eventHex = hex_decode_id(evRef) { if let eventHex = hex_decode_id(evRef) {
HighlightEventRef(damus_state: state, event_ref: NoteId(eventHex)) HighlightEventRef(damus_state: state, event_ref: NoteId(eventHex))
.padding(.horizontal)
.padding(.top, 5) .padding(.top, 5)
} }
} }
+1 -1
View File
@@ -87,7 +87,7 @@ struct AddMuteItemView: View {
new_text = "" new_text = ""
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
dismiss() dismiss()
}) { }) {
@@ -96,7 +96,7 @@ struct DamusAppNotificationView: View {
@MainActor @MainActor
func open_url(url: URL) { func open_url(url: URL) {
UIApplication.shared.open(url) this_app.open(url)
} }
var body: some View { var body: some View {
+37 -6
View File
@@ -30,6 +30,7 @@ enum PostAction {
case replying_to(NostrEvent) case replying_to(NostrEvent)
case quoting(NostrEvent) case quoting(NostrEvent)
case posting(PostTarget) case posting(PostTarget)
case highlighting(HighlightContentDraft)
var ev: NostrEvent? { var ev: NostrEvent? {
switch self { switch self {
@@ -39,6 +40,8 @@ enum PostAction {
return ev return ev
case .posting: case .posting:
return nil return nil
case .highlighting:
return nil
} }
} }
} }
@@ -128,8 +131,13 @@ struct PostView: View {
} }
var posting_disabled: Bool { var posting_disabled: Bool {
switch action {
case .highlighting(_):
return false
default:
return is_post_empty || uploading_disabled return is_post_empty || uploading_disabled
} }
}
// Returns a valid height for the text box, even when textHeight is not a number // Returns a valid height for the text box, even when textHeight is not a number
func get_valid_text_height() -> CGFloat { func get_valid_text_height() -> CGFloat {
@@ -204,6 +212,8 @@ struct PostView: View {
damus_state.drafts.quotes.removeValue(forKey: quoting) damus_state.drafts.quotes.removeValue(forKey: quoting)
case .posting: case .posting:
damus_state.drafts.post = nil damus_state.drafts.post = nil
case .highlighting(let draft):
damus_state.drafts.highlights.removeValue(forKey: draft.source)
} }
} }
@@ -371,6 +381,9 @@ struct PostView: View {
if case .quoting(let ev) = action { if case .quoting(let ev) = action {
BuilderEventView(damus: damus_state, event: ev) BuilderEventView(damus: damus_state, event: ev)
} }
else if case .highlighting(let draft) = action {
HighlightDraftContentView(draft: draft)
}
} }
.padding(.horizontal) .padding(.horizontal)
} }
@@ -460,8 +473,9 @@ struct PostView: View {
references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting) references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting)
case .posting(let target): case .posting(let target):
guard !loaded_draft else { break } guard !loaded_draft else { break }
fill_target_content(target: target) fill_target_content(target: target)
case .highlighting(let draft):
references = [draft.source.ref()]
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
@@ -597,6 +611,8 @@ func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArti
drafts.quotes[ev] = artifacts drafts.quotes[ev] = artifacts
case .posting: case .posting:
drafts.post = artifacts drafts.post = artifacts
case .highlighting(let draft):
drafts.highlights[draft.source] = artifacts
} }
} }
@@ -608,6 +624,8 @@ func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts?
return drafts.quotes[ev] return drafts.quotes[ev]
case .posting: case .posting:
return drafts.post return drafts.post
case .highlighting(let draft):
return drafts.highlights[draft.source]
} }
} }
@@ -681,16 +699,29 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
} }
case .posting(let postTarget): case .posting(let postTarget):
break break
} case .highlighting(let draft):
break
// include pubkeys
tags += pubkeys.map { pk in
["p", pk.hex()]
} }
// append additional tags // append additional tags
tags += uploadedMedias.compactMap { $0.metadata?.to_tag() } tags += uploadedMedias.compactMap { $0.metadata?.to_tag() }
switch action {
case .highlighting(let draft):
tags.append(contentsOf: draft.source.tags())
if !(content.isEmpty || content.allSatisfy { $0.isWhitespace }) {
tags.append(["comment", content])
}
tags += pubkeys.map { pk in
["p", pk.hex(), "mention"]
}
return NostrPost(content: draft.selected_text, kind: .highlight, tags: tags)
default:
tags += pubkeys.map { pk in
["p", pk.hex()]
}
}
return NostrPost(content: content, kind: .text, tags: tags) return NostrPost(content: content, kind: .text, tags: tags)
} }
+1 -1
View File
@@ -356,7 +356,7 @@ struct ZapSheetViewIfPossible: View {
extension View { extension View {
func hideKeyboard() { func hideKeyboard() {
let resign = #selector(UIResponder.resignFirstResponder) let resign = #selector(UIResponder.resignFirstResponder)
UIApplication.shared.sendAction(resign, to: nil, from: nil, for: nil) this_app.sendAction(resign, to: nil, from: nil, for: nil)
} }
} }
+3 -3
View File
@@ -240,7 +240,7 @@ class ReplyTests: XCTestCase {
let content = "this is a @\(pk.npub) mention" let content = "this is a @\(pk.npub) mention"
let blocks = parse_post_blocks(content: content) let blocks = parse_post_blocks(content: content)
let post = NostrPost(content: content, tags: [["e", evid.hex()]]) let post = NostrPost(content: content, tags: [["e", evid.hex()]])
let ev = post_to_event(post: post, keypair: test_keypair_full)! let ev = post.to_event(keypair: test_keypair_full)!
XCTAssertEqual(ev.tags.count, 2) XCTAssertEqual(ev.tags.count, 2)
XCTAssertEqual(blocks.count, 3) XCTAssertEqual(blocks.count, 3)
@@ -255,7 +255,7 @@ class ReplyTests: XCTestCase {
let content = "this is a @\(nsec) mention" let content = "this is a @\(nsec) mention"
let blocks = parse_post_blocks(content: content) let blocks = parse_post_blocks(content: content)
let post = NostrPost(content: content, tags: [["e", evid.hex()]]) let post = NostrPost(content: content, tags: [["e", evid.hex()]])
let ev = post_to_event(post: post, keypair: test_keypair_full)! let ev = post.to_event(keypair: test_keypair_full)!
XCTAssertEqual(ev.tags.count, 2) XCTAssertEqual(ev.tags.count, 2)
XCTAssertEqual(blocks.count, 3) XCTAssertEqual(blocks.count, 3)
@@ -275,7 +275,7 @@ class ReplyTests: XCTestCase {
] ]
let post = NostrPost(content: "this is a (@\(pubkey.npub)) mention", tags: tags) let post = NostrPost(content: "this is a (@\(pubkey.npub)) mention", tags: tags)
let ev = post_to_event(post: post, keypair: test_keypair_full)! let ev = post.to_event(keypair: test_keypair_full)!
XCTAssertEqual(ev.content, "this is a (nostr:\(pubkey.npub)) mention") XCTAssertEqual(ev.content, "this is a (nostr:\(pubkey.npub)) mention")
XCTAssertEqual(ev.tags[2][1].string(), pubkey.description) XCTAssertEqual(ev.tags[2][1].string(), pubkey.description)
+2 -2
View File
@@ -193,7 +193,7 @@ class damusTests: XCTestCase {
func testMakeHashtagPost() { func testMakeHashtagPost() {
let post = NostrPost(content: "#damus some content #bitcoin derp #かっこいい wow", tags: []) let post = NostrPost(content: "#damus some content #bitcoin derp #かっこいい wow", tags: [])
let ev = post_to_event(post: post, keypair: test_keypair_full)! let ev = post.to_event(keypair: test_keypair_full)!
XCTAssertEqual(ev.tags.count, 3) XCTAssertEqual(ev.tags.count, 3)
XCTAssertEqual(ev.content, "#damus some content #bitcoin derp #かっこいい wow") XCTAssertEqual(ev.content, "#damus some content #bitcoin derp #かっこいい wow")
@@ -270,7 +270,7 @@ class damusTests: XCTestCase {
private func createEventFromContentString(_ content: String) -> NostrEvent { private func createEventFromContentString(_ content: String) -> NostrEvent {
let post = NostrPost(content: content, tags: []) let post = NostrPost(content: content, tags: [])
guard let ev = post_to_event(post: post, keypair: test_keypair_full) else { guard let ev = post.to_event(keypair: test_keypair_full) else {
XCTFail("Could not create event") XCTFail("Could not create event")
return test_note return test_note
} }
@@ -0,0 +1,287 @@
//
// ActionViewController.swift
// highlighter action extension
//
// Created by Daniel DAquino on 2024-08-09.
//
import UIKit
import MobileCoreServices
import UniformTypeIdentifiers
import SwiftUI
struct ShareExtensionView: View {
@State var highlighter_state: HighlighterState = .loading
let extensionContext: NSExtensionContext
@State var state: DamusState? = nil
@State var signedEvent: String? = nil
@State private var selectedText = ""
@State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero
@Environment(\.scenePhase) var scenePhase
var body: some View {
VStack(spacing: 15) {
if let state {
switch self.highlighter_state {
case .loading:
ProgressView()
case .no_highlight_text:
Group {
Text("No text selected", comment: "Title indicating that a highlight cannot be posted because no text was selected.")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
Text("You cannot post a highlight because you have selected no text on the page! Please close this, select some text, and try again.", comment: "Label explaining a highlight cannot be made because there was no selected text, and some instructions on how to resolve the issue")
.multilineTextAlignment(.center)
Button(action: {
self.done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying to post a highlight")
})
.foregroundStyle(.secondary)
}
case .not_logged_in:
Group {
Text("Not logged in", comment: "Title indicating that a highlight cannot be posted because the user is not logged in.")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
Text("You cannot post a highlight because you are not logged in with a private key! Please close this, login with a private key (or nsec), and try again.", comment: "Label explaining a highlight cannot be made because the user is not logged in")
.multilineTextAlignment(.center)
Button(action: {
self.done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying to post a highlight")
})
.foregroundStyle(.secondary)
}
case .loaded(let highlighted_text, let source_url):
PostView(
action: .highlighting(HighlightContentDraft(selected_text: highlighted_text, source: .external_url(source_url))),
damus_state: state
)
case .failed(let error):
Group {
Text("Error", comment: "Title indicating that an error has occurred.")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding()
Text("An unexpected error occurred. Please contact Damus support via [Nostr](damus:npub18m76awca3y37hkvuneavuw6pjj4525fw90necxmadrvjg0sdy6qsngq955) or [email](support@damus.io) with the error message below.", comment: "Label explaining there was an error, and suggesting next steps")
.multilineTextAlignment(.center)
Text("Error: \(error)")
Button(action: {
self.done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying to post a highlight")
})
.foregroundStyle(.secondary)
}
case .posted(event: let event):
Group {
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 60, height: 60)
Text("Posted", comment: "Title indicating that the user has posted a highlight successfully")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding(.bottom)
Link(destination: URL(string: "damus:\(event.id.bech32)")!, label: {
Text("Go to the app", comment: "Button label giving the user the option to go to the app after posting a highlight")
})
.buttonStyle(GradientButtonStyle())
Button(action: {
self.done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the sheet from which they posted a highlight")
})
.foregroundStyle(.secondary)
}
case .cancelled:
Group {
Text("Cancelled", comment: "Title indicating that the user has cancelled.")
.font(.largeTitle)
.padding()
Button(action: {
self.done()
}, label: {
Text("Close", comment: "Button label giving the user the option to close the sheet from which they were trying to post a highlight")
})
.foregroundStyle(.secondary)
}
case .posting:
Group {
ProgressView()
.frame(width: 20, height: 20)
Text("Posting", comment: "Title indicating that the highlight post is being published to the network")
.font(.largeTitle)
.multilineTextAlignment(.center)
.padding(.bottom)
Text("Your highlight is being broadcasted to the network. Please wait.", comment: "Label explaining there their highlight publishing action is in progress")
.multilineTextAlignment(.center)
.padding()
}
}
}
}
.onAppear(perform: {
self.loadSharedUrl()
guard let keypair = get_saved_keypair() else { return }
guard keypair.privkey != nil else {
self.highlighter_state = .not_logged_in
return
}
self.state = DamusState(keypair: keypair)
})
.onChange(of: self.highlighter_state) {
if case .cancelled = highlighter_state {
self.done()
}
}
.onReceive(handle_notify(.post)) { post_notification in
switch post_notification {
case .post(let post):
self.post(post)
case .cancel:
self.highlighter_state = .cancelled
}
}
.onChange(of: scenePhase) { (phase: ScenePhase) in
guard let state else { return }
switch phase {
case .background:
print("txn: 📙 HIGHLIGHTER BACKGROUNDED")
Task { @MainActor in
state.ndb.close()
}
break
case .inactive:
print("txn: 📙 HIGHLIGHTER INACTIVE")
break
case .active:
print("txn: 📙 HIGHLIGHTER ACTIVE")
state.pool.ping()
@unknown default:
break
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
guard let state else { return }
print("txn: 📙 HIGHLIGHTER ACTIVE NOTIFY")
if state.ndb.reopen() {
print("txn: HIGHLIGHTER NOSTRDB REOPENED")
} else {
print("txn: HIGHLIGHTER NOSTRDB FAILED TO REOPEN closed: \(state.ndb.is_closed)")
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { obj in
guard let state else { return }
print("txn: 📙 HIGHLIGHTER BACKGROUNDED")
Task { @MainActor in
state.ndb.close()
}
}
}
func loadSharedUrl() {
guard
let extensionItem = extensionContext.inputItems.first as? NSExtensionItem,
let itemProvider = extensionItem.attachments?.first else {
self.highlighter_state = .failed(error: "Can't get itemProvider")
return
}
let propertyList = UTType.propertyList.identifier
if itemProvider.hasItemConformingToTypeIdentifier(propertyList) {
itemProvider.loadItem(forTypeIdentifier: propertyList, options: nil, completionHandler: { (item, error) -> Void in
guard let dictionary = item as? NSDictionary else { return }
if error != nil {
self.highlighter_state = .failed(error: "Error loading plist item: \(error?.localizedDescription ?? "Unknown")")
return
}
OperationQueue.main.addOperation {
if let results = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary,
let urlString = results["URL"] as? String,
let selection = results["selectedText"] as? String,
let url = URL(string: urlString) {
guard selection != "" else {
self.highlighter_state = .no_highlight_text
return
}
self.highlighter_state = .loaded(highlighted_text: selection, source_url: url)
}
else {
self.highlighter_state = .failed(error: "Cannot load results")
}
}
})
}
else {
self.highlighter_state = .failed(error: "No plist detected")
}
}
func post(_ post: NostrPost) {
self.highlighter_state = .posting
guard let state else {
self.highlighter_state = .failed(error: "Damus state not initialized")
return
}
guard let full_keypair = state.keypair.to_full() else {
self.highlighter_state = .not_logged_in
return
}
guard let posted_event = post.to_event(keypair: full_keypair) else {
self.highlighter_state = .failed(error: "Cannot convert post data into a nostr event")
return
}
state.postbox.send(posted_event, on_flush: .once({ flushed_event in
if flushed_event.event.id == posted_event.id {
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { // Offset labor perception bias
self.highlighter_state = .posted(event: flushed_event.event)
})
}
else {
self.highlighter_state = .failed(error: "Flushed event is not the event we just tried to post.")
}
}))
}
func done() {
self.extensionContext.completeRequest(returningItems: [], completionHandler: nil)
}
enum HighlighterState: Equatable {
case loading
case no_highlight_text
case not_logged_in
case loaded(highlighted_text: String, source_url: URL)
case posting
case posted(event: NostrEvent)
case cancelled
case failed(error: String)
}
}
class ActionViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
self.view.tintColor = UIColor(DamusColors.purple)
DispatchQueue.main.async {
let contentView = UIHostingController(rootView: ShareExtensionView(extensionContext: self.extensionContext!))
self.addChild(contentView)
self.view.addSubview(contentView.view)
// set up constraints
contentView.view.translatesAutoresizingMaskIntoConstraints = false
contentView.view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
contentView.view.bottomAnchor.constraint (equalTo: self.view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint (equalTo: self.view.rightAnchor).isActive = true
}
}
}
@@ -0,0 +1,11 @@
//
// HighlighterExtensionAliases.swift
// highlighter action extension
//
// Created by Daniel DAquino on 2024-08-12.
//
import Foundation
import UIKit
let this_app: UIApplication = UIApplication()
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>TRUEPREDICATE</string>
<key>NSExtensionJavaScriptPreprocessingFile</key>
<string>getSelection</string>
<key>NSExtensionServiceAllowsFinderPreviewItem</key>
<true/>
<key>NSExtensionServiceAllowsTouchBarItem</key>
<true/>
<key>NSExtensionServiceFinderPreviewIconName</key>
<string>NSActionTemplate</string>
<key>NSExtensionServiceTouchBarBezelColorName</key>
<string>TouchBarBezel</string>
<key>NSExtensionServiceTouchBarIconName</key>
<string>NSActionTemplate</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ActionViewController</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "highlighter.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,14 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
},
"colors" : [
{
"idiom" : "mac",
"color" : {
"reference" : "systemPurpleColor"
}
}
]
}
@@ -0,0 +1,12 @@
var Share = function() {};
Share.prototype = {
run: function(arguments) {
arguments.completionFunction({"URL": document.URL, "selectedText": document.getSelection().toString()});
},
finalize: function(arguments) {
// alert shared!
}
};
var ExtensionPreprocessingJS = new Share
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.damus</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.jb55.damus2</string>
</array>
</dict>
</plist>
+7
View File
@@ -336,6 +336,10 @@ extension NdbNote {
References<MuteItem>(tags: self.tags) References<MuteItem>(tags: self.tags)
} }
public var referenced_comment_items: References<CommentItem> {
References<CommentItem>(tags: self.tags)
}
public var references: References<RefId> { public var references: References<RefId> {
References<RefId>(tags: self.tags) References<RefId>(tags: self.tags)
} }
@@ -355,6 +359,9 @@ extension NdbNote {
if known_kind == .dm { if known_kind == .dm {
return decrypted(keypair: keypair) ?? "*failed to decrypt content*" return decrypted(keypair: keypair) ?? "*failed to decrypt content*"
} }
else if known_kind == .highlight {
return self.referenced_comment_items.first?.content ?? ""
}
return content return content
} }
+11 -2
View File
@@ -4893,8 +4893,17 @@ mdb_env_setup_locks(MDB_env *env, MDB_name *fname, int mode, int *excl)
#ifdef MDB_SHORT_SEMNAMES #ifdef MDB_SHORT_SEMNAMES
encbuf[9] = '\0'; /* drop name from 15 chars to 14 chars */ encbuf[9] = '\0'; /* drop name from 15 chars to 14 chars */
#endif #endif
sprintf(env->me_txns->mti_rmname, "/MDBr%s", encbuf);
sprintf(env->me_txns->mti_wmname, "/MDBw%s", encbuf); #define DEF_STR(x) #x
#define DEF_TO_STRING(x) DEF_STR(x)
sprintf(env->me_txns->mti_rmname, DEF_TO_STRING(MDB_SEM_NAME_PREFIX) "/MDBr%s", encbuf);
sprintf(env->me_txns->mti_wmname, DEF_TO_STRING(MDB_SEM_NAME_PREFIX) "/MDBw%s", encbuf);
#undef DEF_STR
#undef DEF_TO_STRING
printf("mdb_env_setup_locks: using semnames '%s' (%d), '%s' (%d)\n",
env->me_txns->mti_rmname, strlen(env->me_txns->mti_rmname),
env->me_txns->mti_wmname, strlen(env->me_txns->mti_wmname));
/* Clean up after a previous run, if needed: Try to /* Clean up after a previous run, if needed: Try to
* remove both semaphores before doing anything else. * remove both semaphores before doing anything else.
*/ */