Files
andros d3a6a0ed3e Initial commit: OrgSocialKit library
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
2026-04-18 20:03:46 +02:00

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