Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
668532c
updating build settings
rabble Feb 2, 2025
fe9c2dc
feat: Add NIP-68 picture-first event support to JSONEvent and NoteCard
rabble Feb 2, 2025
a0185af
feat: Add picturePost event kind support for NIP-68
rabble Feb 2, 2025
1ea55de
fix: Add pictureFirst case to CardStyle and update NoteCard comparison
rabble Feb 2, 2025
bc29168
fix: Handle pictureFirst case in CardButtonStyle cornerRadius switch
rabble Feb 2, 2025
171b28d
fix: Add missing .pictureFirst case and correct event kind comparison
rabble Feb 2, 2025
77869d4
refactor: Update note kind comparison to use EventKind.picturePost.ra…
rabble Feb 2, 2025
a3fc16f
refactor: Extract picture-first note card to separate component
rabble Feb 2, 2025
7190e29
refactor: Remove unnecessary blank lines in PictureFirstNoteCard view
rabble Feb 2, 2025
1a7c100
fix: Handle optional tags and type casting in PictureFirstNoteCard
rabble Feb 2, 2025
3a576fa
fix: Correct nil content check in PictureFirstNoteCard view
rabble Feb 2, 2025
9e1da15
refactor: Improve optional content handling in PictureFirstNoteCard
rabble Feb 2, 2025
f608889
feat: Automatically include kind 20 picture-first events when request…
rabble Feb 2, 2025
7d3c9c8
feat: Add logging for kind 20 events in RelayService
rabble Feb 2, 2025
06fab06
fix: Correct log statement syntax in RelayService
rabble Feb 2, 2025
25dd772
first working version supporting kind 20 events
rabble Feb 3, 2025
d38b92f
fixing the layout a bit
rabble Feb 3, 2025
8256714
feat: Add support for NIP-71 video events (kinds 21 and 22)
rabble Feb 3, 2025
85d69ba
adding support for displaying kind 20 events, kind 21, and 22 aren't …
rabble Feb 4, 2025
4d012f2
adding change log
rabble Feb 4, 2025
f7c1de8
Improve NIP-68/71 implementation
rabble Apr 6, 2025
fca2a43
Add tests and documentation for NIP-68/71 implementation
rabble Apr 7, 2025
dcef592
Fix NIP-71 video playback implementation
rabble Apr 7, 2025
13ab5fd
Fix missing Logger import in RepliesLabel.swift
rabble Apr 7, 2025
6396997
Fix linting issues and add tests for media tag helper methods
rabble Apr 7, 2025
41dcd3c
Fix VideoNoteCard error handling conditional binding
rabble Apr 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,4 @@ fastlane/test_output

.idea
.vscode
.aider*
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
- Added support for displaying kind 20 picture notes generated by Olas.

