d3a6a0ed3e
Pure Swift library for iOS (iOS 17+) to interact with Org Social, a decentralized social network based on plain-text social.org files. Features: - FeedFetcher: async download of social.org content - OrgSocialParser: full parser for profile headers and posts - TimelineFetcher: concurrent multi-feed timeline with relay support - PostWriter: create and upload posts to vhost - 43 tests across 8 suites, all passing
311 lines
8.7 KiB
Markdown
311 lines
8.7 KiB
Markdown
# OrgSocialKit
|
|
|
|
A pure Swift library for iOS to interact with [Org Social](https://github.com/tanrax/org-social), a decentralized social network based on plain-text `social.org` files served over HTTP.
|
|
|
|
No external dependencies. Requires iOS 17+ or macOS 14+.
|
|
|
|
---
|
|
|
|
## Installation
|
|
|
|
### Swift Package Manager
|
|
|
|
Add the package to your `Package.swift`:
|
|
|
|
```swift
|
|
dependencies: [
|
|
.package(url: "https://github.com/tanrax/org-social-ios-lib", from: "0.1.0"),
|
|
],
|
|
targets: [
|
|
.target(
|
|
name: "YourApp",
|
|
dependencies: ["OrgSocialKit"]
|
|
),
|
|
]
|
|
```
|
|
|
|
Or in Xcode: **File > Add Package Dependencies** and paste the repository URL.
|
|
|
|
---
|
|
|
|
## Features
|
|
|
|
| Feature | Status |
|
|
|---------|--------|
|
|
| Fetch feed content from a URL | ✅ |
|
|
| Parse profile and posts | ✅ |
|
|
| Timeline assembly | ✅ |
|
|
| Post creation and upload | ✅ |
|
|
| Relay API | Planned |
|
|
|
|
---
|
|
|
|
## Usage
|
|
|
|
### Fetch and parse a feed
|
|
|
|
The typical flow is to fetch the raw content with `FeedFetcher` and then parse it with `OrgSocialParser`:
|
|
|
|
```swift
|
|
import OrgSocialKit
|
|
|
|
let url = URL(string: "https://andros.dev/social.org")!
|
|
let fetcher = FeedFetcher()
|
|
let parser = OrgSocialParser()
|
|
|
|
do {
|
|
let content = try await fetcher.fetch(from: url)
|
|
var profile = parser.parse(content)
|
|
profile.feedURL = url // set the source URL (the parser doesn't know it)
|
|
|
|
print(profile.nick ?? "") // "andros"
|
|
print(profile.posts.count) // number of posts
|
|
} catch {
|
|
print(error.localizedDescription)
|
|
}
|
|
```
|
|
|
|
### Work with the profile
|
|
|
|
```swift
|
|
// Profile metadata
|
|
profile.title // "Andros Fenollosa"
|
|
profile.nick // "andros"
|
|
profile.description // "Software developer."
|
|
profile.avatar // URL?
|
|
profile.languages // ["en", "es"]
|
|
profile.follows // [OrgSocialFollow]
|
|
profile.groups // [OrgSocialGroup]
|
|
profile.contacts // ["mailto:hi@andros.dev"]
|
|
profile.pinned // "2025-03-10T09:00:00+01:00"
|
|
|
|
// Iterate follows
|
|
for follow in profile.follows {
|
|
print(follow.name ?? "unknown", follow.url)
|
|
}
|
|
```
|
|
|
|
### Fetch the timeline
|
|
|
|
`TimelineFetcher` downloads all relevant feeds concurrently and returns a merged, filtered, and date-sorted list of posts.
|
|
|
|
```swift
|
|
import OrgSocialKit
|
|
|
|
// Parse your own profile first
|
|
let myURL = URL(string: "https://andros.dev/social.org")!
|
|
var profile = OrgSocialParser().parse(try await FeedFetcher().fetch(from: myURL))
|
|
profile.feedURL = myURL
|
|
|
|
// Fetch the timeline (relay enabled by default)
|
|
let posts = await TimelineFetcher().fetch(following: profile)
|
|
```
|
|
|
|
**Relay behaviour:**
|
|
- `useRelay: true` (default): downloads the full feed list from the relay network. If the relay is unreachable it falls back to the local `#+FOLLOW:` list automatically.
|
|
- `useRelay: false`: uses only the profile's `#+FOLLOW:` list, no network call to the relay.
|
|
|
|
```swift
|
|
// Relay off — local follows only
|
|
let options = TimelineOptions(useRelay: false)
|
|
let posts = await TimelineFetcher().fetch(following: profile, options: options)
|
|
|
|
// Custom options
|
|
let options = TimelineOptions(
|
|
relayURL: URL(string: "https://relay.org-social.org")!,
|
|
useRelay: true,
|
|
maxConcurrentDownloads: 10,
|
|
maxPostAgeDays: 7, // nil = no date limit
|
|
languageFilter: ["en", "es"] // [] = all languages
|
|
)
|
|
```
|
|
|
|
**What the timeline filters out automatically:**
|
|
- Pure emoji reactions (posts with `MOOD` + `REPLY_TO` and no body text)
|
|
- Group posts (use a dedicated group feed for those)
|
|
- Posts older than `maxPostAgeDays`
|
|
- Posts in languages not in `languageFilter` (when the filter is non-empty)
|
|
- `VISIBILITY: mention` posts where the user is neither the author nor a mentioned party
|
|
|
|
---
|
|
|
|
### Work with posts
|
|
|
|
```swift
|
|
for post in profile.posts {
|
|
print(post.timestamp) // "2025-03-10T09:00:00+01:00"
|
|
print(post.date) // Date
|
|
print(post.text) // body text (Org Mode syntax preserved)
|
|
print(post.lang ?? "") // "en"
|
|
print(post.tags) // ["emacs", "swift"]
|
|
|
|
// Post type hints
|
|
if post.replyTo != nil { /* it's a reply */ }
|
|
if post.include != nil { /* it's a boost */ }
|
|
if post.pollEnd != nil { /* it's a poll */ }
|
|
if post.mood != nil { /* it's a reaction */ }
|
|
if post.visibility == "mention" { /* mention-only */ }
|
|
}
|
|
```
|
|
|
|
### Handle fetch errors
|
|
|
|
`fetch(from:)` throws `FeedFetcherError`:
|
|
|
|
```swift
|
|
do {
|
|
let content = try await fetcher.fetch(from: url)
|
|
} catch FeedFetcherError.invalidURL {
|
|
// The URL scheme is not http or https
|
|
} catch FeedFetcherError.httpError(let statusCode) {
|
|
print("Server returned \(statusCode)")
|
|
} catch FeedFetcherError.decodingError {
|
|
// Response body is not valid UTF-8
|
|
} catch FeedFetcherError.networkError(let message) {
|
|
print("Network failure: \(message)")
|
|
}
|
|
```
|
|
|
|
### Create and publish a post
|
|
|
|
`PostWriter` appends a new post to your feed content and uploads the result to the vhost.
|
|
|
|
```swift
|
|
import OrgSocialKit
|
|
|
|
let feedURL = URL(string: "https://andros.dev/social.org")!
|
|
let writer = PostWriter()
|
|
|
|
// Fetch the current feed content
|
|
let content = try await FeedFetcher().fetch(from: feedURL)
|
|
|
|
// Build and append the new post
|
|
let options = NewPostOptions(
|
|
text: "Hello from OrgSocialKit!",
|
|
lang: "en",
|
|
tags: "swift ios"
|
|
)
|
|
let (updatedContent, postURL) = try writer.appendPost(
|
|
to: content,
|
|
feedURL: feedURL,
|
|
options: options
|
|
)
|
|
|
|
print(postURL) // "https://andros.dev/social.org#2025-04-18T12:00:00+02:00"
|
|
|
|
// Upload the updated feed to the vhost
|
|
try await writer.upload(content: updatedContent, to: feedURL)
|
|
```
|
|
|
|
**Post options:**
|
|
|
|
```swift
|
|
let options = NewPostOptions(
|
|
text: "Body text. Org Mode syntax supported.",
|
|
lang: "en", // ISO 639-1 code (optional)
|
|
tags: "emacs swift", // space-separated (optional)
|
|
replyTo: "https://feed.url/social.org#TIMESTAMP", // reply (optional)
|
|
include: "https://feed.url/social.org#TIMESTAMP", // boost (optional)
|
|
mood: "🚀", // emoji reaction (optional)
|
|
group: "Swift Dev https://relay.example.com", // group (optional)
|
|
visibility: .mention, // .public (default) or .mention
|
|
client: "my-app" // defaults to "org-social-ios"
|
|
)
|
|
```
|
|
|
|
**Errors from `PostWriter`:**
|
|
|
|
```swift
|
|
do {
|
|
try await writer.upload(content: updatedContent, to: feedURL)
|
|
} catch PostWriterError.missingPostsSection {
|
|
// Feed has no "* Posts" heading
|
|
} catch PostWriterError.uploadFailed(let statusCode) {
|
|
print("vhost returned HTTP \(statusCode)")
|
|
} catch PostWriterError.networkError(let message) {
|
|
print("Network failure: \(message)")
|
|
}
|
|
```
|
|
|
|
### Custom URLSession
|
|
|
|
```swift
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 10
|
|
let fetcher = FeedFetcher(session: URLSession(configuration: config))
|
|
let writer = PostWriter(session: URLSession(configuration: config))
|
|
```
|
|
|
|
---
|
|
|
|
## Data model
|
|
|
|
### OrgSocialProfile
|
|
|
|
| Property | Type | Notes |
|
|
|----------|------|-------|
|
|
| `title` | `String?` | Required by spec |
|
|
| `nick` | `String?` | Required by spec, no spaces |
|
|
| `description` | `String?` | |
|
|
| `avatar` | `URL?` | |
|
|
| `links` | `[URL]` | Multiple allowed |
|
|
| `location` | `String?` | |
|
|
| `birthday` | `String?` | YYYY-MM-DD |
|
|
| `languages` | `[String]` | ISO 639-1 codes |
|
|
| `feedURL` | `URL?` | Set by caller after parsing |
|
|
| `pinned` | `String?` | RFC 3339 timestamp |
|
|
| `follows` | `[OrgSocialFollow]` | |
|
|
| `groups` | `[OrgSocialGroup]` | |
|
|
| `contacts` | `[String]` | Email, XMPP, etc. |
|
|
| `posts` | `[OrgSocialPost]` | In file order |
|
|
|
|
### OrgSocialPost
|
|
|
|
| Property | Type | Notes |
|
|
|----------|------|-------|
|
|
| `timestamp` | `String` | RFC 3339, unique ID |
|
|
| `date` | `Date` | Parsed from timestamp |
|
|
| `text` | `String` | Body (Org Mode syntax preserved) |
|
|
| `lang` | `String?` | ISO 639-1 |
|
|
| `tags` | `[String]` | |
|
|
| `client` | `String?` | App that created the post |
|
|
| `replyTo` | `String?` | `url#timestamp` |
|
|
| `include` | `String?` | `url#timestamp` (boost) |
|
|
| `pollEnd` | `Date?` | Poll close date |
|
|
| `pollOption` | `String?` | Vote selection |
|
|
| `group` | `String?` | `"Name relay-url"` |
|
|
| `mood` | `String?` | Emoji reaction |
|
|
| `migration` | `String?` | `"old-url new-url"` |
|
|
| `visibility` | `String?` | `"public"` or `"mention"` |
|
|
|
|
---
|
|
|
|
## What is Org Social?
|
|
|
|
Org Social is a decentralized social network. Each user hosts a plain-text file called `social.org` on their own web server. The file contains both their profile and all their posts, readable by humans and machines alike. There is no central server or registration required.
|
|
|
|
A minimal `social.org` looks like this:
|
|
|
|
```org
|
|
#+TITLE: My Feed
|
|
#+NICK: andros
|
|
#+FOLLOW: shom https://shom.dev/social.org
|
|
|
|
* Posts
|
|
|
|
** 2025-03-10T09:00:00+01:00
|
|
:PROPERTIES:
|
|
:LANG: en
|
|
:END:
|
|
|
|
Hello, Org Social!
|
|
```
|
|
|
|
Learn more at the [official protocol repository](https://github.com/tanrax/org-social).
|
|
|
|
---
|
|
|
|
## License
|
|
|
|
GPL-3.0
|