Files
damus/damus/Views/NoteContentView.swift
T
Daniel D’Aquino 4703ed80a7 Damus Purple initial Proof-of-Concept support
This commit includes various code changes necessary to get a basic proof of concept of the feature working.

This is NOT a full working feature yet, only a preliminary prototype/PoC. It includes:
- [X] Basic Storekit configuration
- [X] Basic purchase mechanism
- [X] Basic layout and copywriting
- [X] Basic design
- [X] Manage button (To help user cancel their subscription)
- [X] Thank you confirmation + special welcome view
- [X] Star badge on profile (by checking the Damus Purple API)
- [X] Connection to Damus purple API for fetching account info, registering for an account and sending over the App Store receipt data

The feature sits behind a feature flag which is OFF by default (it can be turned ON via Settings --> Developer settings --> Enable experimental Purple API and restarting the app)

Testing
---------

PASS

Device: iPhone 15 Pro simulator
iOS: 17.0.1
Damus: This commit
damus-api: 59ce44a92cff1c1aaed9886f9befbd5f1053821d
Server: Ubuntu 22.04 (VM)
Setup:
1. On the server, delete the `mdb` database files to start from scratch
2. In iOS, reinstall the app if necessary to make sure there are no in-app purchases
3. Enable subscriptions support via developer settings with localhost test mode and restart app
4. Start server with mock parameters (Run `npm run dev`)