### Release Notes
- Fixed: adding/removing relays not reflected on feed filter. [#119](https://github.com/verse-pbc/issues/issues/119)
Expand Down
99 changes: 99 additions & 0 deletions KIND_20_IMPROVEMENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# NIP-68/71 Implementation Improvement Plan

This document outlines the plan to improve the implementation of NIP-68 (Picture Posts - kind 20) and NIP-71 (Video Posts - kinds 21 & 22) in the Nos app.

## Overview

The current implementation adds support for the following Nostr event kinds:
- Kind 20: Picture Post (NIP-68)
- Kind 21: Video Post (NIP-71)
- Kind 22: Short-form Video Post (NIP-71)

While the core functionality is in place, there are several areas that could be improved for better code maintainability, performance, and user experience.

## Improvement Checklist

### Code Structure Issues

- [x] **1. Remove duplicate `videoPost` method in JSONEvent+Kinds.swift**
- There are two identical method definitions
- Keep only one implementation to avoid confusion

- [x] **2. Simplify constructor initialization in PictureNoteCard and VideoNoteCard**
- Refactor the verbose initialization to a more concise version using tuples
- Makes the code more maintainable

### UI and UX Improvements

- [x] **3. Add better error handling in VideoNoteCard**
- Add a loading indicator during video loading
- Add a fallback UI when a video URL is invalid
- Improve the user experience when videos don't load

- [x] **4. Fix bug in conditional rendering in NoteCard.swift**
- Correct the condition that uses `EventKind.picturePost.rawValue` for VideoNoteCard
- Should be checking for `EventKind.video.rawValue` instead

### Refactoring for Better Maintainability

- [x] **5. Implement helper methods for tag parsing**
- Create an extension to `Event` with helper methods
- Add `getTagValue`, `getImageMetaTags`, and `getURLFromTag` methods
- Refactor PictureNoteCard and VideoNoteCard to use these helpers

- [x] **6. Improve RepliesLabel error handling**
- Add better error handling for AttributedString creation
- Add fallback when markdown parsing fails
- Log errors for debugging

- [x] **7. Optimize performance in RepliesLabel**
- Add throttling mechanism to avoid excessive recomputation
- Consider using a Combine-based approach for more efficient updates

### Type Safety and Resilience

- [x] **8. Add type safety improvements for tag arrays**
- Add a computed property for tag arrays to avoid repeated casting
- Make the code more resilient to type issues
- ✅ Implemented as part of item #5 with the `tagArray` computed property

### Testing and Documentation

- [x] **9. Add unit tests for new event kinds**
- Create tests for parsing kind 20, 21, and 22 events
- Test the tag helper methods with various inputs
- Ensure edge cases are properly handled

- [x] **10. Update documentation and comments**
- Added comprehensive documentation in `doc/nip68_71_implementation.md`
- Documented the tag structure expected for each kind
- Added examples and implementation details

## Implementation Progress

We've successfully completed all items in our improvement checklist:

1. ✅ Removed duplicate `videoPost` method and improved documentation
2. ✅ Simplified constructors using tuple assignment
3. ✅ Added better error handling and loading states to VideoNoteCard
4. ✅ Fixed conditional bug in NoteCard.swift that was showing wrong card type
5. ✅ Implemented helper methods for tag parsing
6. ✅ Improved RepliesLabel error handling with fallbacks
7. ✅ Added throttling to avoid excessive avatar computations
8. ✅ Added type safety improvements with tagArray computed property
9. ✅ Added unit tests for the new event kinds and helper methods
10. ✅ Created comprehensive documentation in `doc/nip68_71_implementation.md`

The implementation is now complete with improved code quality, better user experience, and comprehensive documentation.

## Implementation Approach

For each item in the checklist:

1. Make the code changes for the specific improvement
2. Test the changes locally
3. Verify that the UI works as expected
4. Check for regressions in related functionality
5. Commit the changes with a clear commit message

This methodical approach will ensure that the implementation is robust and maintainable while minimizing the risk of introducing new bugs.
32 changes: 18 additions & 14 deletions Nos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@
04F16AA72CBDBD91003AD693 /* DeleteConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F16AA62CBDBD91003AD693 /* DeleteConfirmationView.swift */; };
2D06BB9D2AE249D70085F509 /* ThreadRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D06BB9C2AE249D70085F509 /* ThreadRootView.swift */; };
2D4010A22AD87DF300F93AD4 /* KnownFollowersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4010A12AD87DF300F93AD4 /* KnownFollowersView.swift */; };
2DC910682D50AAFF0065C468 /* PictureNoteCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC910672D50AAFF0065C468 /* PictureNoteCard.swift */; };
2DC9106C2D50AB300065C468 /* VideoNoteCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC9106B2D50AB2B0065C468 /* VideoNoteCard.swift */; };
3A1C296F2B2A537C0020B753 /* Moderation.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 3A1C296E2B2A537C0020B753 /* Moderation.xcstrings */; };
3A67449C2B294712002B8DE0 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 3A67449B2B294712002B8DE0 /* Localizable.xcstrings */; };
3AAB61B52B24CD0000717A07 /* Date+ElapsedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AAB61B42B24CD0000717A07 /* Date+ElapsedTests.swift */; };
Expand Down Expand Up @@ -759,6 +761,8 @@
2D06BB9C2AE249D70085F509 /* ThreadRootView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreadRootView.swift; sourceTree = "<group>"; };
2D3C71A52CEE6F7100625BCB /* Nos 20.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 20.xcdatamodel"; sourceTree = "<group>"; };
2D4010A12AD87DF300F93AD4 /* KnownFollowersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KnownFollowersView.swift; sourceTree = "<group>"; };
2DC910672D50AAFF0065C468 /* PictureNoteCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PictureNoteCard.swift; sourceTree = "<group>"; };
2DC9106B2D50AB2B0065C468 /* VideoNoteCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoNoteCard.swift; sourceTree = "<group>"; };
3A1C296E2B2A537C0020B753 /* Moderation.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Moderation.xcstrings; sourceTree = "<group>"; };
3A67449B2B294712002B8DE0 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
3AAB61B42B24CD0000717A07 /* Date+ElapsedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+ElapsedTests.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1434,6 +1438,8 @@
03618C6D2C8267E600BCBC55 /* Note */ = {
isa = PBXGroup;
children = (
2DC910672D50AAFF0065C468 /* PictureNoteCard.swift */,
2DC9106B2D50AB2B0065C468 /* VideoNoteCard.swift */,
C9DFA96A299BEE2C006929C1 /* CompactNoteView.swift */,
C9DFA964299BEB96006929C1 /* NoteCard.swift */,
C974652D2A3B86600031226F /* NoteCardHeader.swift */,
Expand Down Expand Up @@ -2686,6 +2692,7 @@
0326346D2C10C2FD00E489B5 /* FileStorageServerInfoResponseJSON.swift in Sources */,
5B6136462C348A5100ADD9C3 /* RepliesDisplayType.swift in Sources */,
5BE281C72AE2CCD800880466 /* ReplyButton.swift in Sources */,
2DC9106C2D50AB300065C468 /* VideoNoteCard.swift in Sources */,
C9DFA966299BEB96006929C1 /* NoteCard.swift in Sources */,
C98651102B0BD49200597B68 /* PagedNoteListView.swift in Sources */,
C9E37E152A1E8143003D4B0A /* ReportTarget.swift in Sources */,
Expand All @@ -2694,6 +2701,7 @@
045EDD052CAC025700B67964 /* ScrollViewProxy+Animate.swift in Sources */,
CD09A74629A50F750063464F /* SideMenuContent.swift in Sources */,
030E56F32CC2836D00A4A51E /* CopyKeyView.swift in Sources */,
2DC910682D50AAFF0065C468 /* PictureNoteCard.swift in Sources */,
C9DFA971299BF8CD006929C1 /* NoteView.swift in Sources */,
033E940B2D08F14900C6FB03 /* AuthorList+CoreDataClass.swift in Sources */,
037071272C90C5FA00BEAEC4 /* OpenGraphService.swift in Sources */,
Expand Down Expand Up @@ -3034,12 +3042,11 @@
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = accent;
CODE_SIGN_ENTITLEMENTS = Nos/NosStaging.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: Verse Communications, Inc. (GZCZBKH7MY)";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 224;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GZCZBKH7MY;
DEVELOPMENT_TEAM = GZCZBKH7MY;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
Expand Down Expand Up @@ -3072,7 +3079,6 @@
PRODUCT_MODULE_NAME = Nos;
PRODUCT_NAME = "$(TARGET_NAME) Staging";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.verse.Nos-staging";
SCHEME_PREFIX = "nos-staging";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand Down Expand Up @@ -3489,11 +3495,11 @@
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = accent;
CODE_SIGN_ENTITLEMENTS = Nos/Nos.entitlements;
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 224;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GZCZBKH7MY;
DEVELOPMENT_TEAM = GZCZBKH7MY;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
Expand Down Expand Up @@ -3525,7 +3531,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.verse.Nos;
PRODUCT_MODULE_NAME = Nos;
PRODUCT_NAME = "$(TARGET_NAME)";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.verse.Nos";
PROVISIONING_PROFILE_SPECIFIER = "";
SCHEME_PREFIX = nos;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand All @@ -3546,12 +3552,11 @@
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = NO;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = accent;
CODE_SIGN_ENTITLEMENTS = Nos/Nos.entitlements;
CODE_SIGN_IDENTITY = "Apple Distribution: Verse Communications, Inc. (GZCZBKH7MY)";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 224;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = GZCZBKH7MY;
DEVELOPMENT_TEAM = GZCZBKH7MY;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
Expand Down Expand Up @@ -3583,7 +3588,6 @@
PRODUCT_MODULE_NAME = Nos;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.verse.Nos";
SCHEME_PREFIX = nos;
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand Down
1 change: 1 addition & 0 deletions Nos.xcodeproj/xcshareddata/xcschemes/Nos.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
buildConfiguration = "Dev"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableThreadSanitizer = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
Expand Down
2 changes: 1 addition & 1 deletion Nos/Models/CoreData/Author+CoreDataClass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ import Logger
}

@nonobjc func postsPredicate(before: Date) -> NSPredicate {
let onlyRootPostsClause = "(kind = 1 AND SUBQUERY(" +
let onlyRootPostsClause = "(kind = 1 or kind = 20 AND SUBQUERY(" +
"eventReferences, " +
"$reference, " +
"$reference.marker = 'root' OR $reference.marker = 'reply' OR $reference.marker = nil" +
Expand Down
126 changes: 126 additions & 0 deletions Nos/Models/CoreData/Event+CoreDataClass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,132 @@ public class Event: NosManagedObject, VerifiableEvent {
}
}

// MARK: - Tag Parsing Helpers

/// Returns the tag array from allTags, casting it to the expected type
var tagArray: [[String]] {
self.allTags as? [[String]] ?? []
}

/// Gets the value from a tag with the specified key
/// - Parameter key: The key (first element) of the tag to search for
/// - Returns: The second element of the tag if found, nil otherwise
func getTagValue(key: String) -> String? {
tagArray.first(where: { $0.count > 1 && $0[0] == key })?[1]
}

/// Gets all tags that have a specific key
/// - Parameter key: The key (first element) to search for in tags
/// - Returns: Array of tags that match the key
func getTags(withKey key: String) -> [[String]] {
tagArray.filter { $0.count > 0 && $0[0] == key }
}

/// Gets all image or video metadata tags (imeta)
/// - Returns: Array of imeta tags
func getMediaMetaTags() -> [[String]] {
getTags(withKey: "imeta")
}

/// Gets the MIME type from an imeta tag if available
/// - Parameter tag: The imeta tag to search for MIME type
/// - Returns: MIME type string if found, nil otherwise
func getMimeType(from tag: [String]) -> String? {
// Look for "m " prefix which indicates MIME type
if let mimeElement = tag.first(where: { $0.hasPrefix("m ") }) {
return String(mimeElement.dropFirst(2))
}

// Look for common MIME type strings in the tag
for element in tag {
if element.contains("video/") || element.contains("audio/") || element.contains("image/") {
return element
}
}

// Try to infer from URL extension
if let url = getURLFromTag(tag) {
let pathExtension = url.pathExtension.lowercased()

// Check for common video formats
let videoExtensions = ["mp4", "mov", "m4v", "avi", "webm", "mkv", "flv", "wmv"]
if videoExtensions.contains(pathExtension) {
return "video/\(pathExtension)"
}

// Check for common image formats
let imageExtensions = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "avif"]
if imageExtensions.contains(pathExtension) {
return pathExtension == "jpg" ? "image/jpeg" : "image/\(pathExtension)"
}

// Check for common audio formats
let audioExtensions = ["mp3", "wav", "aac", "m4a", "ogg", "flac"]
if audioExtensions.contains(pathExtension) {
return "audio/\(pathExtension)"
}
}

return nil
}

