Merge Highlighter

This brings Daniel's highlighter safari extension to master/testflight.
Previously we only had it on the 1.10 release branch. This also includes
some extended virtual addressing fixes to fix push notifications, we
also update the push notification server address since that seems to
have been missed.

Daniel D’Aquino (8):
      Update push notification server address
      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 (5):
      lmdb: patch semaphore names to use shared group container prefix
      Revert "ux: Mute selected text"
      notifications: add extended virtual addressing entitlement
      highlighter: add extended virtual addressing entitlement
This commit is contained in:
William Casarin
2024-09-01 07:26:57 -07:00
46 changed files with 2284 additions and 275 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))
}
+45 -42
View File
@@ -13,9 +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 showMutePost = false
@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
@@ -38,9 +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
showMutePost: $showMutePost, self.highlightPostingState = .show_post_view(highlighted_text: selectedText)
selectedText: $selectedText, },
height: $selectedTextHeight height: $selectedTextHeight
) )
.padding([.leading, .trailing], -1.0) .padding([.leading, .trailing], -1.0)
@@ -55,17 +53,19 @@ 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
.presentationDragIndicator(.visible) self.highlightPostingState = newValue ? .show_post_view(highlighted_text: self.highlightPostingState.highlighted_text() ?? "") : .hide
.presentationDetents([.height(selectedTextHeight + 150), .medium, .large]) })) {
} if let event, case .show_post_view(let highlighted_text) = self.highlightPostingState {
} PostView(
.sheet(isPresented: $showMutePost) { action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
AddMuteItemView(state: damus_state, new_text: $selectedText) damus_state: damus_state
)
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
.presentationDetents([.height(300), .medium, .large]) .presentationDetents([.height(selectedTextHeight + 450), .medium, .large])
}
} }
.frame(height: selectedTextHeight) .frame(height: selectedTextHeight)
} }
@@ -73,17 +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 showMutePost: Bool
@Binding var selectedText: String
init(frame: CGRect, textContainer: NSTextContainer?, showHighlightPost: Binding<Bool>, showMutePost: Binding<Bool>, selectedText: Binding<String>) { init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void) {
self._showHighlightPost = showHighlightPost self.postHighlight = postHighlight
self._showMutePost = showMutePost
self._selectedText = selectedText
super.init(frame: frame, textContainer: textContainer) super.init(frame: frame, textContainer: textContainer)
} }
@@ -95,24 +112,13 @@ fileprivate class TextView: UITextView {
if action == #selector(highlightText(_:)) { if action == #selector(highlightText(_:)) {
return true return true
} }
if action == #selector(muteText(_:)) {
return true
}
return super.canPerformAction(action, withSender: sender) return super.canPerformAction(action, withSender: sender)
} }
@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)
}
@objc public func muteText(_ sender: Any?) {
guard let selectedRange = self.selectedTextRange else { return }
selectedText = self.text(in: selectedRange) ?? ""
showMutePost.toggle()
} }
} }
@@ -125,13 +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 showMutePost: Bool
@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, showMutePost: $showMutePost, 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
@@ -144,8 +148,7 @@ fileprivate class TextView: UITextView {
let menuController = UIMenuController.shared let menuController = UIMenuController.shared
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:))) let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:))) menuController.menuItems = self.enableHighlighting ? [highlightItem] : []
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
return view return view
} }
+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?
@@ -819,7 +823,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
@@ -1051,7 +1055,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)
@@ -1112,7 +1116,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
@@ -74,6 +74,79 @@ class DamusState: HeadlessDamusState {
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings) self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
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 {
+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
@@ -13,22 +13,203 @@ struct HighlightEvent {
var event_ref: String? = nil var event_ref: String? = nil
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
} }
+1 -1
View File
@@ -220,7 +220,7 @@ extension PushNotificationClient {
case .local_test(let host): case .local_test(let host):
URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL
case .production: case .production:
Constants.PURPLE_API_PRODUCTION_BASE_URL Constants.PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL
} }
} }
+12 -7
View File
@@ -122,20 +122,22 @@ 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 {
case .event: return .e case .event: return .e
case .pubkey: return .p case .pubkey: return .p
case .quote: return .q case .quote: return .q
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())
} }
} }
} }
+1 -1
View File
@@ -14,7 +14,7 @@ class Constants {
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService" static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
// MARK: Push notification server // MARK: Push notification server
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "http://45.33.32.5:8000")! static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
static let PUSH_NOTIFICATION_SERVER_TEST_BASE_URL: URL = URL(string: "http://localhost:8000")! static let PUSH_NOTIFICATION_SERVER_TEST_BASE_URL: URL = URL(string: "http://localhost:8000")!
// MARK: Purple // MARK: Purple
+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)
} }
} }
+3 -3
View File
@@ -8,7 +8,7 @@ import SwiftUI
struct AddMuteItemView: View { struct AddMuteItemView: View {
let state: DamusState let state: DamusState
@Binding var new_text: String @State var new_text: String = ""
@State var expiration: DamusDuration = .indefinite @State var expiration: DamusDuration = .indefinite
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@@ -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()
}) { }) {
@@ -108,6 +108,6 @@ struct AddMuteItemView: View {
struct AddMuteItemView_Previews: PreviewProvider { struct AddMuteItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
AddMuteItemView(state: test_damus_state, new_text: .constant("")) AddMuteItemView(state: test_damus_state)
} }
} }
+7 -5
View File
@@ -15,8 +15,6 @@ struct MutelistView: View {
@State var hashtags: [MuteItem] = [] @State var hashtags: [MuteItem] = []
@State var threads: [MuteItem] = [] @State var threads: [MuteItem] = []
@State var words: [MuteItem] = [] @State var words: [MuteItem] = []
@State var new_text: String = ""
func RemoveAction(item: MuteItem) -> some View { func RemoveAction(item: MuteItem) -> some View {
Button { Button {
@@ -122,9 +120,13 @@ struct MutelistView: View {
} }
} }
.sheet(isPresented: $show_add_muteitem, onDismiss: { self.show_add_muteitem = false }) { .sheet(isPresented: $show_add_muteitem, onDismiss: { self.show_add_muteitem = false }) {
AddMuteItemView(state: damus_state, new_text: $new_text) if #available(iOS 16.0, *) {
.presentationDetents([.height(300)]) AddMuteItemView(state: damus_state)
.presentationDragIndicator(.visible) .presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
} else {
AddMuteItemView(state: damus_state)
}
} }
} }
} }
@@ -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 {
+61 -30
View File
@@ -30,15 +30,18 @@ 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 {
case .replying_to(let ev): case .replying_to(let ev):
return ev return ev
case .quoting(let ev): case .quoting(let ev):
return ev return ev
case .posting: case .posting:
return nil return nil
case .highlighting:
return nil
} }
} }
} }
@@ -128,7 +131,12 @@ struct PostView: View {
} }
var posting_disabled: Bool { var posting_disabled: Bool {
return is_post_empty || uploading_disabled switch action {
case .highlighting(_):
return false
default:
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
@@ -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)
} }
@@ -454,14 +467,15 @@ struct PostView: View {
let loaded_draft = load_draft() let loaded_draft = load_draft()
switch action { switch action {
case .replying_to(let replying_to): case .replying_to(let replying_to):
references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to) references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to)
case .quoting(let quoting): case .quoting(let quoting):
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]
} }
} }
@@ -669,27 +687,40 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
var tags: [[String]] = [] var tags: [[String]] = []
switch action { switch action {
case .replying_to(let replying_to): case .replying_to(let replying_to):
// start off with the reply tags // start off with the reply tags
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair) tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
case .quoting(let ev): case .quoting(let ev):
content.append(" nostr:" + bech32_note_id(ev.id)) content.append(" nostr:" + bech32_note_id(ev.id))
if let quoted_ev = state.events.lookup(ev.id) { if let quoted_ev = state.events.lookup(ev.id) {
tags.append(["p", quoted_ev.pubkey.hex()]) tags.append(["p", quoted_ev.pubkey.hex()])
} }
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
@@ -335,6 +335,10 @@ extension NdbNote {
public var referenced_mute_items: References<MuteItem> { public var referenced_mute_items: References<MuteItem> {
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.
*/ */