Select the types of activity you want to include in your feed.
A terminal-only Bluesky / AT Protocol client written in Fortran, with a asm/Rust native firehose decoder for the relay-raw stream. DM slide support. Dither image composer. Yes, that Fortran
www.patreon.com/FormerLab
···11+# Suggested Fixture Set
22+33+Capture and store raw websocket frames for at least these cases:
44+55+1. `#commit` with one `create app.bsky.feed.post`
66+2. `#commit` with one `create like`
77+3. `#commit` with one `delete follow`
88+4. `#identity`
99+5. `#account`
1010+6. error frame (`op = -1`)
1111+1212+Then assert:
1313+- envelope decode works
1414+- commit metadata matches expected values
1515+- CAR block lookup resolves the expected record block
1616+- DAG-CBOR JSON contains post `text`
1717+- normalize emits exactly one `fs_event_t` for the post-create fixture
+9
bridge/firehose-bridge/src/lib.rs
···11+pub mod abi;
22+pub mod decoder;
33+pub mod envelope;
44+pub mod commit;
55+pub mod car;
66+pub mod dagcbor;
77+pub mod normalize;
88+99+pub use abi::*;
+76
bridge/firehose-bridge/src/normalize.rs
···11+use anyhow::Result;
22+use serde_json::Value;
33+44+use crate::abi::{NormalizedEvent, FS_KIND_COMMIT_OP, FS_OP_CREATE, FS_OP_DELETE, FS_OP_UPDATE};
55+66+pub fn normalize_commit(commit: crate::commit::DecodedCommit) -> Result<Vec<NormalizedEvent>> {
77+ let mut out = Vec::new();
88+99+ for op in commit.ops {
1010+ let (collection, rkey) = split_repo_path(&op.path);
1111+ let action = match op.action.as_str() {
1212+ "create" => FS_OP_CREATE,
1313+ "update" => FS_OP_UPDATE,
1414+ "delete" => FS_OP_DELETE,
1515+ _ => 0,
1616+ };
1717+1818+ if action != FS_OP_CREATE {
1919+ continue;
2020+ }
2121+2222+ if collection.as_deref() != Some("app.bsky.feed.post") {
2323+ continue;
2424+ }
2525+2626+ let uri = match (&collection, &rkey) {
2727+ (Some(c), Some(r)) => Some(format!("at://{}/{}/{}", commit.repo, c, r)),
2828+ _ => None,
2929+ };
3030+3131+ let record_json = sanitize_post_record_json(op.record_json.as_deref());
3232+3333+ out.push(NormalizedEvent {
3434+ seq: commit.seq,
3535+ kind: FS_KIND_COMMIT_OP,
3636+ op_action: action,
3737+ repo_did: Some(commit.repo.clone()),
3838+ rev: Some(commit.rev.clone()),
3939+ collection,
4040+ rkey,
4141+ record_cid: op.cid.clone(),
4242+ uri,
4343+ record_json,
4444+ error_message: None,
4545+ });
4646+ }
4747+4848+ Ok(out)
4949+}
5050+5151+fn split_repo_path(path: &str) -> (Option<String>, Option<String>) {
5252+ let mut parts = path.split('/');
5353+ let collection = parts.next().map(|s| s.to_string());
5454+ let rkey = parts.next().map(|s| s.to_string());
5555+ (collection, rkey)
5656+}
5757+5858+fn sanitize_post_record_json(src: Option<&str>) -> Option<String> {
5959+ let Some(src) = src else { return None; };
6060+ let Ok(mut value) = serde_json::from_str::<Value>(src) else {
6161+ return Some(src.to_string());
6262+ };
6363+6464+ if let Value::Object(obj) = &mut value {
6565+ if !obj.contains_key("$type") {
6666+ obj.insert("$type".to_string(), Value::String("app.bsky.feed.post".to_string()));
6767+ }
6868+ if let Some(text) = obj.get("text") {
6969+ if !text.is_string() {
7070+ obj.insert("text".to_string(), Value::String(text.to_string()));
7171+ }
7272+ }
7373+ }
7474+7575+ serde_json::to_string(&value).ok()
7676+}
···11+{"kind":"commit","did":"did:plc:fortranskyfixture000000000000","handle":"","text":"Synthetic raw relay commit fixture: hello from Fortransky.","time_us":"26653242501","uri":"at://did:plc:fortranskyfixture000000000000/app.bsky.feed.post/3lmfixturepost"}
fixtures/relay_commit_frame.bin
This is a binary file and will not be displayed.
+15
scripts/build.sh
···11+#!/usr/bin/env bash
22+set -euo pipefail
33+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
44+55+# Build Rust firehose bridge first so the staticlib is present for the Fortran link
66+printf 'Building Rust firehose bridge...\n'
77+cd "$ROOT/bridge/firehose-bridge"
88+cargo build --release
99+printf 'Rust bridge built: %s\n' "$ROOT/bridge/firehose-bridge/target/release/libfortransky_firehose_bridge.a"
1010+1111+# Build Fortran/C executable
1212+mkdir -p "$ROOT/build"
1313+cd "$ROOT/build"
1414+cmake ..
1515+cmake --build . -j
···11+module post_store_mod
22+ use models_mod, only: post_view, MAX_ITEMS
33+ use app_state_mod, only: app_state, MAX_CACHE
44+ implicit none
55+contains
66+ integer function find_post_index(state, post) result(idx)
77+ type(app_state), intent(in) :: state
88+ type(post_view), intent(in) :: post
99+ integer :: i
1010+1111+ idx = 0
1212+ do i = 1, state%post_count
1313+ if (len_trim(post%uri) > 0 .and. trim(state%post_cache(i)%uri) == trim(post%uri)) then
1414+ idx = i
1515+ return
1616+ end if
1717+ end do
1818+ end function find_post_index
1919+2020+ subroutine upsert_posts(state, posts, n)
2121+ type(app_state), intent(inout) :: state
2222+ type(post_view), intent(in) :: posts(MAX_ITEMS)
2323+ integer, intent(in) :: n
2424+ integer :: i, idx
2525+2626+ state%current_post_ids = 0
2727+ state%current_post_count = 0
2828+ do i = 1, n
2929+ idx = find_post_index(state, posts(i))
3030+ if (idx == 0) then
3131+ if (state%post_count < MAX_CACHE) then
3232+ state%post_count = state%post_count + 1
3333+ idx = state%post_count
3434+ state%post_cache(idx) = posts(i)
3535+ else
3636+ idx = mod(i-1, MAX_CACHE) + 1
3737+ state%post_cache(idx) = posts(i)
3838+ end if
3939+ else
4040+ state%post_cache(idx) = posts(i)
4141+ end if
4242+ if (state%current_post_count < MAX_ITEMS) then
4343+ state%current_post_count = state%current_post_count + 1
4444+ state%current_post_ids(state%current_post_count) = idx
4545+ end if
4646+ end do
4747+ end subroutine upsert_posts
4848+4949+ subroutine get_current_post(state, list_index, post, ok)
5050+ type(app_state), intent(in) :: state
5151+ integer, intent(in) :: list_index
5252+ type(post_view), intent(out) :: post
5353+ logical, intent(out) :: ok
5454+ integer :: idx
5555+5656+ post = post_view()
5757+ ok = .false.
5858+ if (list_index < 1 .or. list_index > state%current_post_count) return
5959+ idx = state%current_post_ids(list_index)
6060+ if (idx < 1 .or. idx > state%post_count) return
6161+ post = state%post_cache(idx)
6262+ ok = .true.
6363+ end subroutine get_current_post
6464+end module post_store_mod
+75
src/storage/log_store.f90
···11+module log_store_mod
22+ use iso_fortran_env, only: error_unit
33+ implicit none
44+contains
55+ subroutine ensure_dir(path)
66+ character(len=*), intent(in) :: path
77+ call execute_command_line('mkdir -p ' // trim(path))
88+ end subroutine ensure_dir
99+1010+ function app_state_dir() result(path)
1111+ character(len=:), allocatable :: path
1212+ character(len=512) :: home
1313+ integer :: stat
1414+ call get_environment_variable('HOME', home, status=stat)
1515+ if (stat == 0 .and. len_trim(home) > 0) then
1616+ path = trim(home) // '/.fortransky'
1717+ else
1818+ path = '.fortransky'
1919+ end if
2020+ call ensure_dir(path)
2121+ end function app_state_dir
2222+2323+ function state_file(name) result(path)
2424+ character(len=*), intent(in) :: name
2525+ character(len=:), allocatable :: path
2626+ path = app_state_dir() // '/' // trim(name)
2727+ end function state_file
2828+2929+ subroutine append_line(path, line)
3030+ character(len=*), intent(in) :: path, line
3131+ integer :: unit, ios
3232+ open(newunit=unit, file=trim(path), status='unknown', position='append', action='write', iostat=ios)
3333+ if (ios /= 0) then
3434+ write(error_unit,'(a)') 'append_line failed: ' // trim(path)
3535+ return
3636+ end if
3737+ write(unit,'(a)') trim(line)
3838+ close(unit)
3939+ end subroutine append_line
4040+4141+ function read_first_line(path) result(line)
4242+ character(len=*), intent(in) :: path
4343+ character(len=:), allocatable :: line
4444+ integer :: unit, ios
4545+ logical :: exists
4646+ character(len=4096) :: buf
4747+4848+ inquire(file=trim(path), exist=exists)
4949+ if (.not. exists) then
5050+ line = ''
5151+ return
5252+ end if
5353+ open(newunit=unit, file=trim(path), status='old', action='read', iostat=ios)
5454+ if (ios /= 0) then
5555+ line = ''
5656+ return
5757+ end if
5858+ read(unit,'(a)', iostat=ios) buf
5959+ close(unit)
6060+ if (ios /= 0) then
6161+ line = ''
6262+ else
6363+ line = trim(buf)
6464+ end if
6565+ end function read_first_line
6666+6767+ subroutine write_text(path, text)
6868+ character(len=*), intent(in) :: path, text
6969+ integer :: unit, ios
7070+ open(newunit=unit, file=trim(path), status='replace', action='write', iostat=ios)
7171+ if (ios /= 0) return
7272+ write(unit,'(a)') trim(text)
7373+ close(unit)
7474+ end subroutine write_text
7575+end module log_store_mod
+806
src/ui/tui.f90
···11+module tui_mod
22+ use client_mod, only: login_session, fetch_author_feed, search_posts, fetch_timeline, tail_live_stream, &
33+ fetch_post_thread, create_post, create_reply, create_quote_post, like_post, repost_post, &
44+ fetch_profile_view, fetch_notifications_view, load_saved_session, clear_saved_session, &
55+ resolve_did_to_handle
66+ use models_mod, only: post_view, stream_event, actor_profile, notification_view, MAX_ITEMS
77+ use config_mod, only: load_session_from_env
88+ use app_state_mod, only: app_state, VIEW_HOME, VIEW_POST_LIST, VIEW_PROFILE, VIEW_NOTIFICATIONS, VIEW_STREAM, &
99+ reset_selection, set_status
1010+ use post_store_mod, only: upsert_posts, get_current_post
1111+ implicit none
1212+contains
1313+ subroutine clear_screen()
1414+ write(*,'(a)', advance='no') achar(27)//'[2J'//achar(27)//'[H'
1515+ end subroutine clear_screen
1616+1717+ subroutine wrap_print(prefix, text, width)
1818+ character(len=*), intent(in) :: prefix, text
1919+ integer, intent(in) :: width
2020+ integer :: start, stop, last_space, maxw, n
2121+ character(len=:), allocatable :: line
2222+2323+ maxw = max(20, width - len_trim(prefix))
2424+ if (len_trim(text) == 0) then
2525+ write(*,'(a)') trim(prefix)
2626+ return
2727+ end if
2828+ start = 1
2929+ n = len_trim(text)
3030+ do while (start <= n)
3131+ stop = min(n, start + maxw - 1)
3232+ if (stop < n) then
3333+ last_space = scan(text(start:stop), ' ', back=.true.)
3434+ if (last_space > 0 .and. stop < n) stop = start + last_space - 2
3535+ end if
3636+ if (stop < start) stop = min(n, start + maxw - 1)
3737+ line = text(start:stop)
3838+ if (start == 1) then
3939+ write(*,'(a)') trim(prefix) // trim(line)
4040+ else
4141+ write(*,'(a)') repeat(' ', len_trim(prefix)) // trim(line)
4242+ end if
4343+ start = stop + 1
4444+ do while (start <= n .and. text(start:start) == ' ')
4545+ start = start + 1
4646+ end do
4747+ end do
4848+ end subroutine wrap_print
4949+5050+ subroutine prompt_line(prompt, text)
5151+ character(len=*), intent(in) :: prompt
5252+ character(len=*), intent(out) :: text
5353+ write(*,'(a)', advance='no') trim(prompt)
5454+ read(*,'(a)') text
5555+ end subroutine prompt_line
5656+5757+ subroutine draw_header(state)
5858+ type(app_state), intent(in) :: state
5959+ write(*,'(a)') 'Fortransky v1.1 - TUI only'
6060+ write(*,'(a)') repeat('=', 28)
6161+ write(*,'(a)') 'View : ' // trim(state%view_title)
6262+ if (len_trim(state%session%identifier) > 0) write(*,'(a)') 'User : ' // trim(state%session%identifier)
6363+ if (len_trim(state%session%did) > 0) write(*,'(a)') 'DID : ' // trim(state%session%did)
6464+ if (len_trim(state%session%access_jwt) > 0) then
6565+ write(*,'(a)') 'Auth : logged in'
6666+ else
6767+ write(*,'(a)') 'Auth : anonymous'
6868+ end if
6969+ write(*,'(a)') 'Stream : ' // trim(state%stream_mode)
7070+ write(*,'(a)') 'Status : ' // trim(state%status)
7171+ write(*,'(a)') ''
7272+ end subroutine draw_header
7373+7474+ subroutine draw_home(state)
7575+ type(app_state), intent(in) :: state
7676+ call clear_screen()
7777+ call draw_header(state)
7878+ write(*,'(a)') 'Commands:'
7979+ write(*,'(a)') ' a <handle> author feed'
8080+ write(*,'(a)') ' s <query> search posts'
8181+ write(*,'(a)') ' p <handle> profile view'
8282+ write(*,'(a)') ' l login + timeline'
8383+ write(*,'(a)') ' x logout + clear saved session'
8484+ write(*,'(a)') ' n notifications'
8585+ write(*,'(a)') ' c compose post'
8686+ write(*,'(a)') ' t <uri/url> open thread'
8787+ write(*,'(a)') ' j stream tail'
8888+ write(*,'(a)') ' m toggle stream mode (jetstream/relay-raw)'
8989+ write(*,'(a)') ' q quit'
9090+ end subroutine draw_home
9191+9292+ subroutine draw_post_list(state)
9393+ type(app_state), intent(in) :: state
9494+ integer :: i, start_idx, end_idx, pages
9595+ type(post_view) :: post
9696+ logical :: ok
9797+9898+ call clear_screen()
9999+ call draw_header(state)
100100+ if (state%current_post_count == 0) then
101101+ write(*,'(a)') 'No posts loaded.'
102102+ write(*,'(a)') ''
103103+ write(*,'(a)') 'Commands: b back'
104104+ return
105105+ end if
106106+ pages = max(1, (state%current_post_count + state%page_size - 1) / state%page_size)
107107+ start_idx = (state%page - 1) * state%page_size + 1
108108+ end_idx = min(state%current_post_count, start_idx + state%page_size - 1)
109109+ write(*,'(a,i0,a,i0,a,i0)') 'Page ', state%page, '/', pages, ' Selected ', state%selected
110110+ write(*,'(a)') ''
111111+ do i = start_idx, end_idx
112112+ call get_current_post(state, i, post, ok)
113113+ if (.not. ok) cycle
114114+ if (i == state%selected) then
115115+ write(*,'(a,i0,a)') '>', i, ' <'
116116+ else
117117+ write(*,'(a,i0)') ' ', i
118118+ end if
119119+ call wrap_print('Author: ', trim(post%author), 96)
120120+ if (len_trim(post%handle) > 0) call wrap_print('Handle: ', trim(post%handle), 96)
121121+ if (len_trim(post%indexed_at) > 0) call wrap_print('When : ', trim(post%indexed_at), 96)
122122+ call wrap_print('Text : ', trim(post%text), 96)
123123+ call wrap_print('Meta : ', post_meta_line(post), 96)
124124+ if (len_trim(post%uri) > 0) call wrap_print('URI : ', trim(post%uri), 96)
125125+ write(*,'(a)') repeat('-', 72)
126126+ end do
127127+ write(*,'(a)') 'Commands: j/k move, n/p page, o open thread, r reply, P profile, b back, / search'
128128+ end subroutine draw_post_list
129129+130130+ subroutine draw_profile(state)
131131+ type(app_state), intent(in) :: state
132132+ call clear_screen()
133133+ call draw_header(state)
134134+ call wrap_print('Name : ', trim(state%profile%display_name), 96)
135135+ call wrap_print('Handle: ', trim(state%profile%handle), 96)
136136+ call wrap_print('DID : ', trim(state%profile%did), 96)
137137+ if (len_trim(state%profile%indexed_at) > 0) call wrap_print('Seen : ', trim(state%profile%indexed_at), 96)
138138+ if (len_trim(state%profile%posts_count) > 0) call wrap_print('Posts : ', trim(state%profile%posts_count), 96)
139139+ if (len_trim(state%profile%followers_count) > 0) call wrap_print('Followers: ', trim(state%profile%followers_count), 96)
140140+ if (len_trim(state%profile%follows_count) > 0) call wrap_print('Follows : ', trim(state%profile%follows_count), 96)
141141+ if (len_trim(state%profile%description) > 0) call wrap_print('Bio : ', trim(state%profile%description), 96)
142142+ write(*,'(a)') ''
143143+ write(*,'(a)') 'Commands: b back, a load author feed'
144144+ end subroutine draw_profile
145145+146146+ subroutine draw_notifications(state)
147147+ type(app_state), intent(in) :: state
148148+ integer :: i, start_idx, end_idx, pages
149149+150150+ call clear_screen()
151151+ call draw_header(state)
152152+ if (state%notification_count == 0) then
153153+ write(*,'(a)') 'No notifications loaded.'
154154+ write(*,'(a)') 'Commands: b back'
155155+ return
156156+ end if
157157+ pages = max(1, (state%notification_count + state%page_size - 1) / state%page_size)
158158+ start_idx = (state%page - 1) * state%page_size + 1
159159+ end_idx = min(state%notification_count, start_idx + state%page_size - 1)
160160+ write(*,'(a,i0,a,i0,a,i0)') 'Page ', state%page, '/', pages, ' Selected ', state%selected
161161+ write(*,'(a)') ''
162162+ do i = start_idx, end_idx
163163+ if (i == state%selected) then
164164+ write(*,'(a,i0,a)') '>', i, ' <'
165165+ else
166166+ write(*,'(a,i0)') ' ', i
167167+ end if
168168+ call wrap_print('Reason: ', trim(state%notifications(i)%reason), 96)
169169+ call wrap_print('Actor : ', trim(state%notifications(i)%author), 96)
170170+ if (len_trim(state%notifications(i)%handle) > 0) call wrap_print('Handle: ', trim(state%notifications(i)%handle), 96)
171171+ if (len_trim(state%notifications(i)%indexed_at) > 0) call wrap_print('When : ', trim(state%notifications(i)%indexed_at), 96)
172172+ if (len_trim(state%notifications(i)%text) > 0) call wrap_print('Text : ', trim(state%notifications(i)%text), 96)
173173+ if (len_trim(state%notifications(i)%uri) > 0) call wrap_print('URI : ', trim(state%notifications(i)%uri), 96)
174174+ write(*,'(a)') repeat('-', 72)
175175+ end do
176176+ write(*,'(a)') 'Commands: j/k move, n/p page, o open thread, r reply, l like, R repost, q quote, b back'
177177+ end subroutine draw_notifications
178178+179179+ subroutine draw_stream(events, n, message)
180180+ type(stream_event), intent(in) :: events(MAX_ITEMS)
181181+ integer, intent(in) :: n
182182+ character(len=*), intent(in) :: message
183183+ integer :: i
184184+ call clear_screen()
185185+ write(*,'(a)') 'Fortransky v1.1 - stream tail'
186186+ write(*,'(a)') repeat('=', 28)
187187+ write(*,'(a)') trim(message)
188188+ write(*,'(a)') ''
189189+ if (n == 0) then
190190+ write(*,'(a)') 'No events decoded.'
191191+ else
192192+ do i = 1, n
193193+ write(*,'(a,i0)') 'Event ', i
194194+ call wrap_print('Kind : ', trim(events(i)%kind), 96)
195195+ if (len_trim(events(i)%handle) > 0) call wrap_print('Handle: ', trim(events(i)%handle), 96)
196196+ if (len_trim(events(i)%did) > 0) call wrap_print('DID : ', trim(events(i)%did), 96)
197197+ if (len_trim(events(i)%time_us) > 0) call wrap_print('Cursor: ', trim(events(i)%time_us), 96)
198198+ if (len_trim(events(i)%text) > 0) call wrap_print('Text : ', trim(events(i)%text), 96)
199199+ write(*,'(a)') repeat('-', 72)
200200+ end do
201201+ end if
202202+ write(*,'(a)') 'Commands: b back, j refresh'
203203+ end subroutine draw_stream
204204+205205+ function post_meta_line(post) result(out)
206206+ type(post_view), intent(in) :: post
207207+ character(len=:), allocatable :: out
208208+209209+ out = 'type=' // trim(post%record_type)
210210+ if (len_trim(post%reason) > 0) out = out // ' reason=' // trim(post%reason)
211211+ if (post%is_quote) out = out // ' quote'
212212+ if (post%has_images) out = out // ' images'
213213+ if (post%has_video) out = out // ' video'
214214+ if (post%has_external) out = out // ' link'
215215+ if (post%has_facets) out = out // ' facets'
216216+ if (len_trim(post%reply_count) > 0) out = out // ' replies=' // trim(post%reply_count)
217217+ if (len_trim(post%repost_count) > 0) out = out // ' reposts=' // trim(post%repost_count)
218218+ if (len_trim(post%like_count) > 0) out = out // ' likes=' // trim(post%like_count)
219219+ if (len_trim(post%quote_count) > 0) out = out // ' quotes=' // trim(post%quote_count)
220220+ end function post_meta_line
221221+222222+ subroutine login_flow(state)
223223+ type(app_state), intent(inout) :: state
224224+ type(post_view) :: posts(MAX_ITEMS)
225225+ character(len=256) :: input, password, message
226226+ integer :: n
227227+ logical :: ok
228228+229229+ if (len_trim(state%session%identifier) == 0) then
230230+ call prompt_line('Identifier: ', state%session%identifier)
231231+ else
232232+ call prompt_line('Identifier [' // trim(state%session%identifier) // ']: ', input)
233233+ if (len_trim(input) > 0) state%session%identifier = trim(input)
234234+ end if
235235+ call prompt_line('Password/app password: ', password)
236236+ call login_session(state%session, trim(password), ok, message)
237237+ if (.not. ok) then
238238+ call set_status(state, trim(message))
239239+ return
240240+ end if
241241+ call fetch_timeline(state%session, posts, n, ok)
242242+ if (ok) then
243243+ call upsert_posts(state, posts, n)
244244+ state%prev_view = state%view
245245+ state%view = VIEW_POST_LIST
246246+ state%view_title = 'Home timeline'
247247+ call reset_selection(state)
248248+ call set_status(state, 'Login OK. Timeline loaded.')
249249+ else
250250+ call set_status(state, 'Login OK, but timeline fetch failed.')
251251+ end if
252252+ end subroutine login_flow
253253+254254+ subroutine load_author_feed(state, handle)
255255+ type(app_state), intent(inout) :: state
256256+ character(len=*), intent(in) :: handle
257257+ type(post_view) :: posts(MAX_ITEMS)
258258+ integer :: n
259259+ call fetch_author_feed(trim(handle), posts, n)
260260+ call upsert_posts(state, posts, n)
261261+ state%prev_view = state%view
262262+ state%view = VIEW_POST_LIST
263263+ state%view_title = 'Author feed: ' // trim(handle)
264264+ call reset_selection(state)
265265+ call set_status(state, 'Loaded author feed.')
266266+ end subroutine load_author_feed
267267+268268+ subroutine load_search(state, query)
269269+ type(app_state), intent(inout) :: state
270270+ character(len=*), intent(in) :: query
271271+ type(post_view) :: posts(MAX_ITEMS)
272272+ integer :: n
273273+ call search_posts(trim(query), posts, n)
274274+ call upsert_posts(state, posts, n)
275275+ state%prev_view = state%view
276276+ state%view = VIEW_POST_LIST
277277+ state%view_title = 'Search: ' // trim(query)
278278+ call reset_selection(state)
279279+ call set_status(state, 'Search loaded.')
280280+ end subroutine load_search
281281+282282+ subroutine load_profile(state, handle)
283283+ type(app_state), intent(inout) :: state
284284+ character(len=*), intent(in) :: handle
285285+ logical :: ok
286286+ character(len=256) :: message
287287+288288+ call fetch_profile_view(trim(handle), state%profile, ok, message)
289289+ if (ok) then
290290+ state%prev_view = state%view
291291+ state%view = VIEW_PROFILE
292292+ state%view_title = 'Profile: ' // trim(handle)
293293+ call set_status(state, 'Profile loaded.')
294294+ else
295295+ call set_status(state, trim(message))
296296+ end if
297297+ end subroutine load_profile
298298+299299+ subroutine load_notifications(state)
300300+ type(app_state), intent(inout) :: state
301301+ logical :: ok
302302+ character(len=256) :: message
303303+ integer :: n
304304+305305+ call fetch_notifications_view(state%session, state%notifications, n, ok, message)
306306+ if (ok) then
307307+ state%notification_count = n
308308+ state%prev_view = state%view
309309+ state%view = VIEW_NOTIFICATIONS
310310+ state%view_title = 'Notifications'
311311+ call reset_selection(state)
312312+ call set_status(state, 'Notifications loaded.')
313313+ else
314314+ call set_status(state, trim(message))
315315+ end if
316316+ end subroutine load_notifications
317317+318318+ subroutine load_thread(state, ref)
319319+ type(app_state), intent(inout) :: state
320320+ character(len=*), intent(in) :: ref
321321+ type(post_view) :: posts(MAX_ITEMS)
322322+ integer :: n
323323+ logical :: ok
324324+ character(len=256) :: message
325325+326326+ call fetch_post_thread(trim(ref), posts, n, ok, message)
327327+ if (ok) then
328328+ call upsert_posts(state, posts, n)
329329+ state%prev_view = state%view
330330+ state%view = VIEW_POST_LIST
331331+ state%view_title = 'Thread view'
332332+ call reset_selection(state)
333333+ call set_status(state, 'Thread loaded.')
334334+ else
335335+ call set_status(state, trim(message))
336336+ end if
337337+ end subroutine load_thread
338338+339339+ subroutine compose_flow(state)
340340+ type(app_state), intent(inout) :: state
341341+ character(len=2000) :: text
342342+ character(len=256) :: message, created_uri
343343+ logical :: ok
344344+345345+ call prompt_line('Compose text: ', text)
346346+ if (len_trim(text) == 0) then
347347+ call set_status(state, 'Empty post discarded.')
348348+ return
349349+ end if
350350+ call create_post(state%session, trim(text), ok, message, created_uri)
351351+ if (ok) then
352352+ call set_status(state, 'Post created: ' // trim(created_uri))
353353+ else
354354+ call set_status(state, trim(message))
355355+ end if
356356+ end subroutine compose_flow
357357+358358+ subroutine reply_to_selected_post(state)
359359+ type(app_state), intent(inout) :: state
360360+ type(post_view) :: target
361361+ logical :: ok
362362+ character(len=2000) :: text
363363+ character(len=256) :: message, created_uri
364364+ character(len=512) :: root_uri, root_cid
365365+366366+ call get_current_post(state, state%selected, target, ok)
367367+ if (.not. ok) then
368368+ call set_status(state, 'No selected post.')
369369+ return
370370+ end if
371371+ if (len_trim(target%uri) == 0 .or. len_trim(target%cid) == 0) then
372372+ call set_status(state, 'Selected post is missing reply metadata.')
373373+ return
374374+ end if
375375+ call prompt_line('Reply text: ', text)
376376+ if (len_trim(text) == 0) then
377377+ call set_status(state, 'Empty reply discarded.')
378378+ return
379379+ end if
380380+ if (len_trim(target%root_uri) > 0 .and. len_trim(target%root_cid) > 0) then
381381+ root_uri = trim(target%root_uri)
382382+ root_cid = trim(target%root_cid)
383383+ else
384384+ root_uri = trim(target%uri)
385385+ root_cid = trim(target%cid)
386386+ end if
387387+ call create_reply(state%session, trim(text), trim(target%uri), trim(target%cid), trim(root_uri), trim(root_cid), ok, message, created_uri)
388388+ if (ok) then
389389+ call set_status(state, 'Reply created: ' // trim(created_uri))
390390+ else
391391+ call set_status(state, trim(message))
392392+ end if
393393+ end subroutine reply_to_selected_post
394394+395395+ subroutine reply_to_selected_notification(state)
396396+ type(app_state), intent(inout) :: state
397397+ type(post_view) :: temp
398398+399399+ if (state%selected < 1 .or. state%selected > state%notification_count) then
400400+ call set_status(state, 'No selected notification.')
401401+ return
402402+ end if
403403+ temp = post_view(state%notifications(state%selected)%author, state%notifications(state%selected)%handle, &
404404+ state%notifications(state%selected)%text, state%notifications(state%selected)%uri, &
405405+ state%notifications(state%selected)%cid, state%notifications(state%selected)%indexed_at, &
406406+ state%notifications(state%selected)%parent_uri, state%notifications(state%selected)%parent_cid, &
407407+ state%notifications(state%selected)%root_uri, state%notifications(state%selected)%root_cid)
408408+ call reply_to_post_object(state, temp)
409409+ end subroutine reply_to_selected_notification
410410+411411+ subroutine reply_to_post_object(state, target)
412412+ type(app_state), intent(inout) :: state
413413+ type(post_view), intent(in) :: target
414414+ logical :: ok
415415+ character(len=2000) :: text
416416+ character(len=256) :: message, created_uri
417417+ character(len=512) :: root_uri, root_cid
418418+419419+ if (len_trim(target%uri) == 0 .or. len_trim(target%cid) == 0) then
420420+ call set_status(state, 'Selected item is missing reply metadata.')
421421+ return
422422+ end if
423423+ call prompt_line('Reply text: ', text)
424424+ if (len_trim(text) == 0) then
425425+ call set_status(state, 'Empty reply discarded.')
426426+ return
427427+ end if
428428+ if (len_trim(target%root_uri) > 0 .and. len_trim(target%root_cid) > 0) then
429429+ root_uri = trim(target%root_uri)
430430+ root_cid = trim(target%root_cid)
431431+ else
432432+ root_uri = trim(target%uri)
433433+ root_cid = trim(target%cid)
434434+ end if
435435+ call create_reply(state%session, trim(text), trim(target%uri), trim(target%cid), trim(root_uri), trim(root_cid), ok, message, created_uri)
436436+ if (ok) then
437437+ call set_status(state, 'Reply created: ' // trim(created_uri))
438438+ else
439439+ call set_status(state, trim(message))
440440+ end if
441441+ end subroutine reply_to_post_object
442442+443443+ subroutine like_selected_post(state)
444444+ type(app_state), intent(inout) :: state
445445+ type(post_view) :: target
446446+ logical :: ok
447447+ character(len=256) :: message, created_uri
448448+449449+ call get_current_post(state, state%selected, target, ok)
450450+ if (.not. ok) then
451451+ call set_status(state, 'No selected post.')
452452+ return
453453+ end if
454454+ call like_post(state%session, trim(target%uri), trim(target%cid), ok, message, created_uri)
455455+ if (ok) then
456456+ call set_status(state, 'Liked: ' // trim(created_uri))
457457+ else
458458+ call set_status(state, trim(message))
459459+ end if
460460+ end subroutine like_selected_post
461461+462462+ subroutine repost_selected_post(state)
463463+ type(app_state), intent(inout) :: state
464464+ type(post_view) :: target
465465+ logical :: ok
466466+ character(len=256) :: message, created_uri
467467+468468+ call get_current_post(state, state%selected, target, ok)
469469+ if (.not. ok) then
470470+ call set_status(state, 'No selected post.')
471471+ return
472472+ end if
473473+ call repost_post(state%session, trim(target%uri), trim(target%cid), ok, message, created_uri)
474474+ if (ok) then
475475+ call set_status(state, 'Reposted: ' // trim(created_uri))
476476+ else
477477+ call set_status(state, trim(message))
478478+ end if
479479+ end subroutine repost_selected_post
480480+481481+ subroutine quote_selected_post(state)
482482+ type(app_state), intent(inout) :: state
483483+ type(post_view) :: target
484484+ logical :: ok
485485+ character(len=2000) :: text
486486+ character(len=256) :: message, created_uri
487487+488488+ call get_current_post(state, state%selected, target, ok)
489489+ if (.not. ok) then
490490+ call set_status(state, 'No selected post.')
491491+ return
492492+ end if
493493+ if (len_trim(target%uri) == 0 .or. len_trim(target%cid) == 0) then
494494+ call set_status(state, 'Selected post is missing URI/CID.')
495495+ return
496496+ end if
497497+ call prompt_line('Quote text: ', text)
498498+ if (len_trim(text) == 0) then
499499+ call set_status(state, 'Empty quote discarded.')
500500+ return
501501+ end if
502502+ call create_quote_post(state%session, trim(text), trim(target%uri), trim(target%cid), ok, message, created_uri)
503503+ if (ok) then
504504+ call set_status(state, 'Quote created: ' // trim(created_uri))
505505+ else
506506+ call set_status(state, trim(message))
507507+ end if
508508+ end subroutine quote_selected_post
509509+510510+ subroutine refresh_stream_view(state, events, n)
511511+ type(app_state), intent(inout) :: state
512512+ type(stream_event), intent(out) :: events(MAX_ITEMS)
513513+ integer, intent(out) :: n
514514+ logical :: ok
515515+ character(len=256) :: message
516516+ integer :: i
517517+ character(len=256) :: resolved_handle
518518+519519+ call tail_live_stream(events, n, ok, message, 12, trim(state%stream_mode))
520520+521521+ ! Resolve DID -> handle for each event (cache hit is fast; miss calls API)
522522+ do i = 1, n
523523+ if (len_trim(events(i)%did) > 0 .and. len_trim(events(i)%handle) == 0) then
524524+ call resolve_did_to_handle(state, trim(events(i)%did), resolved_handle)
525525+ events(i)%handle = trim(resolved_handle)
526526+ end if
527527+ end do
528528+529529+ state%prev_view = state%view
530530+ state%view = VIEW_STREAM
531531+ state%view_title = 'Live stream tail'
532532+ call set_status(state, trim(message))
533533+ end subroutine refresh_stream_view
534534+535535+ subroutine move_selection(state, delta, count)
536536+ type(app_state), intent(inout) :: state
537537+ integer, intent(in) :: delta, count
538538+ integer :: pages
539539+540540+ if (count <= 0) return
541541+ state%selected = max(1, min(count, state%selected + delta))
542542+ pages = max(1, (count + state%page_size - 1) / state%page_size)
543543+ state%page = max(1, min(pages, (state%selected - 1) / state%page_size + 1))
544544+ end subroutine move_selection
545545+546546+ subroutine next_page(state, count)
547547+ type(app_state), intent(inout) :: state
548548+ integer, intent(in) :: count
549549+ integer :: pages
550550+ pages = max(1, (count + state%page_size - 1) / state%page_size)
551551+ if (state%page < pages) state%page = state%page + 1
552552+ state%selected = min(count, (state%page - 1) * state%page_size + 1)
553553+ end subroutine next_page
554554+555555+ subroutine prev_page(state, count)
556556+ type(app_state), intent(inout) :: state
557557+ integer, intent(in) :: count
558558+ if (state%page > 1) state%page = state%page - 1
559559+ state%selected = min(count, (state%page - 1) * state%page_size + 1)
560560+ end subroutine prev_page
561561+562562+ subroutine handle_home_command(state, line, quit, events, stream_n)
563563+ type(app_state), intent(inout) :: state
564564+ character(len=*), intent(in) :: line
565565+ logical, intent(inout) :: quit
566566+ type(stream_event), intent(inout) :: events(MAX_ITEMS)
567567+ integer, intent(inout) :: stream_n
568568+ character(len=:), allocatable :: cmd, arg
569569+ integer :: sp
570570+571571+ sp = index(trim(line), ' ')
572572+ if (sp > 0) then
573573+ cmd = adjustl(trim(line(1:sp-1)))
574574+ arg = adjustl(trim(line(sp+1:)))
575575+ else
576576+ cmd = adjustl(trim(line))
577577+ arg = ''
578578+ end if
579579+580580+ select case (trim(cmd))
581581+ case ('a')
582582+ if (len_trim(arg) == 0) then
583583+ call set_status(state, 'Usage: a <handle>')
584584+ else
585585+ call load_author_feed(state, arg)
586586+ end if
587587+ case ('s')
588588+ if (len_trim(arg) == 0) then
589589+ call set_status(state, 'Usage: s <query>')
590590+ else
591591+ call load_search(state, arg)
592592+ end if
593593+ case ('p')
594594+ if (len_trim(arg) == 0) then
595595+ call set_status(state, 'Usage: p <handle>')
596596+ else
597597+ call load_profile(state, arg)
598598+ end if
599599+ case ('l')
600600+ call login_flow(state)
601601+ case ('n')
602602+ call load_notifications(state)
603603+ case ('c')
604604+ call compose_flow(state)
605605+ case ('t')
606606+ if (len_trim(arg) == 0) then
607607+ call set_status(state, 'Usage: t <at://uri or bsky.app URL>')
608608+ else
609609+ call load_thread(state, arg)
610610+ end if
611611+ case ('j')
612612+ call refresh_stream_view(state, events, stream_n)
613613+ case ('m')
614614+ if (trim(state%stream_mode) == 'jetstream') then
615615+ state%stream_mode = 'relay-raw'
616616+ else
617617+ state%stream_mode = 'jetstream'
618618+ end if
619619+ call set_status(state, 'Stream mode set to ' // trim(state%stream_mode))
620620+ case ('x')
621621+ state%session%access_jwt = ''
622622+ state%session%refresh_jwt = ''
623623+ state%session%did = ''
624624+ call clear_saved_session()
625625+ call set_status(state, 'Logged out and cleared saved session.')
626626+ case ('q')
627627+ quit = .true.
628628+ case default
629629+ call set_status(state, 'Unknown command on home view.')
630630+ end select
631631+ end subroutine handle_home_command
632632+633633+ subroutine handle_post_command(state, line)
634634+ type(app_state), intent(inout) :: state
635635+ character(len=*), intent(in) :: line
636636+ character(len=256) :: arg
637637+ type(post_view) :: target
638638+ logical :: ok
639639+640640+ select case (trim(line))
641641+ case ('j')
642642+ call move_selection(state, 1, state%current_post_count)
643643+ case ('k')
644644+ call move_selection(state, -1, state%current_post_count)
645645+ case ('n')
646646+ call next_page(state, state%current_post_count)
647647+ case ('p')
648648+ call prev_page(state, state%current_post_count)
649649+ case ('o')
650650+ call get_current_post(state, state%selected, target, ok)
651651+ if (ok) then
652652+ call load_thread(state, trim(target%uri))
653653+ else
654654+ call set_status(state, 'No selected post.')
655655+ end if
656656+ case ('r')
657657+ call reply_to_selected_post(state)
658658+ case ('l')
659659+ call like_selected_post(state)
660660+ case ('R')
661661+ call repost_selected_post(state)
662662+ case ('q')
663663+ call quote_selected_post(state)
664664+ case ('P')
665665+ call get_current_post(state, state%selected, target, ok)
666666+ if (ok .and. len_trim(target%handle) > 0) then
667667+ call load_profile(state, trim(target%handle))
668668+ else
669669+ call set_status(state, 'Selected post has no handle.')
670670+ end if
671671+ case ('b')
672672+ state%view = VIEW_HOME
673673+ state%view_title = 'Fortransky'
674674+ call set_status(state, 'Back to home.')
675675+ case default
676676+ if (len_trim(line) >= 2 .and. line(1:1) == '/') then
677677+ arg = adjustl(trim(line(2:)))
678678+ if (len_trim(arg) > 0) then
679679+ call load_search(state, trim(arg))
680680+ else
681681+ call set_status(state, 'Usage: /search terms')
682682+ end if
683683+ else
684684+ call set_status(state, 'Unknown command on post list.')
685685+ end if
686686+ end select
687687+ end subroutine handle_post_command
688688+689689+ subroutine handle_profile_command(state, line)
690690+ type(app_state), intent(inout) :: state
691691+ character(len=*), intent(in) :: line
692692+ select case (trim(line))
693693+ case ('b')
694694+ state%view = VIEW_HOME
695695+ state%view_title = 'Fortransky'
696696+ call set_status(state, 'Back to home.')
697697+ case ('a')
698698+ if (len_trim(state%profile%handle) > 0) then
699699+ call load_author_feed(state, trim(state%profile%handle))
700700+ else
701701+ call set_status(state, 'Profile has no handle.')
702702+ end if
703703+ case default
704704+ call set_status(state, 'Unknown command on profile view.')
705705+ end select
706706+ end subroutine handle_profile_command
707707+708708+ subroutine handle_notification_command(state, line)
709709+ type(app_state), intent(inout) :: state
710710+ character(len=*), intent(in) :: line
711711+712712+ select case (trim(line))
713713+ case ('j')
714714+ call move_selection(state, 1, state%notification_count)
715715+ case ('k')
716716+ call move_selection(state, -1, state%notification_count)
717717+ case ('n')
718718+ call next_page(state, state%notification_count)
719719+ case ('p')
720720+ call prev_page(state, state%notification_count)
721721+ case ('o')
722722+ if (state%selected >= 1 .and. state%selected <= state%notification_count) then
723723+ call load_thread(state, trim(state%notifications(state%selected)%uri))
724724+ else
725725+ call set_status(state, 'No selected notification.')
726726+ end if
727727+ case ('r')
728728+ call reply_to_selected_notification(state)
729729+ case ('b')
730730+ state%view = VIEW_HOME
731731+ state%view_title = 'Fortransky'
732732+ call set_status(state, 'Back to home.')
733733+ case default
734734+ call set_status(state, 'Unknown command on notifications view.')
735735+ end select
736736+ end subroutine handle_notification_command
737737+738738+ subroutine handle_stream_command(state, line, events, n)
739739+ type(app_state), intent(inout) :: state
740740+ character(len=*), intent(in) :: line
741741+ type(stream_event), intent(inout) :: events(MAX_ITEMS)
742742+ integer, intent(inout) :: n
743743+ select case (trim(line))
744744+ case ('b')
745745+ state%view = VIEW_HOME
746746+ state%view_title = 'Fortransky'
747747+ call set_status(state, 'Back to home.')
748748+ case ('j')
749749+ call refresh_stream_view(state, events, n)
750750+ case default
751751+ call set_status(state, 'Unknown command on stream view.')
752752+ end select
753753+ end subroutine handle_stream_command
754754+755755+ subroutine app_loop()
756756+ type(app_state) :: state
757757+ type(stream_event) :: events(MAX_ITEMS)
758758+ character(len=512) :: line
759759+ logical :: quit
760760+ integer :: stream_n
761761+762762+ state = app_state()
763763+ call load_session_from_env(state%session)
764764+ call load_saved_session(state%session)
765765+ state%view = VIEW_HOME
766766+ state%view_title = 'Fortransky'
767767+ if (len_trim(state%session%access_jwt) > 0) then
768768+ call set_status(state, 'Loaded saved session. TUI commands are line based: type a key or command and press Enter.')
769769+ else
770770+ call set_status(state, 'Ready. TUI commands are line based: type a key or command and press Enter.')
771771+ end if
772772+ quit = .false.
773773+ stream_n = 0
774774+775775+ do while (.not. quit)
776776+ select case (state%view)
777777+ case (VIEW_HOME)
778778+ call draw_home(state)
779779+ case (VIEW_POST_LIST)
780780+ call draw_post_list(state)
781781+ case (VIEW_PROFILE)
782782+ call draw_profile(state)
783783+ case (VIEW_NOTIFICATIONS)
784784+ call draw_notifications(state)
785785+ case (VIEW_STREAM)
786786+ call draw_stream(events, stream_n, state%status)
787787+ end select
788788+789789+ call prompt_line('> ', line)
790790+ if (len_trim(line) == 0) cycle
791791+792792+ select case (state%view)
793793+ case (VIEW_HOME)
794794+ call handle_home_command(state, trim(line), quit, events, stream_n)
795795+ case (VIEW_POST_LIST)
796796+ call handle_post_command(state, trim(line))
797797+ case (VIEW_PROFILE)
798798+ call handle_profile_command(state, trim(line))
799799+ case (VIEW_NOTIFICATIONS)
800800+ call handle_notification_command(state, trim(line))
801801+ case (VIEW_STREAM)
802802+ call handle_stream_command(state, trim(line), events, stream_n)
803803+ end select
804804+ end do
805805+ end subroutine app_loop
806806+end module tui_mod
+42
src/util/process.f90
···11+module process_mod
22+ use iso_fortran_env, only: error_unit
33+ implicit none
44+contains
55+ function slurp_file(path) result(content)
66+ character(len=*), intent(in) :: path
77+ character(len=:), allocatable :: content
88+ integer :: unit, ios, size
99+ logical :: exists
1010+1111+ inquire(file=path, exist=exists, size=size)
1212+ if (.not. exists) then
1313+ content = ''
1414+ return
1515+ end if
1616+1717+ open(newunit=unit, file=path, status='old', access='stream', form='unformatted', action='read', iostat=ios)
1818+ if (ios /= 0) then
1919+ write(error_unit, '(a)') 'Failed to open file: ' // trim(path)
2020+ content = ''
2121+ return
2222+ end if
2323+2424+ allocate(character(len=size) :: content)
2525+ if (size > 0) then
2626+ read(unit, iostat=ios) content
2727+ if (ios /= 0) content = ''
2828+ else
2929+ content = ''
3030+ end if
3131+ close(unit)
3232+ end function slurp_file
3333+3434+ subroutine run_capture(command, output_path, exitstat)
3535+ character(len=*), intent(in) :: command, output_path
3636+ integer, intent(out) :: exitstat
3737+ character(len=:), allocatable :: cmd
3838+3939+ cmd = trim(command) // ' > ' // trim(output_path) // ' 2>&1'
4040+ call execute_command_line(cmd, exitstat=exitstat)
4141+ end subroutine run_capture
4242+end module process_mod
+80
src/util/strings.f90
···11+module strings_mod
22+ implicit none
33+contains
44+ pure function replace_all(text, old, new) result(out)
55+ character(len=*), intent(in) :: text, old, new
66+ character(len=:), allocatable :: out
77+ integer :: pos, start
88+99+ out = ''
1010+ start = 1
1111+ do
1212+ pos = index(text(start:), old)
1313+ if (pos == 0) then
1414+ out = out // text(start:)
1515+ exit
1616+ end if
1717+ pos = pos + start - 1
1818+ out = out // text(start:pos-1) // new
1919+ start = pos + len(old)
2020+ end do
2121+ end function replace_all
2222+2323+ pure function json_unescape(text) result(out)
2424+ character(len=*), intent(in) :: text
2525+ character(len=:), allocatable :: out
2626+ out = text
2727+ out = replace_all(out, '\\/','/')
2828+ out = replace_all(out, '\\n', ' ')
2929+ out = replace_all(out, '\\r', ' ')
3030+ out = replace_all(out, '\\t', ' ')
3131+ out = replace_all(out, '\\"', '"')
3232+ out = replace_all(out, '\\\\', '\\')
3333+ end function json_unescape
3434+3535+ pure function squeeze_spaces(text) result(out)
3636+ character(len=*), intent(in) :: text
3737+ character(len=:), allocatable :: out
3838+ integer :: i
3939+ logical :: prev_space
4040+4141+ out = ''
4242+ prev_space = .false.
4343+ do i = 1, len_trim(text)
4444+ select case (text(i:i))
4545+ case (' ', achar(9), achar(10), achar(13))
4646+ if (.not. prev_space) then
4747+ out = out // ' '
4848+ prev_space = .true.
4949+ end if
5050+ case default
5151+ out = out // text(i:i)
5252+ prev_space = .false.
5353+ end select
5454+ end do
5555+ end function squeeze_spaces
5656+5757+5858+ pure function url_encode(text) result(out)
5959+ character(len=*), intent(in) :: text
6060+ character(len=:), allocatable :: out
6161+ integer :: i, code, hi, lo
6262+ character(len=16), parameter :: hexdigits = '0123456789ABCDEF'
6363+6464+ out = ''
6565+ do i = 1, len_trim(text)
6666+ code = iachar(text(i:i))
6767+ select case (text(i:i))
6868+ case ('A':'Z','a':'z','0':'9','-','_','.','~')
6969+ out = out // text(i:i)
7070+ case (' ')
7171+ out = out // '%20'
7272+ case default
7373+ hi = code / 16 + 1
7474+ lo = mod(code, 16) + 1
7575+ out = out // '%' // hexdigits(hi:hi) // hexdigits(lo:lo)
7676+ end select
7777+ end do
7878+ end function url_encode
7979+8080+end module strings_mod