/// Checks if an imeta tag refers to a video
/// - Parameter tag: The imeta tag to check
/// - Returns: True if the tag appears to be a video
func isVideoTag(_ tag: [String]) -> Bool {
// Check for video MIME type
if let mimeType = getMimeType(from: tag), mimeType.contains("video/") {
return true
}

// Check URL extension
if let url = getURLFromTag(tag) {
let videoExtensions = ["mp4", "mov", "m4v", "avi", "webm", "mkv", "flv", "wmv"]
return videoExtensions.contains(url.pathExtension.lowercased())
}

return false
}

/// Extracts a URL from a tag element that starts with "url " or contains a URL
/// - Parameter tag: The tag array to search for a URL
/// - Returns: URL if found and valid, nil otherwise
func getURLFromTag(_ tag: [String]) -> URL? {
// First try to find elements specifically prefixed with "url "
if let urlString = tag.first(where: { $0.hasPrefix("url ") })?.dropFirst(4) {
if let url = URL(string: String(urlString)) {
return url
}
}

// If that fails, see if any element is a valid URL
for element in tag where element != "imeta" {
// Try the element as is
if let url = URL(string: element) {
return url
}

// Try adding https:// prefix if it's missing
if !element.hasPrefix("http://") && !element.hasPrefix("https://") {
if let url = URL(string: "https://" + element) {
return url
}
}
}

// Check if there's a URL in the content attribute
let urlDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
for element in tag where element != "imeta" {
if let urlDetector = urlDetector,
let match = urlDetector.firstMatch(in: element, options: [], range: NSRange(location: 0, length: element.utf16.count)),
let url = URL(string: (element as NSString).substring(with: match.range)) {
return url
}
}

return nil
}

/// Returns true if this event doesn't have content. Usually this means we saw it referenced by another event
/// but we haven't actually downloaded it yet.
var isStub: Bool {
Expand Down
6 changes: 6 additions & 0 deletions Nos/Models/CoreData/Event+Fetching.swift
Original file line number Diff line number Diff line change
Expand Up @@ -354,11 +354,17 @@ extension Event {
)
])
let kind6Predicate = NSPredicate(format: "kind = 6")
let kind20Predicate = NSPredicate(format: "kind = 20")
let kind21Predicate = NSPredicate(format: "kind = 21")
let kind22Predicate = NSPredicate(format: "kind = 22")
let kind30023Predicate = NSPredicate(format: "kind = 30023")

let kindsPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [
kind1Predicate,
kind6Predicate,
kind20Predicate,
kind21Predicate,
kind22Predicate,
kind30023Predicate
])

Expand Down
Loading