Steps:
1. Open top bar and click on "Purple"
2. Purple screen should appear and show both benefits and the purchase options. PASS
3. Click on "monthly". An Apple screen to confirm purchase should appear. PASS
4. Welcome screen with animation should appear. PASS
5. Click continue and restart app (Due to known issue tracked at https://github.com/damus-io/damus/issues/1814)
6. Post something
7. Gold star should appear beside your name
8. Look at the server logs. There should be some requests to create the account (POST), to send the receipt (POST), and to get account status
9. Go to purple view. There should be some information about the subscription, as well as a "manage" button. PASS
10. Click on "manage" button. An iOS sheet should appear allow the user to unsubscribe or manage their subscription to Damus Purple.

Feature flag testing
--------------------

PASS

Preconditions: Continue from above test
Steps:
1. Disable Damus Purple experiment support on developer settings. Restart the app.
2. Check your post. There should be no star beside your profile name. PASS
3. Check side menu. There should be no "Damus Purple" option. PASS
4. Check server logs. There should be no new requests being done to the server. PASS

Closes: https://github.com/damus-io/damus/issues/1422
2023-12-24 09:30:26 -08:00

736 lines
23 KiB
Swift

//
// NoteContentView.swift
// damus
//
// Created by William Casarin on 2022-05-04.
//
import SwiftUI
import LinkPresentation
import NaturalLanguage
import MarkdownUI
struct Blur: UIViewRepresentable {
var style: UIBlurEffect.Style = .systemUltraThinMaterial
func makeUIView(context: Context) -> UIVisualEffectView {
return UIVisualEffectView(effect: UIBlurEffect(style: style))
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: style)
}
}
struct NoteContentView: View {
let damus_state: DamusState
let event: NostrEvent
@State var blur_images: Bool
@State var load_media: Bool = false
let size: EventViewKind
let preview_height: CGFloat?
let options: EventViewOptions
@ObservedObject var artifacts_model: NoteArtifactsModel
@ObservedObject var preview_model: PreviewModel
@ObservedObject var settings: UserSettingsStore
var note_artifacts: NoteArtifacts {
return self.artifacts_model.state.artifacts ?? .separated(.just_content(event.get_content(damus_state.keypair)))
}
init(damus_state: DamusState, event: NostrEvent, blur_images: Bool, size: EventViewKind, options: EventViewOptions) {
self.damus_state = damus_state
self.event = event
self.blur_images = blur_images
self.size = size
self.options = options
self.preview_height = lookup_cached_preview_size(previews: damus_state.previews, evid: event.id)
let cached = damus_state.events.get_cache_data(event.id)
self._preview_model = ObservedObject(wrappedValue: cached.preview_model)
self._artifacts_model = ObservedObject(wrappedValue: cached.artifacts_model)
self._settings = ObservedObject(wrappedValue: damus_state.settings)
}
var truncate: Bool {
return options.contains(.truncate_content)
}
var with_padding: Bool {
return options.contains(.wide)
}
var preview: LinkViewRepresentable? {
guard blur_images,
case .loaded(let preview) = preview_model.state,
case .value(let cached) = preview else {
return nil
}
return LinkViewRepresentable(meta: .linkmeta(cached))
}
func truncatedText(content: CompatibleText) -> some View {
Group {
if truncate {
TruncatedText(text: content)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
} else {
content.text
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
}
}
func invoicesView(invoices: [Invoice]) -> some View {
InvoicesView(our_pubkey: damus_state.keypair.pubkey, invoices: invoices, settings: damus_state.settings)
}
var translateView: some View {
TranslateView(damus_state: damus_state, event: event, size: self.size)
}
func previewView(links: [URL]) -> some View {
Group {
if let preview = self.preview, blur_images {
if let preview_height {
preview
.frame(height: preview_height)
} else {
preview
}
} else if let link = links.first {
LinkViewRepresentable(meta: .url(link))
.frame(height: 50)
}
}
}
func MainContent(artifacts: NoteArtifactsSeparated) -> some View {
VStack(alignment: .leading) {
if size == .selected {
if with_padding {
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
.padding(.horizontal)
} else {
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
}
} else {
if with_padding {
truncatedText(content: artifacts.content)
.padding(.horizontal)
} else {
truncatedText(content: artifacts.content)
}
}
if !options.contains(.no_translate) && (size == .selected || damus_state.settings.auto_translate) {
if with_padding {
translateView
.padding(.horizontal)
} else {
translateView
}
}
if artifacts.media.count > 0 {
if !damus_state.settings.media_previews && !load_media {
loadMediaButton(artifacts: artifacts)
} else if !blur_images || (!blur_images && !damus_state.settings.media_previews && load_media) {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media)
} else if blur_images || (blur_images && !damus_state.settings.media_previews && load_media) {
ZStack {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media)
Blur()
.onTapGesture {
blur_images = false
}
}
}
}
if artifacts.invoices.count > 0 {
if with_padding {
invoicesView(invoices: artifacts.invoices)
.padding(.horizontal)
} else {
invoicesView(invoices: artifacts.invoices)
}
}
if damus_state.settings.media_previews {
if with_padding {
previewView(links: artifacts.links).padding(.horizontal)
} else {
previewView(links: artifacts.links)
}
}
}
}
func loadMediaButton(artifacts: NoteArtifactsSeparated) -> some View {
Button(action: {
load_media = true
}, label: {
VStack(alignment: .leading) {
HStack {
Image("images")
Text("Load media", comment: "Button to show media in note.")
.fontWeight(.bold)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
}
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
ForEach(artifacts.media.indices, id: \.self) { index in
Divider()
.frame(height: 1)
switch artifacts.media[index] {
case .image(let url), .video(let url):
Text(url.absoluteString)
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
.foregroundStyle(DamusColors.neutral6)
.multilineTextAlignment(.leading)
.padding(EdgeInsets(top: 0, leading: 10, bottom: 5, trailing: 10))
}
}
}
.background(DamusColors.neutral1)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(DamusColors.neutral3, lineWidth: 1)
)
})
.padding(.horizontal)
}
func load(force_artifacts: Bool = false) {
if case .loading = damus_state.events.get_cache_data(event.id).artifacts_model.state {
return
}
// always reload artifacts on load
let plan = get_preload_plan(evcache: damus_state.events, ev: event, our_keypair: damus_state.keypair, settings: damus_state.settings)
// TODO: make this cleaner
Task {
// this is surprisingly slow
let rel = format_relative_time(event.created_at)
Task { @MainActor in
self.damus_state.events.get_cache_data(event.id).relative_time.value = rel
}
if var plan {
if force_artifacts {
plan.load_artifacts = true
}
await preload_event(plan: plan, state: damus_state)
} else if force_artifacts {
let arts = render_note_content(ev: event, profiles: damus_state.profiles, keypair: damus_state.keypair)
self.artifacts_model.state = .loaded(arts)
}
}
}
func artifactPartsView(_ parts: [ArtifactPart]) -> some View {
LazyVStack(alignment: .leading) {
ForEach(parts.indices, id: \.self) { ind in
let part = parts[ind]
switch part {
case .text(let txt):
if with_padding {
txt.padding(.horizontal)
} else {
txt
}
case .invoice(let inv):
if with_padding {
InvoiceView(our_pubkey: damus_state.pubkey, invoice: inv, settings: damus_state.settings)
.padding(.horizontal)
} else {
InvoiceView(our_pubkey: damus_state.pubkey, invoice: inv, settings: damus_state.settings)
}
case .media(let media):
Text(verbatim: "media \(media.url.absoluteString)")
}
}
}
}
var ArtifactContent: some View {
Group {
switch self.note_artifacts {
case .longform(let md):
Markdown(md.markdown)
.padding([.leading, .trailing, .top])
case .separated(let separated):
MainContent(artifacts: separated)
}
}
.fixedSize(horizontal: false, vertical: true)
}
var body: some View {
ArtifactContent
.onReceive(handle_notify(.profile_updated)) { profile in
let blocks = event.blocks(damus_state.keypair)
for block in blocks.blocks {
switch block {
case .mention(let m):
if case .pubkey(let pk) = m.ref, pk == profile.pubkey {
load(force_artifacts: true)
return
}
case .relay: return
case .text: return
case .hashtag: return
case .url: return
case .invoice: return
}
}
}
.onAppear {
load()
}
}
}
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
let wrapped = icon_attributed_string(img: img)
astr.append(wrapped)
}
func icon_attributed_string(img: UIImage) -> AttributedString {
let attachment = NSTextAttachment()
attachment.image = img
let attachmentString = NSAttributedString(attachment: attachment)
return AttributedString(attachmentString)
}
func url_str(_ url: URL) -> CompatibleText {
var attributedString = AttributedString(stringLiteral: url.absoluteString)
attributedString.link = url
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText {
switch m.ref {
case .pubkey(let pk):
let npub = bech32_pubkey(pk)
let profile_txn = profiles.lookup(id: pk)
let profile = profile_txn.unsafeUnownedValue
let disp = Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
var attributedString = AttributedString(stringLiteral: "@\(disp)")
attributedString.link = URL(string: "damus:nostr:\(npub)")
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
case .note(let note_id):
let bevid = bech32_note_id(note_id)
var attributedString = AttributedString(stringLiteral: "@\(abbrev_pubkey(bevid))")
attributedString.link = URL(string: "damus:nostr:\(bevid)")
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
}
struct LongformContent {
let markdown: MarkdownContent
let words: Int
init(_ markdown: String) {
let blocks = [BlockNode].init(markdown: markdown)
self.markdown = MarkdownContent(blocks: blocks)
self.words = count_markdown_words(blocks: blocks)
}
}
enum NoteArtifacts {
case separated(NoteArtifactsSeparated)
case longform(LongformContent)
var images: [URL] {
switch self {
case .separated(let arts):
return arts.images
case .longform:
return []
}
}
}
enum ArtifactPart {
case text(Text)
case media(MediaUrl)
case invoice(Invoice)
var is_text: Bool {
switch self {
case .text: return true
case .media: return false
case .invoice: return false
}
}
}
class NoteArtifactsParts {
var parts: [ArtifactPart]
var words: Int
init(parts: [ArtifactPart], words: Int) {
self.parts = parts
self.words = words
}
}
struct NoteArtifactsSeparated: Equatable {
static func == (lhs: NoteArtifactsSeparated, rhs: NoteArtifactsSeparated) -> Bool {
return lhs.content == rhs.content
}
let content: CompatibleText
let words: Int
let urls: [UrlType]
let invoices: [Invoice]
var media: [MediaUrl] {
return urls.compactMap { url in url.is_media }
}
var images: [URL] {
return urls.compactMap { url in url.is_img }
}
var links: [URL] {
return urls.compactMap { url in url.is_link }
}
static func just_content(_ content: String) -> NoteArtifactsSeparated {
let txt = CompatibleText(attributed: AttributedString(stringLiteral: content))
return NoteArtifactsSeparated(content: txt, words: 0, urls: [], invoices: [])
}
}
enum NoteArtifactState {
case not_loaded
case loading
case loaded(NoteArtifacts)
var artifacts: NoteArtifacts? {
if case .loaded(let artifacts) = self {
return artifacts
}
return nil
}
var should_preload: Bool {
switch self {
case .loaded:
return false
case .loading:
return false
case .not_loaded:
return true
}
}
}
func note_artifact_is_separated(kind: NostrKind?) -> Bool {
return kind != .longform
}
func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> NoteArtifacts {
let blocks = ev.blocks(keypair)
if ev.known_kind == .longform {
return .longform(LongformContent(ev.content))
}
return .separated(render_blocks(blocks: blocks, profiles: profiles))
}
fileprivate func artifact_part_last_text_ind(parts: [ArtifactPart]) -> (Int, Text)? {
let ind = parts.count - 1
if ind < 0 {
return nil
}
guard case .text(let txt) = parts[safe: ind] else {
return nil
}
return (ind, txt)
}
func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String {
var trimmed = txt
if let prev = blocks[safe: ind-1],
case .url(let u) = prev,
classify_url(u).is_media != nil {
trimmed = " " + trim_prefix(trimmed)
}
if let next = blocks[safe: ind+1] {
if case .url(let u) = next, classify_url(u).is_media != nil {
trimmed = trim_suffix(trimmed)
} else if case .mention(let m) = next,
case .note = m.ref,
one_note_ref {
trimmed = trim_suffix(trimmed)
}
}
return trimmed
}
func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated {
var invoices: [Invoice] = []
var urls: [UrlType] = []
let blocks = bs.blocks
let one_note_ref = blocks
.filter({
if case .mention(let mention) = $0,
case .note = mention.ref {
return true
}
else {
return false
}
})
.count == 1
var ind: Int = -1
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
ind = ind + 1
switch block {
case .mention(let m):
if case .note = m.ref, one_note_ref {
return str
}
return str + mention_str(m, profiles: profiles)
case .text(let txt):
return str + CompatibleText(stringLiteral: reduce_text_block(blocks: blocks, ind: ind, txt: txt, one_note_ref: one_note_ref))
case .relay(let relay):
return str + CompatibleText(stringLiteral: relay)
case .hashtag(let htag):
return str + hashtag_str(htag)
case .invoice(let invoice):
invoices.append(invoice)
return str
case .url(let url):
let url_type = classify_url(url)
switch url_type {
case .media:
urls.append(url_type)
return str
case .link(let url):
urls.append(url_type)
return str + url_str(url)
}
}
}
return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
}
enum MediaUrl {
case image(URL)
case video(URL)
var url: URL {
switch self {
case .image(let url):
return url
case .video(let url):
return url
}
}
}
enum UrlType {
case media(MediaUrl)
case link(URL)
var url: URL {
switch self {
case .media(let media_url):
switch media_url {
case .image(let url):
return url
case .video(let url):
return url
}
case .link(let url):
return url
}
}
var is_video: URL? {
switch self {
case .media(let media_url):
switch media_url {
case .image:
return nil
case .video(let url):
return url
}
case .link:
return nil
}
}
var is_img: URL? {
switch self {
case .media(let media_url):
switch media_url {
case .image(let url):
return url
case .video:
return nil
}
case .link:
return nil
}
}
var is_link: URL? {
switch self {
case .media:
return nil
case .link(let url):
return url
}
}
var is_media: MediaUrl? {
switch self {
case .media(let murl):
return murl
case .link:
return nil
}
}
}
func classify_url(_ url: URL) -> UrlType {
let str = url.lastPathComponent.lowercased()
if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") {
return .media(.image(url))
}
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
return .media(.video(url))
}
return .link(url)
}
func lookup_cached_preview_size(previews: PreviewCache, evid: NoteId) -> CGFloat? {
guard case .value(let cached) = previews.lookup(evid) else {
return nil
}
guard let height = cached.intrinsic_height else {
return nil
}
return height
}
// trim suffix whitespace and newlines
func trim_suffix(_ str: String) -> String {
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
}
// trim prefix whitespace and newlines
func trim_prefix(_ str: String) -> String {
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
}
struct NoteContentView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
let state2 = test_damus_state
Group {
VStack {
NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .normal, options: [])
}
.previewDisplayName("Short note")
VStack {
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: [])
}
.previewDisplayName("Note with image")
VStack {
NoteContentView(damus_state: state2, event: test_longform_event.event, blur_images: false, size: .normal, options: [.wide])
.border(Color.red)
}
.previewDisplayName("Long-form note")
}
}
}
func count_words(_ s: String) -> Int {
return s.components(separatedBy: .whitespacesAndNewlines).count
}
func count_inline_nodes_words(nodes: [InlineNode]) -> Int {
return nodes.reduce(0) { words, node in
switch node {
case .text(let words):
return count_words(words)
case .emphasis(let children):
return words + count_inline_nodes_words(nodes: children)
case .strong(let children):
return words + count_inline_nodes_words(nodes: children)
case .strikethrough(let children):
return words + count_inline_nodes_words(nodes: children)
case .softBreak, .lineBreak, .code, .html, .image, .link:
return words
}
}
}
func count_markdown_words(blocks: [BlockNode]) -> Int {
return blocks.reduce(0) { words, block in
switch block {
case .paragraph(let content):
return words + count_inline_nodes_words(nodes: content)
case .blockquote, .bulletedList, .numberedList, .taskList, .codeBlock, .htmlBlock, .heading, .table, .thematicBreak:
return words
}
}
}
func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
let urlBlocks: [URL] = ev.blocks(keypair).blocks.reduce(into: []) { urls, block in
guard case .url(let url) = block else {
return
}
if classify_url(url).is_img != nil {
urls.append(url)
}
}
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls
}