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
8.7 KiB
OrgSocialKit
A pure Swift library for iOS to interact with 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:
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:
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
// 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.
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.
// 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_TOand 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: mentionposts where the user is neither the author nor a mentioned party
Work with posts
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:
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.
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:
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:
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
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:
#+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.
License
GPL-3.0